This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -13,3 +13,14 @@
|
||||
*.vsix
|
||||
|
||||
build/
|
||||
|
||||
# Runtime data (SQLite + media)
|
||||
data/RBS.db
|
||||
data/RBS.db-wal
|
||||
data/RBS.db-shm
|
||||
data/maps/*
|
||||
!data/maps/.gitkeep
|
||||
data/sounds/*
|
||||
!data/sounds/.gitkeep
|
||||
data/recordings/*
|
||||
!data/recordings/.gitkeep
|
||||
@@ -27,6 +27,8 @@ if(NOT nlohmann_json_POPULATED)
|
||||
FetchContent_Populate(nlohmann_json)
|
||||
endif()
|
||||
|
||||
find_package(SQLite3 REQUIRED)
|
||||
|
||||
add_executable(lidar_manager_web
|
||||
src/main.cpp
|
||||
src/app/lidar_manager_app.cpp
|
||||
@@ -38,6 +40,10 @@ add_executable(lidar_manager_web
|
||||
src/auth/auth_service.cpp
|
||||
src/domain/layout_schema.cpp
|
||||
src/domain/layout_profile.cpp
|
||||
src/storage/database.cpp
|
||||
src/storage/map_store.cpp
|
||||
src/storage/sound_store.cpp
|
||||
src/storage/dashboard_store.cpp
|
||||
src/storage/state_repository.cpp
|
||||
src/validation/sensor_validator.cpp
|
||||
src/server/static_file_server.cpp
|
||||
@@ -50,9 +56,11 @@ add_executable(lidar_manager_web
|
||||
src/robot/robot_runtime.cpp
|
||||
src/server/api_mission_routes.cpp
|
||||
src/server/api_robot_routes.cpp
|
||||
src/server/api_media_routes.cpp
|
||||
src/server/api_dashboard_routes.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads)
|
||||
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads SQLite::SQLite3)
|
||||
|
||||
target_include_directories(lidar_manager_web PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||
@@ -83,6 +91,7 @@ if(BUILD_TESTING)
|
||||
src/util/file_util.cpp
|
||||
src/util/string_util.cpp
|
||||
src/util/id_util.cpp
|
||||
src/storage/database.cpp
|
||||
src/mission/mission_store.cpp
|
||||
src/mission/mission_enqueue.cpp
|
||||
src/validation/sensor_validator.cpp
|
||||
@@ -104,7 +113,7 @@ if(BUILD_TESTING)
|
||||
target_compile_definitions(lidar_manager_tests PRIVATE
|
||||
TEST_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/data"
|
||||
)
|
||||
target_link_libraries(lidar_manager_tests PRIVATE GTest::gtest_main)
|
||||
target_link_libraries(lidar_manager_tests PRIVATE GTest::gtest_main SQLite::SQLite3)
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(lidar_manager_tests WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
|
||||
add_test(NAME unit COMMAND lidar_manager_tests)
|
||||
|
||||
@@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
cmake \
|
||||
git \
|
||||
libsqlite3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /src
|
||||
@@ -23,6 +24,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
htop \
|
||||
libsqlite3-0 \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -31,9 +33,9 @@ WORKDIR /app
|
||||
COPY --from=build /src/build/lidar_manager_web /app/lidar_manager_web
|
||||
COPY www ./www
|
||||
|
||||
RUN mkdir -p data/models
|
||||
RUN mkdir -p data/maps data/sounds data/recordings
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/app/lidar_manager_web"]
|
||||
CMD ["8080", "/app/www", "/app/data/state.json"]
|
||||
CMD ["8080", "/app/www", "/app/data/RBS.db"]
|
||||
|
||||
33
README.md
33
README.md
@@ -1,22 +1,24 @@
|
||||
# Robot App Web (Test3)
|
||||
# Robot App Web (RBS)
|
||||
|
||||
Chức năng:
|
||||
- Đăng ký danh sách cảm biến LiDAR (tên, ip, port)
|
||||
- Đăng ký IMU (tên, frame_id, topic, nguồn) và pose trên robot
|
||||
- Kéo thả icon LiDAR/IMU trên canvas để set vị trí (robot frame)
|
||||
- Nhiều layout — mỗi layout lưu tại `data/models/{id}.json`; catalog trong `data/state.json`
|
||||
- Nhiều layout — mỗi layout lưu profile trong SQLite (`layout_profiles`); catalog trong document `state`
|
||||
- Database SQLite: `data/RBS.db` (WAL mode). Thư mục media: `data/maps/`, `data/sounds/`, `data/recordings/`
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd /home/robotics/RD/Test3
|
||||
cd /home/robotics/RD/RBS
|
||||
# Ubuntu/Debian: sudo apt install libsqlite3-dev
|
||||
cmake -S . -B build
|
||||
cmake --build build -j
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
Chạy mặc định port 8080, phục vụ static từ `www/`, dữ liệu ở `data/state.json`:
|
||||
Chạy mặc định port 8080, phục vụ static từ `www/`, dữ liệu SQLite tại `data/RBS.db`:
|
||||
|
||||
```bash
|
||||
./build/lidar_manager_web
|
||||
@@ -25,14 +27,29 @@ Chạy mặc định port 8080, phục vụ static từ `www/`, dữ liệu ở
|
||||
Hoặc chỉ định:
|
||||
|
||||
```bash
|
||||
./build/lidar_manager_web 8080 ./www ./data/state.json
|
||||
./build/lidar_manager_web 8080 ./www ./data/RBS.db
|
||||
```
|
||||
|
||||
Mở trình duyệt: `http://localhost:8080/`
|
||||
|
||||
### API Maps & Sounds (SQLite)
|
||||
|
||||
| Method | Endpoint | Mô tả |
|
||||
|--------|----------|-------|
|
||||
| GET | `/api/maps` | Danh sách map |
|
||||
| POST | `/api/maps` | Tạo map (JSON metadata) |
|
||||
| GET/PUT/DELETE | `/api/maps/{id}` | CRUD map |
|
||||
| GET/POST | `/api/maps/{id}/image` | Tải/xem ảnh map (file trong `data/maps/{id}/`) |
|
||||
| GET | `/api/sounds` | Danh sách sound |
|
||||
| POST | `/api/sounds` | Tạo sound |
|
||||
| GET/PUT/DELETE | `/api/sounds/{id}` | CRUD sound |
|
||||
| GET/POST | `/api/sounds/{id}/file` | Tải/upload file âm thanh |
|
||||
| GET/PUT | `/api/dashboards` | Dashboard (server-side, thay localStorage) |
|
||||
| GET | `/api/recordings` | Stub — trả về `[]` (Phase sau) |
|
||||
|
||||
### Đăng nhập (Signing in — MiR §2.1)
|
||||
|
||||
Trang web **bắt buộc đăng nhập**. Hai tab: tên/mật khẩu hoặc **Mã PIN** (keypad 4 số). Tài khoản mặc định (`data/auth.json`):
|
||||
Trang web **bắt buộc đăng nhập**. Hai tab: tên/mật khẩu hoặc **Mã PIN** (keypad 4 số). Tài khoản mặc định (trong `data/RBS.db`, seed lần đầu):
|
||||
|
||||
| User | Password | Nhóm |
|
||||
|------|----------|------|
|
||||
@@ -50,7 +67,7 @@ Tài liệu đầy đủ: [`docs/Reference_guide.md` §2.1](docs/Reference_guide
|
||||
Mô phỏng cấu hình controller tối thiểu SICK (Dual-Core, 4 GB) trên máy dev:
|
||||
|
||||
```bash
|
||||
cd /home/robotics/RD/Test3
|
||||
cd /home/robotics/RD/RBS
|
||||
./scripts/lm.sh docker up
|
||||
# hoặc: sudo docker compose up --build -d
|
||||
```
|
||||
@@ -93,7 +110,7 @@ cat /proc/meminfo | head
|
||||
Chạy toàn bộ: unit C++ (GTest), API smoke (`curl`), pytest integration.
|
||||
|
||||
```bash
|
||||
cd /home/robotics/RD/Test3
|
||||
cd /home/robotics/RD/RBS
|
||||
chmod +x scripts/lm.sh scripts/test/*.sh
|
||||
./scripts/lm.sh test run
|
||||
```
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"allow_pin": false,
|
||||
"id": "group_distributors",
|
||||
"name": "Distributors",
|
||||
"permissions": {
|
||||
"config": "write",
|
||||
"dashboard": "write",
|
||||
"integrations": "write",
|
||||
"missions": "write",
|
||||
"users": "write"
|
||||
}
|
||||
},
|
||||
{
|
||||
"allow_pin": false,
|
||||
"id": "group_administrators",
|
||||
"name": "Administrators",
|
||||
"permissions": {
|
||||
"config": "write",
|
||||
"dashboard": "write",
|
||||
"integrations": "write",
|
||||
"missions": "write",
|
||||
"users": "write"
|
||||
}
|
||||
},
|
||||
{
|
||||
"allow_pin": true,
|
||||
"id": "group_users",
|
||||
"name": "Users",
|
||||
"permissions": {
|
||||
"config": "read",
|
||||
"dashboard": "write",
|
||||
"integrations": "read",
|
||||
"missions": "read",
|
||||
"users": "none"
|
||||
}
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"display_name": "Distributor",
|
||||
"enabled": true,
|
||||
"group_id": "group_distributors",
|
||||
"id": "user_distributor",
|
||||
"password_hash": "e245409d2efb801adfb55abc4f8298deff27e86d9c3ca11a05e1403de3d4cc44",
|
||||
"password_salt": "9c23467cf7b338b6cd27dab6f411135a",
|
||||
"pin_hash": null,
|
||||
"pin_salt": null,
|
||||
"username": "Distributor"
|
||||
},
|
||||
{
|
||||
"display_name": "Administrator",
|
||||
"enabled": true,
|
||||
"group_id": "group_administrators",
|
||||
"id": "user_admin",
|
||||
"password_hash": "d07eb95a7364e6fb9fe2ce152e3617dc0f23bb943263c5ca2f77a4cbbf5d5396",
|
||||
"password_salt": "804fec3b7b4910d6bdde1fb3782371e5",
|
||||
"pin_hash": null,
|
||||
"pin_salt": null,
|
||||
"username": "Admin"
|
||||
},
|
||||
{
|
||||
"display_name": "Operator",
|
||||
"enabled": true,
|
||||
"group_id": "group_users",
|
||||
"id": "user_operator",
|
||||
"password_hash": "b9091e9f6bcbd060231cc2f2e0ae028af88db0bca2af068548cb7604329fbdc9",
|
||||
"password_salt": "d2eedd0b0d2446af5ba875ebcff658f1",
|
||||
"pin_hash": "8dd8a6d52c7b7b76fde819aae2d5d3e3e06b321f71f61ef2918be879ace49d71",
|
||||
"pin_salt": "8d0ec0ed4339dafcb0f099a4c77895a2",
|
||||
"username": "User"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
}
|
||||
0
data/maps/.gitkeep
Normal file
0
data/maps/.gitkeep
Normal file
@@ -1,891 +0,0 @@
|
||||
{
|
||||
"queue": [
|
||||
{
|
||||
"created_at": "2026-06-15T03:25:12Z",
|
||||
"finished_at": "2026-06-15T03:26:42Z",
|
||||
"id": "6732b109c5f13b8f",
|
||||
"log": [
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Loop endless (simulated, max 10000)",
|
||||
"ts": "2026-06-15T03:25:12Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:12Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:13Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:14Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:14Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:15Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:16Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:17Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:17Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:18Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:18Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:19Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:20Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:21Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:21Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:22Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:23Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:24Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:24Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:25Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:25Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:26Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:27Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:28Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:28Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:29Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:30Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:31Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:31Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:32Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:32Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:33Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:34Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:35Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:35Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:36Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:37Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:38Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:38Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:39Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:39Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:40Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:41Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:42Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:42Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:43Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:44Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:45Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:45Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:46Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:46Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:47Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:48Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:49Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:49Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:50Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:51Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:52Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:52Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:53Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:53Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:54Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:55Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:56Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:56Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:57Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:58Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:25:59Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:25:59Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:00Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:00Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:01Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:02Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:03Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:03Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:04Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:05Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:06Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:06Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:07Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:07Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:08Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:09Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:10Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:10Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:11Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:12Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:13Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:13Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:14Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:14Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:15Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:16Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:17Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:17Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:18Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:19Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:20Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:20Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:21Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:21Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:22Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:23Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:24Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:24Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:25Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:26Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:27Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:27Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:28Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:28Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:29Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:30Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:31Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:31Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:32Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:33Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:34Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:34Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:35Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:35Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:36Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:37Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:38Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:38Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:39Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:40Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:41Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-15T03:26:41Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-15T03:26:42Z"
|
||||
},
|
||||
{
|
||||
"level": "warn",
|
||||
"message": "Mission hủy bởi operator",
|
||||
"ts": "2026-06-15T03:26:42Z"
|
||||
}
|
||||
],
|
||||
"mission": {
|
||||
"actions": [
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
|
||||
"kind": "action",
|
||||
"label": "Set PLC register",
|
||||
"params": {
|
||||
"action": "set",
|
||||
"register": 1,
|
||||
"value": 0
|
||||
},
|
||||
"type": "set_plc_register"
|
||||
},
|
||||
{
|
||||
"id": "a1",
|
||||
"kind": "action",
|
||||
"label": "Wait",
|
||||
"params": {
|
||||
"seconds": 1
|
||||
},
|
||||
"type": "wait"
|
||||
}
|
||||
],
|
||||
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
|
||||
"kind": "action",
|
||||
"label": "Loop",
|
||||
"params": {
|
||||
"count": 0,
|
||||
"mode": "endless"
|
||||
},
|
||||
"type": "loop"
|
||||
}
|
||||
],
|
||||
"description": "",
|
||||
"group": "Missions",
|
||||
"id": "5ae9dbcb0722dffb",
|
||||
"name": "Test run",
|
||||
"updated_at": "2026-06-15T03:08:55.138Z"
|
||||
},
|
||||
"mission_group": "Missions",
|
||||
"mission_id": "5ae9dbcb0722dffb",
|
||||
"mission_name": "Test run",
|
||||
"parameters": {},
|
||||
"priority": 0,
|
||||
"robot_id": "default",
|
||||
"source": "ui",
|
||||
"started_at": "2026-06-15T03:25:12Z",
|
||||
"status": "cancelled"
|
||||
},
|
||||
{
|
||||
"created_at": "2026-06-16T09:41:27Z",
|
||||
"finished_at": "2026-06-16T09:41:41Z",
|
||||
"id": "29d42c51d3a96bec",
|
||||
"log": [
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Loop endless (simulated, max 10000)",
|
||||
"ts": "2026-06-16T09:41:28Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-16T09:41:28Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-16T09:41:28Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-16T09:41:29Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-16T09:41:29Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-16T09:41:30Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-16T09:41:31Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-16T09:41:32Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-16T09:41:32Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-16T09:41:33Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-16T09:41:34Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-16T09:41:35Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-16T09:41:35Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-16T09:41:36Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-16T09:41:36Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-16T09:41:37Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-16T09:41:38Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-16T09:41:39Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-16T09:41:39Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Set PLC register (set_plc_register) simulated",
|
||||
"ts": "2026-06-16T09:41:40Z"
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"message": "Wait 1000ms",
|
||||
"ts": "2026-06-16T09:41:41Z"
|
||||
},
|
||||
{
|
||||
"level": "warn",
|
||||
"message": "Mission hủy bởi operator",
|
||||
"ts": "2026-06-16T09:41:41Z"
|
||||
}
|
||||
],
|
||||
"mission": {
|
||||
"actions": [
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
|
||||
"kind": "action",
|
||||
"label": "Set PLC register",
|
||||
"params": {
|
||||
"action": "set",
|
||||
"register": 1,
|
||||
"value": 0
|
||||
},
|
||||
"type": "set_plc_register"
|
||||
},
|
||||
{
|
||||
"id": "a1",
|
||||
"kind": "action",
|
||||
"label": "Wait",
|
||||
"params": {
|
||||
"seconds": 1
|
||||
},
|
||||
"type": "wait"
|
||||
}
|
||||
],
|
||||
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
|
||||
"kind": "action",
|
||||
"label": "Loop",
|
||||
"params": {
|
||||
"count": 0,
|
||||
"mode": "endless"
|
||||
},
|
||||
"type": "loop"
|
||||
}
|
||||
],
|
||||
"description": "",
|
||||
"group": "Missions",
|
||||
"id": "5ae9dbcb0722dffb",
|
||||
"name": "Test run",
|
||||
"updated_at": "2026-06-15T03:08:55.138Z"
|
||||
},
|
||||
"mission_group": "Missions",
|
||||
"mission_id": "5ae9dbcb0722dffb",
|
||||
"mission_name": "Test run",
|
||||
"parameters": {},
|
||||
"priority": 0,
|
||||
"robot_id": "default",
|
||||
"source": "ui",
|
||||
"started_at": "2026-06-16T09:41:28Z",
|
||||
"status": "cancelled"
|
||||
}
|
||||
],
|
||||
"runner": {
|
||||
"current_action": null,
|
||||
"current_queue_id": null,
|
||||
"message": "Đã hủy: Test run",
|
||||
"paused": false,
|
||||
"state": "idle",
|
||||
"updated_at": "2026-06-16T09:41:41Z"
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"dashboard": {
|
||||
"widgets": []
|
||||
},
|
||||
"groups": [
|
||||
"Missions",
|
||||
"Move",
|
||||
"Logic",
|
||||
"I/O",
|
||||
"Cart",
|
||||
"Misc"
|
||||
],
|
||||
"missions": [
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
|
||||
"kind": "action",
|
||||
"label": "Set PLC register",
|
||||
"params": {
|
||||
"action": "set",
|
||||
"register": 1,
|
||||
"value": 0
|
||||
},
|
||||
"type": "set_plc_register"
|
||||
},
|
||||
{
|
||||
"id": "a1",
|
||||
"kind": "action",
|
||||
"label": "Wait",
|
||||
"params": {
|
||||
"seconds": 1
|
||||
},
|
||||
"type": "wait"
|
||||
}
|
||||
],
|
||||
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
|
||||
"kind": "action",
|
||||
"label": "Loop",
|
||||
"params": {
|
||||
"count": 0,
|
||||
"mode": "endless"
|
||||
},
|
||||
"type": "loop"
|
||||
}
|
||||
],
|
||||
"description": "",
|
||||
"group": "Missions",
|
||||
"id": "5ae9dbcb0722dffb",
|
||||
"name": "Test run",
|
||||
"updated_at": "2026-06-15T03:08:55.138Z"
|
||||
},
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"id": "a1",
|
||||
"kind": "action",
|
||||
"label": "Wait",
|
||||
"params": {
|
||||
"seconds": 1
|
||||
},
|
||||
"type": "wait"
|
||||
}
|
||||
],
|
||||
"description": "",
|
||||
"group": "Missions",
|
||||
"id": "68950059fc0bd633",
|
||||
"name": "Test run 3",
|
||||
"updated_at": "2026-06-13T04:45:08Z"
|
||||
}
|
||||
],
|
||||
"robots": [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "Robot chính",
|
||||
"online": true,
|
||||
"serial": "PX-001"
|
||||
}
|
||||
],
|
||||
"schedules": [],
|
||||
"triggers": [],
|
||||
"version": 1
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
{
|
||||
"created_at": "2026-05-29T08:27:25Z",
|
||||
"id": "a07ab938d9029ef1",
|
||||
"imus": [
|
||||
{
|
||||
"enabled": true,
|
||||
"frame_id": "imu_link",
|
||||
"id": "f7ddb6d2c3c1c5cf",
|
||||
"name": "IMU test",
|
||||
"rate_hz": 100,
|
||||
"source": "onboard",
|
||||
"topic": "imu/data"
|
||||
}
|
||||
],
|
||||
"layout": {
|
||||
"imuPoses": {
|
||||
"f7ddb6d2c3c1c5cf": {
|
||||
"x": 196.14886948882076,
|
||||
"y": 0.1286840744156286,
|
||||
"yaw_deg": 0,
|
||||
"z": 0.1
|
||||
}
|
||||
},
|
||||
"imuPosesFrame": "robot",
|
||||
"lidarPoses": {
|
||||
"02c4b7f4de7bd639": {
|
||||
"theta_deg": 45,
|
||||
"x": 215,
|
||||
"y": 40
|
||||
},
|
||||
"1e591c93c581f705": {
|
||||
"theta_deg": -45,
|
||||
"x": 215.39984362180326,
|
||||
"y": -40
|
||||
},
|
||||
"242be6d6e782ecdf": {
|
||||
"theta_deg": 180,
|
||||
"x": 145,
|
||||
"y": -0.3738614899159438
|
||||
}
|
||||
},
|
||||
"lidarPosesFrame": "robot",
|
||||
"lidarPositions": {},
|
||||
"map": {
|
||||
"height": 600,
|
||||
"width": 800
|
||||
},
|
||||
"robot": {
|
||||
"bicycle": {
|
||||
"display": {
|
||||
"L_px": 240.0,
|
||||
"r_px": 60.0,
|
||||
"scale_m_per_px": 0.005
|
||||
},
|
||||
"drive": {
|
||||
"joint_name": "rear_wheel_joint"
|
||||
},
|
||||
"limits": {
|
||||
"cmd_vel_timeout_s": 0.25,
|
||||
"linear": {
|
||||
"max_acceleration": 0.8,
|
||||
"max_velocity": 1
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"joint_name": "front_steer_joint",
|
||||
"max_angle_deg": 60,
|
||||
"preview_deg": 15
|
||||
},
|
||||
"wheel_radius_m": 0.15,
|
||||
"wheelbase_m": 1.2,
|
||||
"wheels": [
|
||||
{
|
||||
"id": "rear",
|
||||
"joint_name": "rear_wheel_joint",
|
||||
"motor": {
|
||||
"gear_ratio": 20,
|
||||
"invert": false,
|
||||
"model": "m2dc10a",
|
||||
"vendor": "moons"
|
||||
},
|
||||
"role": "drive",
|
||||
"x_m": 0,
|
||||
"y_m": 0
|
||||
},
|
||||
{
|
||||
"id": "front",
|
||||
"joint_name": "front_steer_joint",
|
||||
"motor": {
|
||||
"gear_ratio": 20,
|
||||
"invert": false,
|
||||
"model": "m2dc10a",
|
||||
"vendor": "moons"
|
||||
},
|
||||
"role": "steer",
|
||||
"x_m": 1.2,
|
||||
"y_m": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"diff": {
|
||||
"b": 200.0,
|
||||
"d": 120.0,
|
||||
"display": {
|
||||
"b_px": 200.0,
|
||||
"d_px": 120.0,
|
||||
"scale_m_per_px": 0.005
|
||||
},
|
||||
"limits": {
|
||||
"angular": {
|
||||
"max_acceleration": 1.5,
|
||||
"max_velocity": 1.7
|
||||
},
|
||||
"cmd_vel_timeout_s": 0.25,
|
||||
"linear": {
|
||||
"max_acceleration": 0.8,
|
||||
"max_velocity": 1,
|
||||
"min_acceleration": -0.4,
|
||||
"min_velocity": -0.5
|
||||
}
|
||||
},
|
||||
"wheel_radius_m": 0.3,
|
||||
"wheel_radius_multiplier": 1,
|
||||
"wheel_separation_m": 1,
|
||||
"wheel_separation_multiplier": 1,
|
||||
"wheels": [
|
||||
{
|
||||
"id": "left",
|
||||
"joint_name": "wheel_left_joint",
|
||||
"motor": {
|
||||
"gear_ratio": 20,
|
||||
"invert": false,
|
||||
"model": "m2dc10a",
|
||||
"vendor": "moons"
|
||||
},
|
||||
"side": "left",
|
||||
"y_m": 0.5
|
||||
},
|
||||
{
|
||||
"id": "right",
|
||||
"joint_name": "wheel_right_joint",
|
||||
"motor": {
|
||||
"gear_ratio": 20,
|
||||
"invert": false,
|
||||
"model": "m2dc10a",
|
||||
"vendor": "moons"
|
||||
},
|
||||
"side": "right",
|
||||
"y_m": -0.5
|
||||
}
|
||||
]
|
||||
},
|
||||
"footprint": [
|
||||
{
|
||||
"x": 249.49596246923238,
|
||||
"y": 76.53128468019501
|
||||
},
|
||||
{
|
||||
"x": 252.05138984920825,
|
||||
"y": -73.40426803273583
|
||||
},
|
||||
{
|
||||
"x": 146.0988213814129,
|
||||
"y": -73.14624094113161
|
||||
},
|
||||
{
|
||||
"x": 146.4579317148541,
|
||||
"y": -36.76005121552378
|
||||
},
|
||||
{
|
||||
"x": -24.190052366845578,
|
||||
"y": -36.232153738354725
|
||||
},
|
||||
{
|
||||
"x": -23.18092513013994,
|
||||
"y": 31.895774646867324
|
||||
},
|
||||
{
|
||||
"x": 149.1507088069675,
|
||||
"y": 31.363038836025066
|
||||
},
|
||||
{
|
||||
"x": 148.2973527630072,
|
||||
"y": 77.68471811183447
|
||||
}
|
||||
],
|
||||
"footprint_params": {
|
||||
"length_m": 1.69,
|
||||
"radius_m": 0.8432486399759678,
|
||||
"segments": 32,
|
||||
"sides": 6,
|
||||
"width_m": 1.28
|
||||
},
|
||||
"footprint_shape": "custom",
|
||||
"frame_id": "base_footprint",
|
||||
"model": "bicycle",
|
||||
"x": 400,
|
||||
"y": 300,
|
||||
"yaw_deg": 0
|
||||
}
|
||||
},
|
||||
"lidars": [
|
||||
{
|
||||
"id": "02c4b7f4de7bd639",
|
||||
"ip": "192.168.1.11",
|
||||
"name": "Front",
|
||||
"port": 2112
|
||||
},
|
||||
{
|
||||
"id": "1e591c93c581f705",
|
||||
"ip": "192.168.1.12",
|
||||
"name": "Back",
|
||||
"port": 2112
|
||||
},
|
||||
{
|
||||
"id": "242be6d6e782ecdf",
|
||||
"ip": "192.168.1.15",
|
||||
"name": "Oile",
|
||||
"port": 2112
|
||||
}
|
||||
],
|
||||
"name": "Mặc định",
|
||||
"updated_at": "2026-05-29T10:09:07Z"
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
{
|
||||
"created_at": "2026-05-29T08:40:51Z",
|
||||
"id": "ea89e39c835c0557",
|
||||
"imus": [
|
||||
{
|
||||
"enabled": true,
|
||||
"frame_id": "imu_link",
|
||||
"id": "719a21772e114466",
|
||||
"name": "IMU",
|
||||
"rate_hz": 100,
|
||||
"source": "external",
|
||||
"topic": "imu/data"
|
||||
}
|
||||
],
|
||||
"layout": {
|
||||
"imuPoses": {
|
||||
"719a21772e114466": {
|
||||
"x": 0.06910131801805619,
|
||||
"y": 0.8135664703630141,
|
||||
"yaw_deg": 0,
|
||||
"z": 0.1
|
||||
}
|
||||
},
|
||||
"imuPosesFrame": "robot",
|
||||
"lidarPoses": {
|
||||
"40235913b52d8101": {
|
||||
"theta_deg": -135,
|
||||
"x": -120,
|
||||
"y": -90
|
||||
},
|
||||
"f4504deeb605e6ed": {
|
||||
"theta_deg": 45,
|
||||
"x": 120,
|
||||
"y": 90
|
||||
}
|
||||
},
|
||||
"lidarPosesFrame": "robot",
|
||||
"lidarPositions": {},
|
||||
"map": {
|
||||
"height": 600,
|
||||
"width": 800
|
||||
},
|
||||
"robot": {
|
||||
"bicycle": {
|
||||
"display": {
|
||||
"L_px": 240.0,
|
||||
"r_px": 60.0,
|
||||
"scale_m_per_px": 0.005
|
||||
},
|
||||
"drive": {
|
||||
"joint_name": "rear_wheel_joint"
|
||||
},
|
||||
"limits": {
|
||||
"cmd_vel_timeout_s": 0.25,
|
||||
"linear": {
|
||||
"max_acceleration": 0.8,
|
||||
"max_velocity": 1
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"joint_name": "front_steer_joint",
|
||||
"max_angle_deg": 60,
|
||||
"preview_deg": 15
|
||||
},
|
||||
"wheel_radius_m": 0.15,
|
||||
"wheelbase_m": 1.2,
|
||||
"wheels": [
|
||||
{
|
||||
"id": "rear",
|
||||
"joint_name": "rear_wheel_joint",
|
||||
"motor": {
|
||||
"gear_ratio": 20,
|
||||
"invert": false,
|
||||
"model": "m2dc10a",
|
||||
"vendor": "moons"
|
||||
},
|
||||
"role": "drive",
|
||||
"x_m": 0,
|
||||
"y_m": 0
|
||||
},
|
||||
{
|
||||
"id": "front",
|
||||
"joint_name": "front_steer_joint",
|
||||
"motor": {
|
||||
"gear_ratio": 20,
|
||||
"invert": false,
|
||||
"model": "m2dc10a",
|
||||
"vendor": "moons"
|
||||
},
|
||||
"role": "steer",
|
||||
"x_m": 1.2,
|
||||
"y_m": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"diff": {
|
||||
"b": 220.0,
|
||||
"d": 120.0,
|
||||
"display": {
|
||||
"b_px": 220.0,
|
||||
"d_px": 120.0,
|
||||
"scale_m_per_px": 0.005
|
||||
},
|
||||
"limits": {
|
||||
"angular": {
|
||||
"max_acceleration": 1.5,
|
||||
"max_velocity": 1.7
|
||||
},
|
||||
"cmd_vel_timeout_s": 0.25,
|
||||
"linear": {
|
||||
"max_acceleration": 0.8,
|
||||
"max_velocity": 1,
|
||||
"min_acceleration": -0.8,
|
||||
"min_velocity": -0.5
|
||||
}
|
||||
},
|
||||
"wheel_radius_m": 0.3,
|
||||
"wheel_radius_multiplier": 1,
|
||||
"wheel_separation_m": 1.1,
|
||||
"wheel_separation_multiplier": 1,
|
||||
"wheels": [
|
||||
{
|
||||
"id": "left",
|
||||
"joint_name": "wheel_left_joint",
|
||||
"motor": {
|
||||
"gear_ratio": 10,
|
||||
"invert": true,
|
||||
"model": "m2dc10a",
|
||||
"vendor": "moons"
|
||||
},
|
||||
"side": "left",
|
||||
"y_m": 0.55
|
||||
},
|
||||
{
|
||||
"id": "right",
|
||||
"joint_name": "wheel_right_joint",
|
||||
"motor": {
|
||||
"gear_ratio": 10,
|
||||
"invert": false,
|
||||
"model": "m2dc10a",
|
||||
"vendor": "moons"
|
||||
},
|
||||
"side": "right",
|
||||
"y_m": -0.55
|
||||
}
|
||||
]
|
||||
},
|
||||
"footprint": [
|
||||
{
|
||||
"x": 150,
|
||||
"y": 120
|
||||
},
|
||||
{
|
||||
"x": 150,
|
||||
"y": -120
|
||||
},
|
||||
{
|
||||
"x": -150,
|
||||
"y": -120
|
||||
},
|
||||
{
|
||||
"x": -150,
|
||||
"y": 120
|
||||
}
|
||||
],
|
||||
"footprint_params": {
|
||||
"length_m": 1.5,
|
||||
"radius_m": 1,
|
||||
"segments": 32,
|
||||
"sides": 6,
|
||||
"width_m": 1.2
|
||||
},
|
||||
"footprint_shape": "rectangle",
|
||||
"frame_id": "base_footprint",
|
||||
"model": "diff",
|
||||
"x": 400,
|
||||
"y": 300,
|
||||
"yaw_deg": 0
|
||||
}
|
||||
},
|
||||
"lidars": [
|
||||
{
|
||||
"id": "f4504deeb605e6ed",
|
||||
"ip": "192.168.1.11",
|
||||
"name": "Front",
|
||||
"port": 2112
|
||||
},
|
||||
{
|
||||
"id": "40235913b52d8101",
|
||||
"ip": "192.168.1.11",
|
||||
"name": "Back",
|
||||
"port": 2112
|
||||
}
|
||||
],
|
||||
"name": "T800",
|
||||
"updated_at": "2026-06-13T07:03:01Z"
|
||||
}
|
||||
0
data/recordings/.gitkeep
Normal file
0
data/recordings/.gitkeep
Normal file
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"battery_charging": false,
|
||||
"battery_percent": 54,
|
||||
"cmd_angular": 0.0,
|
||||
"cmd_linear": 0.0,
|
||||
"error": null,
|
||||
"health": "ok",
|
||||
"joystick_engaged": false,
|
||||
"joystick_speed": "fast",
|
||||
"message": "Waiting for new missions...",
|
||||
"motion": "running",
|
||||
"updated_at": "2026-06-16T10:33:19Z"
|
||||
}
|
||||
0
data/sounds/.gitkeep
Normal file
0
data/sounds/.gitkeep
Normal file
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"active_layout_id": "ea89e39c835c0557",
|
||||
"layouts": [
|
||||
{
|
||||
"created_at": "2026-05-29T08:27:25Z",
|
||||
"id": "a07ab938d9029ef1",
|
||||
"imu_count": 1,
|
||||
"lidar_count": 3,
|
||||
"model": "bicycle",
|
||||
"name": "Mặc định",
|
||||
"updated_at": "2026-05-29T10:09:07Z"
|
||||
},
|
||||
{
|
||||
"created_at": "2026-05-29T08:40:51Z",
|
||||
"id": "ea89e39c835c0557",
|
||||
"imu_count": 1,
|
||||
"lidar_count": 2,
|
||||
"model": "diff",
|
||||
"name": "T800",
|
||||
"updated_at": "2026-06-13T07:03:01Z"
|
||||
}
|
||||
],
|
||||
"version": 3
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
name: rbs
|
||||
|
||||
services:
|
||||
lidar-manager:
|
||||
build: .
|
||||
image: lidar-manager-web:test3
|
||||
image: lidar-manager-web:RBS
|
||||
container_name: lidar-manager-limited
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
@@ -30,7 +30,7 @@ Giao diện web trên robot: **responsive** (PC, tablet, portrait/landscape). Tr
|
||||
|
||||
### 2.1 Signing in
|
||||
|
||||
> **Test3:** tính năng đã triển khai — xem [Test3 — Signing in](#test3--signing-in-đã-triển-khai).
|
||||
> **RBS:** tính năng đã triển khai — xem [RBS — Signing in](#RBS--signing-in-đã-triển-khai).
|
||||
|
||||
#### Luồng truy cập (MiR)
|
||||
|
||||
@@ -89,7 +89,7 @@ Admin có thể tạo thêm user group (ví dụ `Operators`) và gán quyền t
|
||||
- Không nên nhiều người dùng chung một account.
|
||||
- SW mới (~2023): **auto sign-out** theo user group; MiR Fleet hỗ trợ **OAuth 2.0 / OpenID Connect**.
|
||||
|
||||
#### Test3 — Signing in (đã triển khai)
|
||||
#### RBS — Signing in (đã triển khai)
|
||||
|
||||
Tính năng đăng nhập theo MiR §2.1 đã tích hợp vào `lidar_manager_web`. Toàn bộ API (trừ health/login/logout) yêu cầu session; UI bị chặn cho đến khi đăng nhập thành công.
|
||||
|
||||
@@ -214,9 +214,9 @@ Hash: SHA-256 + salt (`sha256(salt:password)` / `sha256(salt:pin:pin)`).
|
||||
- Docker: `www/` copy lúc build → `docker compose up --build -d` sau sửa UI.
|
||||
- Hard refresh (`Ctrl+Shift+R`) nếu cache JS/CSS.
|
||||
|
||||
##### So sánh MiR ↔ Test3
|
||||
##### So sánh MiR ↔ RBS
|
||||
|
||||
| MiR §2.1 | Test3 |
|
||||
| MiR §2.1 | RBS |
|
||||
|----------|-------|
|
||||
| Sign in bắt buộc | Có |
|
||||
| Tab password \| PIN + keypad | Có |
|
||||
@@ -374,12 +374,12 @@ Thiết lập hệ thống: map → chỉnh map (positions, zones) → missions.
|
||||
|
||||
## 7. Dashboard widgets
|
||||
|
||||
| Widget MiR | Test3 (Cách B) |
|
||||
| Widget MiR | RBS (Cách B) |
|
||||
|------------|----------------|
|
||||
| Mission button | `dashboard.js` — mission_button |
|
||||
| Mission group | mission_group |
|
||||
| Mission queue | mission_queue |
|
||||
| Pause/Continue | pause_continue (+ **Hủy mission** bổ sung trong Test3) |
|
||||
| Pause/Continue | pause_continue (+ **Hủy mission** bổ sung trong RBS) |
|
||||
|
||||
---
|
||||
|
||||
@@ -423,9 +423,9 @@ Xác thực: HTTP Basic (user/password robot).
|
||||
|
||||
---
|
||||
|
||||
## 10. Mapping sang dự án Test3
|
||||
## 10. Mapping sang dự án RBS
|
||||
|
||||
| Khái niệm MiR Reference Guide | Test3 |
|
||||
| Khái niệm MiR Reference Guide | RBS |
|
||||
|------------------------------|--------|
|
||||
| Setup → Missions → queue | **Cách A** — `www/missions.js` |
|
||||
| Dashboard widgets | **Cách B** — `www/dashboard.js` |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Scripts Test3
|
||||
# Scripts RBS
|
||||
|
||||
CLI thống nhất: `./scripts/lm.sh <nhóm> <lệnh>`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Shared paths and helpers for Test3 scripts.
|
||||
# Shared paths and helpers for RBS scripts.
|
||||
# shellcheck shell=bash
|
||||
|
||||
_lm_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# PhenikaaX Test3 — CLI gom script theo nhóm.
|
||||
# PhenikaaX RBS — CLI gom script theo nhóm.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
#include "robot/robot_runtime.hpp"
|
||||
#include "server/api_server.hpp"
|
||||
#include "server/static_file_server.hpp"
|
||||
#include "storage/dashboard_store.hpp"
|
||||
#include "storage/database.hpp"
|
||||
#include "storage/map_store.hpp"
|
||||
#include "storage/sound_store.hpp"
|
||||
#include "storage/state_repository.hpp"
|
||||
|
||||
#include <httplib.h>
|
||||
@@ -25,14 +29,24 @@ LidarManagerApp::LidarManagerApp(int port,
|
||||
|
||||
int LidarManagerApp::run()
|
||||
{
|
||||
StateRepository repo(data_path_);
|
||||
const std::filesystem::path data_dir = data_path_.parent_path();
|
||||
Database database(data_dir);
|
||||
std::string db_err;
|
||||
if (!database.init(db_err))
|
||||
{
|
||||
std::fprintf(stderr, "database init failed: %s\n", db_err.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
StateRepository repo(data_path_, database);
|
||||
repo.load();
|
||||
|
||||
const std::filesystem::path mission_queue_path = data_path_.parent_path() / "mission_queue.json";
|
||||
const std::filesystem::path missions_store_path = data_path_.parent_path() / "missions.json";
|
||||
MissionQueue mission_queue(mission_queue_path);
|
||||
MissionStore mission_store(missions_store_path);
|
||||
RobotRuntime robot_runtime(data_path_.parent_path() / "robot_runtime.json", mission_queue);
|
||||
MissionQueue mission_queue(database);
|
||||
MissionStore mission_store(database);
|
||||
RobotRuntime robot_runtime(database, mission_queue);
|
||||
MapStore map_store(database);
|
||||
SoundStore sound_store(database);
|
||||
DashboardStore dashboard_store(database);
|
||||
|
||||
const auto enqueue_fn = [&mission_store, &mission_queue](const nlohmann::json& request, std::string& err) -> bool {
|
||||
nlohmann::json payload;
|
||||
@@ -44,24 +58,33 @@ int LidarManagerApp::run()
|
||||
ModbusTriggerService modbus(mission_store, enqueue_fn, 5502);
|
||||
MissionScheduler scheduler(mission_store, enqueue_fn);
|
||||
|
||||
AuthService auth(data_path_.parent_path() / "auth.json");
|
||||
AuthService auth(database);
|
||||
|
||||
httplib::Server svr;
|
||||
svr.set_pre_routing_handler([&auth](const httplib::Request& req, httplib::Response& res) {
|
||||
return auth.preRoute(req, res);
|
||||
});
|
||||
|
||||
ApiServer api(repo, mission_queue, mission_store, modbus, scheduler, robot_runtime);
|
||||
ApiServer api(repo,
|
||||
mission_queue,
|
||||
mission_store,
|
||||
modbus,
|
||||
scheduler,
|
||||
robot_runtime,
|
||||
map_store,
|
||||
sound_store,
|
||||
dashboard_store);
|
||||
api.registerRoutes(svr);
|
||||
auth.registerRoutes(svr);
|
||||
StaticFileServer::mount(svr, www_root_);
|
||||
|
||||
std::fprintf(stderr,
|
||||
"lidar_manager_web listening on http://0.0.0.0:%d (www=%s, state=%s, models=%s)\n",
|
||||
"lidar_manager_web listening on http://0.0.0.0:%d (www=%s, db=%s, maps=%s, sounds=%s)\n",
|
||||
port_,
|
||||
www_root_.string().c_str(),
|
||||
data_path_.string().c_str(),
|
||||
(data_path_.parent_path() / "models").string().c_str());
|
||||
database.dbPath().string().c_str(),
|
||||
database.mapsDir().string().c_str(),
|
||||
database.soundsDir().string().c_str());
|
||||
std::fprintf(stderr, "MiR REST API: http://0.0.0.0:%d/api/v2.0.0/mission_queue\n", port_);
|
||||
std::fprintf(stderr, "Modbus TCP triggers: port 5502 (coils 1001-2000)\n");
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "auth/auth_service.hpp"
|
||||
|
||||
#include "storage/database.hpp"
|
||||
#include "util/crypto_util.hpp"
|
||||
#include "util/file_util.hpp"
|
||||
#include "util/http_util.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
@@ -56,7 +56,7 @@ nlohmann::json makeUser(const std::string& id,
|
||||
|
||||
} // namespace
|
||||
|
||||
AuthService::AuthService(std::filesystem::path store_path) : store_path_(std::move(store_path))
|
||||
AuthService::AuthService(Database& db) : db_(db)
|
||||
{
|
||||
loadOrSeed();
|
||||
}
|
||||
@@ -65,17 +65,8 @@ void AuthService::loadOrSeed()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
data_ = nlohmann::json::object();
|
||||
if (std::filesystem::exists(store_path_))
|
||||
{
|
||||
try
|
||||
{
|
||||
data_ = nlohmann::json::parse(FileUtil::readBinary(store_path_));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
if (!db_.getDocument("auth", data_))
|
||||
data_ = nlohmann::json::object();
|
||||
}
|
||||
}
|
||||
|
||||
if (!data_.contains("version"))
|
||||
data_["version"] = 1;
|
||||
@@ -109,10 +100,7 @@ void AuthService::loadOrSeed()
|
||||
|
||||
void AuthService::saveUnlocked()
|
||||
{
|
||||
const auto parent = store_path_.parent_path();
|
||||
if (!parent.empty())
|
||||
std::filesystem::create_directories(parent);
|
||||
FileUtil::writeBinaryAtomic(store_path_, data_.dump(2));
|
||||
db_.setDocument("auth", data_);
|
||||
}
|
||||
|
||||
const AuthSession* AuthService::currentSession() const
|
||||
@@ -164,7 +152,11 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
|
||||
path.rfind("/api/robots", 0) == 0 || path.rfind("/api/fleet", 0) == 0 ||
|
||||
path.rfind("/api/modbus", 0) == 0 || path.rfind("/api/v2.0.0/", 0) == 0)
|
||||
return "integrations";
|
||||
if (path.rfind("/api/", 0) == 0)
|
||||
if (path.rfind("/api/dashboards", 0) == 0)
|
||||
return "dashboard";
|
||||
if (path.rfind("/api/sounds", 0) == 0)
|
||||
return "integrations";
|
||||
if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/recordings", 0) == 0)
|
||||
return "config";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
struct AuthSession
|
||||
{
|
||||
std::string token;
|
||||
@@ -24,7 +26,7 @@ struct AuthSession
|
||||
class AuthService
|
||||
{
|
||||
public:
|
||||
explicit AuthService(std::filesystem::path store_path);
|
||||
explicit AuthService(Database& db);
|
||||
|
||||
httplib::Server::HandlerResponse preRoute(const httplib::Request& req, httplib::Response& res);
|
||||
|
||||
@@ -55,7 +57,7 @@ public:
|
||||
void registerRoutes(httplib::Server& svr);
|
||||
|
||||
private:
|
||||
std::filesystem::path store_path_;
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
nlohmann::json data_;
|
||||
std::unordered_map<std::string, AuthSession> sessions_;
|
||||
|
||||
@@ -8,7 +8,7 @@ int main(int argc, char** argv)
|
||||
const int port = (argc >= 2) ? std::atoi(argv[1]) : 8080;
|
||||
const std::filesystem::path www_root = (argc >= 3) ? std::filesystem::path(argv[2]) : std::filesystem::path("www");
|
||||
const std::filesystem::path data_path =
|
||||
(argc >= 4) ? std::filesystem::path(argv[3]) : std::filesystem::path("data/state.json");
|
||||
(argc >= 4) ? std::filesystem::path(argv[3]) : std::filesystem::path("data/RBS.db");
|
||||
|
||||
lm::LidarManagerApp app(port, www_root, data_path);
|
||||
return app.run();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "mission/mission_queue.hpp"
|
||||
|
||||
#include "util/file_util.hpp"
|
||||
#include "storage/database.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
|
||||
#include <chrono>
|
||||
@@ -40,7 +40,7 @@ double paramNumber(const nlohmann::json& params, const std::string& key, double
|
||||
|
||||
} // namespace
|
||||
|
||||
MissionQueue::MissionQueue(std::filesystem::path queue_path) : queue_path_(std::move(queue_path))
|
||||
MissionQueue::MissionQueue(Database& db) : db_(db)
|
||||
{
|
||||
load();
|
||||
ensureRunnerDefaults();
|
||||
@@ -60,11 +60,9 @@ void MissionQueue::load()
|
||||
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||
queue_ = nlohmann::json::array();
|
||||
runner_ = nlohmann::json::object();
|
||||
if (!std::filesystem::exists(queue_path_))
|
||||
nlohmann::json parsed;
|
||||
if (!db_.getDocument("mission_queue", parsed))
|
||||
return;
|
||||
try
|
||||
{
|
||||
const auto parsed = nlohmann::json::parse(FileUtil::readBinary(queue_path_));
|
||||
if (parsed.is_object())
|
||||
{
|
||||
if (parsed.contains("queue") && parsed["queue"].is_array())
|
||||
@@ -72,18 +70,13 @@ void MissionQueue::load()
|
||||
if (parsed.contains("runner") && parsed["runner"].is_object())
|
||||
runner_ = parsed["runner"];
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
queue_ = nlohmann::json::array();
|
||||
}
|
||||
ensureRunnerDefaults();
|
||||
}
|
||||
|
||||
void MissionQueue::saveUnlocked() const
|
||||
{
|
||||
const nlohmann::json payload = {{"queue", queue_}, {"runner", runner_}};
|
||||
FileUtil::writeBinaryAtomic(queue_path_, payload.dump(2));
|
||||
db_.setDocument("mission_queue", payload);
|
||||
}
|
||||
|
||||
void MissionQueue::ensureRunnerDefaults()
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -11,10 +10,12 @@
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class MissionQueue
|
||||
{
|
||||
public:
|
||||
explicit MissionQueue(std::filesystem::path queue_path);
|
||||
explicit MissionQueue(Database& db);
|
||||
~MissionQueue();
|
||||
|
||||
MissionQueue(const MissionQueue&) = delete;
|
||||
@@ -34,7 +35,7 @@ public:
|
||||
private:
|
||||
enum class LoopControl { None, Break, Continue };
|
||||
|
||||
std::filesystem::path queue_path_;
|
||||
Database& db_;
|
||||
mutable std::recursive_mutex mu_;
|
||||
nlohmann::json queue_;
|
||||
nlohmann::json runner_;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "mission/mission_store.hpp"
|
||||
|
||||
#include "util/file_util.hpp"
|
||||
#include "storage/database.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
@@ -18,7 +18,7 @@ bool coilIdValid(int coil_id)
|
||||
|
||||
} // namespace
|
||||
|
||||
MissionStore::MissionStore(std::filesystem::path store_path) : store_path_(std::move(store_path))
|
||||
MissionStore::MissionStore(Database& db) : db_(db)
|
||||
{
|
||||
load();
|
||||
}
|
||||
@@ -27,21 +27,10 @@ void MissionStore::load()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
data_ = nlohmann::json::object();
|
||||
if (!std::filesystem::exists(store_path_))
|
||||
{
|
||||
const bool existed = db_.getDocument("missions", data_);
|
||||
ensureSchemaUnlocked();
|
||||
if (!existed)
|
||||
saveUnlocked();
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
data_ = nlohmann::json::parse(FileUtil::readBinary(store_path_));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
data_ = nlohmann::json::object();
|
||||
}
|
||||
ensureSchemaUnlocked();
|
||||
}
|
||||
|
||||
void MissionStore::ensureSchemaUnlocked()
|
||||
@@ -69,7 +58,7 @@ void MissionStore::ensureSchemaUnlocked()
|
||||
|
||||
void MissionStore::saveUnlocked() const
|
||||
{
|
||||
FileUtil::writeBinaryAtomic(store_path_, data_.dump(2));
|
||||
db_.setDocument("missions", data_);
|
||||
}
|
||||
|
||||
nlohmann::json MissionStore::snapshot() const
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class MissionStore
|
||||
{
|
||||
public:
|
||||
explicit MissionStore(std::filesystem::path store_path);
|
||||
explicit MissionStore(Database& db);
|
||||
|
||||
nlohmann::json snapshot() const;
|
||||
bool replace(const nlohmann::json& payload, std::string& err);
|
||||
@@ -34,7 +35,7 @@ public:
|
||||
nlohmann::json listRobots() const;
|
||||
|
||||
private:
|
||||
std::filesystem::path store_path_;
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
nlohmann::json data_;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "robot/robot_runtime.hpp"
|
||||
|
||||
#include "mission/mission_queue.hpp"
|
||||
#include "util/file_util.hpp"
|
||||
#include "storage/database.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -16,8 +16,8 @@ constexpr const char* kDefaultMessage = "Waiting for new missions...";
|
||||
|
||||
} // namespace
|
||||
|
||||
RobotRuntime::RobotRuntime(std::filesystem::path runtime_path, MissionQueue& mission_queue)
|
||||
: runtime_path_(std::move(runtime_path)), mission_queue_(mission_queue)
|
||||
RobotRuntime::RobotRuntime(Database& db, MissionQueue& mission_queue)
|
||||
: db_(db), mission_queue_(mission_queue)
|
||||
{
|
||||
load();
|
||||
ensureDefaultsUnlocked();
|
||||
@@ -27,23 +27,14 @@ void RobotRuntime::load()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
state_ = nlohmann::json::object();
|
||||
if (!std::filesystem::exists(runtime_path_))
|
||||
return;
|
||||
try
|
||||
{
|
||||
const auto parsed = nlohmann::json::parse(FileUtil::readBinary(runtime_path_));
|
||||
if (parsed.is_object())
|
||||
nlohmann::json parsed;
|
||||
if (db_.getDocument("robot_runtime", parsed) && parsed.is_object())
|
||||
state_ = parsed;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
state_ = nlohmann::json::object();
|
||||
}
|
||||
}
|
||||
|
||||
void RobotRuntime::saveUnlocked() const
|
||||
{
|
||||
FileUtil::writeBinaryAtomic(runtime_path_, state_.dump(2));
|
||||
db_.setDocument("robot_runtime", state_);
|
||||
}
|
||||
|
||||
void RobotRuntime::ensureDefaultsUnlocked()
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
class MissionQueue;
|
||||
|
||||
class RobotRuntime
|
||||
{
|
||||
public:
|
||||
explicit RobotRuntime(std::filesystem::path runtime_path, MissionQueue& mission_queue);
|
||||
explicit RobotRuntime(Database& db, MissionQueue& mission_queue);
|
||||
|
||||
nlohmann::json status() const;
|
||||
bool start(std::string& err);
|
||||
@@ -24,7 +24,7 @@ public:
|
||||
void tick();
|
||||
|
||||
private:
|
||||
std::filesystem::path runtime_path_;
|
||||
Database& db_;
|
||||
MissionQueue& mission_queue_;
|
||||
mutable std::mutex mu_;
|
||||
nlohmann::json state_;
|
||||
|
||||
34
src/server/api_dashboard_routes.cpp
Normal file
34
src/server/api_dashboard_routes.cpp
Normal file
@@ -0,0 +1,34 @@
|
||||
#include "server/api_server.hpp"
|
||||
|
||||
#include "util/http_util.hpp"
|
||||
|
||||
namespace lm {
|
||||
|
||||
void ApiServer::registerDashboardRoutes(httplib::Server& svr)
|
||||
{
|
||||
svr.Get("/api/dashboards", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = dashboard_store_.snapshot().dump();
|
||||
});
|
||||
|
||||
svr.Put("/api/dashboards", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
nlohmann::json body;
|
||||
try
|
||||
{
|
||||
body = nlohmann::json::parse(req.body);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
std::string err;
|
||||
if (!dashboard_store_.replace(body, err))
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = dashboard_store_.snapshot().dump();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
197
src/server/api_media_routes.cpp
Normal file
197
src/server/api_media_routes.cpp
Normal file
@@ -0,0 +1,197 @@
|
||||
#include "server/api_server.hpp"
|
||||
|
||||
#include "util/file_util.hpp"
|
||||
#include "util/http_util.hpp"
|
||||
|
||||
namespace lm {
|
||||
|
||||
void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
||||
{
|
||||
svr.Get("/api/maps", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = nlohmann::json({{"maps", map_store_.list()}}).dump();
|
||||
});
|
||||
|
||||
svr.Get(R"(/api/maps/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
const auto map = map_store_.find(id);
|
||||
if (!map)
|
||||
return HttpUtil::jsonError(res, 404, "map not found");
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = map->dump();
|
||||
});
|
||||
|
||||
svr.Post("/api/maps", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
nlohmann::json body;
|
||||
try
|
||||
{
|
||||
body = nlohmann::json::parse(req.body);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
std::string err;
|
||||
const auto created = map_store_.create(body, err);
|
||||
if (!created)
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
res.status = 201;
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = created->dump();
|
||||
});
|
||||
|
||||
svr.Put(R"(/api/maps/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
nlohmann::json body;
|
||||
try
|
||||
{
|
||||
body = nlohmann::json::parse(req.body);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
std::string err;
|
||||
if (!map_store_.update(id, body, err))
|
||||
return HttpUtil::jsonError(res, 404, err);
|
||||
const auto updated = map_store_.find(id);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||
});
|
||||
|
||||
svr.Delete(R"(/api/maps/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
std::string err;
|
||||
if (!map_store_.remove(id, err))
|
||||
return HttpUtil::jsonError(res, 404, err);
|
||||
res.status = 204;
|
||||
});
|
||||
|
||||
svr.Get(R"(/api/maps/([^/]+)/image$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
const auto path = map_store_.imagePath(id);
|
||||
if (!path)
|
||||
return HttpUtil::jsonError(res, 404, "map image not found");
|
||||
res.set_header("Content-Type", HttpUtil::contentTypeForPath(*path));
|
||||
res.body = FileUtil::readBinary(*path);
|
||||
});
|
||||
|
||||
svr.Post(R"(/api/maps/([^/]+)/image$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
if (!req.form.has_file("file"))
|
||||
return HttpUtil::jsonError(res, 400, "file is required");
|
||||
const auto& file = req.form.get_file("file");
|
||||
const std::string filename = file.filename.empty() ? "map.png" : file.filename;
|
||||
std::string err;
|
||||
if (!map_store_.saveImageFile(id, filename, file.content, err))
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
const auto updated = map_store_.find(id);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||
});
|
||||
|
||||
svr.Get("/api/sounds", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = nlohmann::json({{"sounds", sound_store_.list()}}).dump();
|
||||
});
|
||||
|
||||
svr.Get(R"(/api/sounds/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
const auto sound = sound_store_.find(id);
|
||||
if (!sound)
|
||||
return HttpUtil::jsonError(res, 404, "sound not found");
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = sound->dump();
|
||||
});
|
||||
|
||||
svr.Post("/api/sounds", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
nlohmann::json body;
|
||||
try
|
||||
{
|
||||
body = nlohmann::json::parse(req.body);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
std::string err;
|
||||
const auto created = sound_store_.create(body, err);
|
||||
if (!created)
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
res.status = 201;
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = created->dump();
|
||||
});
|
||||
|
||||
svr.Put(R"(/api/sounds/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
nlohmann::json body;
|
||||
try
|
||||
{
|
||||
body = nlohmann::json::parse(req.body);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
std::string err;
|
||||
if (!sound_store_.update(id, body, err))
|
||||
return HttpUtil::jsonError(res, 404, err);
|
||||
const auto updated = sound_store_.find(id);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||
});
|
||||
|
||||
svr.Delete(R"(/api/sounds/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
std::string err;
|
||||
if (!sound_store_.remove(id, err))
|
||||
return HttpUtil::jsonError(res, 404, err);
|
||||
res.status = 204;
|
||||
});
|
||||
|
||||
svr.Get(R"(/api/sounds/([^/]+)/file$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
const auto path = sound_store_.filePath(id);
|
||||
if (!path)
|
||||
return HttpUtil::jsonError(res, 404, "sound file not found");
|
||||
res.set_header("Content-Type", HttpUtil::contentTypeForPath(*path));
|
||||
res.body = FileUtil::readBinary(*path);
|
||||
});
|
||||
|
||||
svr.Post(R"(/api/sounds/([^/]+)/file$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
if (!req.form.has_file("file"))
|
||||
return HttpUtil::jsonError(res, 400, "file is required");
|
||||
const auto& file = req.form.get_file("file");
|
||||
const std::string filename = file.filename.empty() ? (id + ".wav") : file.filename;
|
||||
std::string err;
|
||||
if (!sound_store_.saveFile(id, filename, file.content, err))
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
const auto updated = sound_store_.find(id);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||
});
|
||||
|
||||
svr.Get("/api/recordings", [](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = nlohmann::json({{"recordings", nlohmann::json::array()}}).dump();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
@@ -15,13 +15,19 @@ ApiServer::ApiServer(StateRepository& repo,
|
||||
MissionStore& mission_store,
|
||||
ModbusTriggerService& modbus,
|
||||
MissionScheduler& scheduler,
|
||||
RobotRuntime& robot_runtime)
|
||||
RobotRuntime& robot_runtime,
|
||||
MapStore& map_store,
|
||||
SoundStore& sound_store,
|
||||
DashboardStore& dashboard_store)
|
||||
: repo_(repo),
|
||||
mission_queue_(mission_queue),
|
||||
mission_store_(mission_store),
|
||||
modbus_(modbus),
|
||||
scheduler_(scheduler),
|
||||
robot_runtime_(robot_runtime)
|
||||
robot_runtime_(robot_runtime),
|
||||
map_store_(map_store),
|
||||
sound_store_(sound_store),
|
||||
dashboard_store_(dashboard_store)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -541,6 +547,8 @@ void ApiServer::registerRoutes(httplib::Server& svr)
|
||||
registerIntegrationRoutes(svr);
|
||||
registerMirV2Routes(svr);
|
||||
registerRobotRoutes(svr);
|
||||
registerMediaRoutes(svr);
|
||||
registerDashboardRoutes(svr);
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
#include "mission/mission_store.hpp"
|
||||
#include "mission/modbus_trigger_service.hpp"
|
||||
#include "robot/robot_runtime.hpp"
|
||||
#include "storage/dashboard_store.hpp"
|
||||
#include "storage/map_store.hpp"
|
||||
#include "storage/sound_store.hpp"
|
||||
#include "storage/state_repository.hpp"
|
||||
|
||||
namespace lm {
|
||||
@@ -19,7 +22,10 @@ public:
|
||||
MissionStore& mission_store,
|
||||
ModbusTriggerService& modbus,
|
||||
MissionScheduler& scheduler,
|
||||
RobotRuntime& robot_runtime);
|
||||
RobotRuntime& robot_runtime,
|
||||
MapStore& map_store,
|
||||
SoundStore& sound_store,
|
||||
DashboardStore& dashboard_store);
|
||||
|
||||
void registerRoutes(httplib::Server& svr);
|
||||
|
||||
@@ -30,6 +36,9 @@ private:
|
||||
ModbusTriggerService& modbus_;
|
||||
MissionScheduler& scheduler_;
|
||||
RobotRuntime& robot_runtime_;
|
||||
MapStore& map_store_;
|
||||
SoundStore& sound_store_;
|
||||
DashboardStore& dashboard_store_;
|
||||
|
||||
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
|
||||
std::optional<nlohmann::json> enqueueMission(const nlohmann::json& request, std::string& err);
|
||||
@@ -38,6 +47,8 @@ private:
|
||||
void registerMirV2Routes(httplib::Server& svr);
|
||||
void registerIntegrationRoutes(httplib::Server& svr);
|
||||
void registerRobotRoutes(httplib::Server& svr);
|
||||
void registerMediaRoutes(httplib::Server& svr);
|
||||
void registerDashboardRoutes(httplib::Server& svr);
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
|
||||
351
src/storage/dashboard_store.cpp
Normal file
351
src/storage/dashboard_store.cpp
Normal file
@@ -0,0 +1,351 @@
|
||||
#include "storage/dashboard_store.hpp"
|
||||
|
||||
#include "storage/database.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
const char* kDefaultId = "dashboard_default";
|
||||
|
||||
nlohmann::json loadWidgets(sqlite3* db, const std::string& dashboard_id)
|
||||
{
|
||||
nlohmann::json widgets = nlohmann::json::array();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT id, type, title, mission_id, mission_group, config_json, sort_order "
|
||||
"FROM dashboard_widgets WHERE dashboard_id = ?1 ORDER BY sort_order, id",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return widgets;
|
||||
sqlite3_bind_text(stmt, 1, dashboard_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
nlohmann::json w;
|
||||
w["id"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
w["type"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||
w["title"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
||||
if (sqlite3_column_type(stmt, 3) != SQLITE_NULL)
|
||||
w["mission_id"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||
if (sqlite3_column_type(stmt, 4) != SQLITE_NULL)
|
||||
w["group"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 4));
|
||||
const char* cfg = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 5));
|
||||
if (cfg)
|
||||
{
|
||||
try
|
||||
{
|
||||
const auto extra = nlohmann::json::parse(cfg);
|
||||
if (extra.is_object())
|
||||
{
|
||||
for (auto it = extra.begin(); it != extra.end(); ++it)
|
||||
{
|
||||
if (!w.contains(it.key()))
|
||||
w[it.key()] = it.value();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
}
|
||||
}
|
||||
widgets.push_back(w);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return widgets;
|
||||
}
|
||||
|
||||
nlohmann::json loadEditGroups(sqlite3* db, const std::string& dashboard_id)
|
||||
{
|
||||
nlohmann::json groups = nlohmann::json::array();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT group_id FROM dashboard_edit_groups WHERE dashboard_id = ?1 ORDER BY group_id",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return groups;
|
||||
sqlite3_bind_text(stmt, 1, dashboard_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
groups.push_back(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0)));
|
||||
sqlite3_finalize(stmt);
|
||||
return groups;
|
||||
}
|
||||
|
||||
std::optional<std::string> loadActiveDashboardId(sqlite3* db)
|
||||
{
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db, "SELECT active_dashboard_id FROM dashboard_state WHERE id = 1", -1, &stmt, nullptr) !=
|
||||
SQLITE_OK)
|
||||
return std::nullopt;
|
||||
std::optional<std::string> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_type(stmt, 0) != SQLITE_NULL)
|
||||
out = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DashboardStore::DashboardStore(Database& db) : db_(db)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
ensureDefaultsUnlocked();
|
||||
}
|
||||
|
||||
void DashboardStore::ensureDefaultsUnlocked()
|
||||
{
|
||||
sqlite3_stmt* count_stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(), "SELECT COUNT(*) FROM dashboards", -1, &count_stmt, nullptr) != SQLITE_OK)
|
||||
return;
|
||||
int count = 0;
|
||||
if (sqlite3_step(count_stmt) == SQLITE_ROW)
|
||||
count = sqlite3_column_int(count_stmt, 0);
|
||||
sqlite3_finalize(count_stmt);
|
||||
if (count > 0)
|
||||
return;
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
sqlite3_stmt* ins = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT INTO dashboards(id, name, created_by, created_by_user, is_default, sort_order, "
|
||||
"created_at, updated_at) VALUES(?1,?2,?3,NULL,1,0,?4,?4)",
|
||||
-1,
|
||||
&ins,
|
||||
nullptr) != SQLITE_OK)
|
||||
return;
|
||||
sqlite3_bind_text(ins, 1, kDefaultId, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(ins, 2, "Default Dashboard", -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(ins, 3, "MiR", -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(ins, 4, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(ins);
|
||||
sqlite3_finalize(ins);
|
||||
|
||||
for (const char* gid :
|
||||
{"group_administrators", "group_distributors", "group_users"})
|
||||
{
|
||||
sqlite3_stmt* g = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT OR IGNORE INTO dashboard_edit_groups(dashboard_id, group_id) VALUES(?1,?2)",
|
||||
-1,
|
||||
&g,
|
||||
nullptr) != SQLITE_OK)
|
||||
continue;
|
||||
sqlite3_bind_text(g, 1, kDefaultId, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(g, 2, gid, -1, SQLITE_STATIC);
|
||||
sqlite3_step(g);
|
||||
sqlite3_finalize(g);
|
||||
}
|
||||
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT OR REPLACE INTO dashboard_state(id, active_dashboard_id) VALUES(1, ?1)",
|
||||
-1,
|
||||
&st,
|
||||
nullptr) == SQLITE_OK)
|
||||
{
|
||||
sqlite3_bind_text(st, 1, kDefaultId, -1, SQLITE_STATIC);
|
||||
sqlite3_step(st);
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
}
|
||||
|
||||
nlohmann::json DashboardStore::snapshot() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
nlohmann::json dashboards = nlohmann::json::array();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, created_by, created_by_user, is_default FROM dashboards ORDER BY sort_order, name",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return {{"dashboards", dashboards}, {"activeDashboardId", nullptr}};
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
const std::string id = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
nlohmann::json d;
|
||||
d["id"] = id;
|
||||
d["name"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||
d["createdBy"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
||||
if (sqlite3_column_type(stmt, 3) != SQLITE_NULL)
|
||||
d["createdByUser"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||
else
|
||||
d["createdByUser"] = nullptr;
|
||||
d["isDefault"] = sqlite3_column_int(stmt, 4) != 0;
|
||||
d["editGroups"] = loadEditGroups(db_.handle(), id);
|
||||
d["widgets"] = loadWidgets(db_.handle(), id);
|
||||
dashboards.push_back(d);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
std::string active_id = kDefaultId;
|
||||
if (auto active = loadActiveDashboardId(db_.handle()))
|
||||
active_id = *active;
|
||||
if (dashboards.empty())
|
||||
return {{"dashboards", nlohmann::json::array()}, {"activeDashboardId", nullptr}};
|
||||
return {{"dashboards", dashboards}, {"activeDashboardId", active_id}};
|
||||
}
|
||||
|
||||
bool DashboardStore::replace(const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
if (!payload.is_object())
|
||||
{
|
||||
err = "payload must be an object";
|
||||
return false;
|
||||
}
|
||||
if (!payload.contains("dashboards") || !payload["dashboards"].is_array())
|
||||
{
|
||||
err = "dashboards array is required";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3* db = db_.handle();
|
||||
char* msg = nullptr;
|
||||
if (sqlite3_exec(db, "BEGIN IMMEDIATE", nullptr, nullptr, &msg) != SQLITE_OK)
|
||||
{
|
||||
err = msg ? msg : "begin failed";
|
||||
sqlite3_free(msg);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto rollback = [&]() {
|
||||
sqlite3_exec(db, "ROLLBACK", nullptr, nullptr, nullptr);
|
||||
};
|
||||
|
||||
if (sqlite3_exec(db, "DELETE FROM dashboard_widgets", nullptr, nullptr, &msg) != SQLITE_OK ||
|
||||
sqlite3_exec(db, "DELETE FROM dashboard_edit_groups", nullptr, nullptr, &msg) != SQLITE_OK ||
|
||||
sqlite3_exec(db, "DELETE FROM dashboards", nullptr, nullptr, &msg) != SQLITE_OK)
|
||||
{
|
||||
err = msg ? msg : "clear failed";
|
||||
sqlite3_free(msg);
|
||||
rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
int sort = 0;
|
||||
for (const auto& d : payload["dashboards"])
|
||||
{
|
||||
if (!d.is_object() || !d.contains("id"))
|
||||
continue;
|
||||
const std::string id = d.value("id", IdUtil::newId());
|
||||
sqlite3_stmt* ins = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"INSERT INTO dashboards(id, name, created_by, created_by_user, is_default, sort_order, "
|
||||
"created_at, updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?7)",
|
||||
-1,
|
||||
&ins,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
rollback();
|
||||
err = "insert dashboard failed";
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(ins, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ins, 2, d.value("name", "Dashboard").c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ins, 3, d.value("createdBy", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (d.contains("createdByUser") && !d["createdByUser"].is_null())
|
||||
sqlite3_bind_text(ins, 4, d["createdByUser"].get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
|
||||
else
|
||||
sqlite3_bind_null(ins, 4);
|
||||
sqlite3_bind_int(ins, 5, d.value("isDefault", false) ? 1 : 0);
|
||||
sqlite3_bind_int(ins, 6, sort++);
|
||||
sqlite3_bind_text(ins, 7, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(ins);
|
||||
sqlite3_finalize(ins);
|
||||
|
||||
if (d.contains("editGroups") && d["editGroups"].is_array())
|
||||
{
|
||||
for (const auto& g : d["editGroups"])
|
||||
{
|
||||
if (!g.is_string())
|
||||
continue;
|
||||
sqlite3_stmt* gs = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"INSERT INTO dashboard_edit_groups(dashboard_id, group_id) VALUES(?1,?2)",
|
||||
-1,
|
||||
&gs,
|
||||
nullptr) == SQLITE_OK)
|
||||
{
|
||||
sqlite3_bind_text(gs, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(gs, 2, g.get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(gs);
|
||||
sqlite3_finalize(gs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (d.contains("widgets") && d["widgets"].is_array())
|
||||
{
|
||||
int wsort = 0;
|
||||
for (const auto& w : d["widgets"])
|
||||
{
|
||||
if (!w.is_object())
|
||||
continue;
|
||||
nlohmann::json config = nlohmann::json::object();
|
||||
for (auto it = w.begin(); it != w.end(); ++it)
|
||||
{
|
||||
if (it.key() != "id" && it.key() != "type" && it.key() != "title" && it.key() != "mission_id" &&
|
||||
it.key() != "group")
|
||||
config[it.key()] = it.value();
|
||||
}
|
||||
sqlite3_stmt* ws = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"INSERT INTO dashboard_widgets(id, dashboard_id, type, title, mission_id, mission_group, "
|
||||
"config_json, sort_order) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
-1,
|
||||
&ws,
|
||||
nullptr) != SQLITE_OK)
|
||||
continue;
|
||||
const std::string wid = w.value("id", IdUtil::newId());
|
||||
sqlite3_bind_text(ws, 1, wid.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ws, 2, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ws, 3, w.value("type", "unknown").c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ws, 4, w.value("title", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (w.contains("mission_id") && w["mission_id"].is_string())
|
||||
sqlite3_bind_text(ws, 5, w["mission_id"].get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
|
||||
else
|
||||
sqlite3_bind_null(ws, 5);
|
||||
if (w.contains("group") && w["group"].is_string())
|
||||
sqlite3_bind_text(ws, 6, w["group"].get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
|
||||
else
|
||||
sqlite3_bind_null(ws, 6);
|
||||
const std::string cfg = config.dump();
|
||||
sqlite3_bind_text(ws, 7, cfg.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(ws, 8, wsort++);
|
||||
sqlite3_step(ws);
|
||||
sqlite3_finalize(ws);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const std::string active = payload.value("activeDashboardId", kDefaultId);
|
||||
sqlite3_stmt* ast = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"INSERT OR REPLACE INTO dashboard_state(id, active_dashboard_id) VALUES(1, ?1)",
|
||||
-1,
|
||||
&ast,
|
||||
nullptr) == SQLITE_OK)
|
||||
{
|
||||
sqlite3_bind_text(ast, 1, active.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(ast);
|
||||
sqlite3_finalize(ast);
|
||||
}
|
||||
|
||||
if (sqlite3_exec(db, "COMMIT", nullptr, nullptr, &msg) != SQLITE_OK)
|
||||
{
|
||||
err = msg ? msg : "commit failed";
|
||||
sqlite3_free(msg);
|
||||
rollback();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
28
src/storage/dashboard_store.hpp
Normal file
28
src/storage/dashboard_store.hpp
Normal file
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class DashboardStore
|
||||
{
|
||||
public:
|
||||
explicit DashboardStore(Database& db);
|
||||
|
||||
nlohmann::json snapshot() const;
|
||||
bool replace(const nlohmann::json& payload, std::string& err);
|
||||
|
||||
private:
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
|
||||
void ensureDefaultsUnlocked();
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
412
src/storage/database.cpp
Normal file
412
src/storage/database.cpp
Normal file
@@ -0,0 +1,412 @@
|
||||
#include "storage/database.hpp"
|
||||
|
||||
#include "util/file_util.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
bool execSql(sqlite3* db, const char* sql, std::string& err)
|
||||
{
|
||||
char* msg = nullptr;
|
||||
const int rc = sqlite3_exec(db, sql, nullptr, nullptr, &msg);
|
||||
if (rc != SQLITE_OK)
|
||||
{
|
||||
err = msg ? msg : sqlite3_errstr(rc);
|
||||
sqlite3_free(msg);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* kSchemaSql = R"SQL(
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
name TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS layout_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maps (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
width REAL,
|
||||
height REAL,
|
||||
resolution REAL,
|
||||
origin_x REAL DEFAULT 0,
|
||||
origin_y REAL DEFAULT 0,
|
||||
origin_yaw REAL DEFAULT 0,
|
||||
image_file TEXT,
|
||||
yaml_file TEXT,
|
||||
zones_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sounds (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
file_name TEXT,
|
||||
duration_ms INTEGER,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
map_id TEXT,
|
||||
file_path TEXT,
|
||||
started_at TEXT,
|
||||
ended_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dashboards (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_by_user TEXT,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dashboard_edit_groups (
|
||||
dashboard_id TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
PRIMARY KEY (dashboard_id, group_id),
|
||||
FOREIGN KEY (dashboard_id) REFERENCES dashboards(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dashboard_widgets (
|
||||
id TEXT PRIMARY KEY,
|
||||
dashboard_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
mission_id TEXT,
|
||||
mission_group TEXT,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (dashboard_id) REFERENCES dashboards(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dashboard_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
active_dashboard_id TEXT
|
||||
);
|
||||
)SQL";
|
||||
|
||||
} // namespace
|
||||
|
||||
Database::Database(std::filesystem::path data_dir)
|
||||
: data_dir_(std::move(data_dir)), db_path_(data_dir_ / "RBS.db")
|
||||
{
|
||||
}
|
||||
|
||||
void Database::close()
|
||||
{
|
||||
if (db_)
|
||||
{
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool Database::openDb(std::string& err)
|
||||
{
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(data_dir_, ec);
|
||||
|
||||
const auto legacy_path = data_dir_ / "test3.db";
|
||||
if (!std::filesystem::exists(db_path_) && std::filesystem::exists(legacy_path))
|
||||
{
|
||||
std::filesystem::rename(legacy_path, db_path_, ec);
|
||||
for (const char* suffix : {"-wal", "-shm"})
|
||||
{
|
||||
const auto from = legacy_path.string() + suffix;
|
||||
const auto to = db_path_.string() + suffix;
|
||||
if (std::filesystem::exists(from))
|
||||
std::filesystem::rename(from, to, ec);
|
||||
}
|
||||
}
|
||||
|
||||
const int rc = sqlite3_open(db_path_.string().c_str(), &db_);
|
||||
if (rc != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_);
|
||||
db_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3_busy_timeout(db_, 5000);
|
||||
if (!execSql(db_, "PRAGMA journal_mode=WAL;", err))
|
||||
return false;
|
||||
if (!execSql(db_, "PRAGMA synchronous=NORMAL;", err))
|
||||
return false;
|
||||
if (!execSql(db_, "PRAGMA foreign_keys=ON;", err))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::ensureDataDirs(std::string& err)
|
||||
{
|
||||
std::error_code ec;
|
||||
for (const auto& dir : {mapsDir(), soundsDir(), recordingsDir()})
|
||||
{
|
||||
if (!std::filesystem::create_directories(dir, ec) && ec)
|
||||
{
|
||||
err = "failed to create directory: " + dir.string();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::applySchema(std::string& err)
|
||||
{
|
||||
return execSql(db_, kSchemaSql, err);
|
||||
}
|
||||
|
||||
std::optional<std::string> Database::getMeta(const std::string& key) const
|
||||
{
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "SELECT value FROM meta WHERE key = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return std::nullopt;
|
||||
sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::optional<std::string> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
const char* val = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
if (val)
|
||||
out = val;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool Database::setMeta(const std::string& key, const std::string& value)
|
||||
{
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT INTO meta(key, value) VALUES(?1, ?2) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, value.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool Database::getDocument(const std::string& name, nlohmann::json& out) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "SELECT content FROM documents WHERE name = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
bool found = false;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
const char* text = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
if (text)
|
||||
{
|
||||
try
|
||||
{
|
||||
out = nlohmann::json::parse(text);
|
||||
found = true;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
found = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return found;
|
||||
}
|
||||
|
||||
bool Database::setDocument(const std::string& name, const nlohmann::json& doc)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
const std::string body = doc.dump();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT INTO documents(name, content, updated_at) VALUES(?1, ?2, ?3) "
|
||||
"ON CONFLICT(name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, body.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> Database::getLayoutProfile(const std::string& id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "SELECT content FROM layout_profiles WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return std::nullopt;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::optional<nlohmann::json> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
const char* text = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
if (text)
|
||||
{
|
||||
try
|
||||
{
|
||||
out = nlohmann::json::parse(text);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
out = std::nullopt;
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool Database::setLayoutProfile(const nlohmann::json& profile)
|
||||
{
|
||||
if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string())
|
||||
return false;
|
||||
const std::string id = profile["id"].get<std::string>();
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
const std::string body = profile.dump();
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT INTO layout_profiles(id, content, updated_at) VALUES(?1, ?2, ?3) "
|
||||
"ON CONFLICT(id) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, body.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool Database::deleteLayoutProfile(const std::string& id)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "DELETE FROM layout_profiles WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool Database::migrateFromJsonIfNeeded(std::string& err)
|
||||
{
|
||||
if (getMeta("json_imported"))
|
||||
return true;
|
||||
|
||||
const auto importDoc = [&](const std::string& name, const std::filesystem::path& path) {
|
||||
if (!std::filesystem::exists(path))
|
||||
return;
|
||||
try
|
||||
{
|
||||
const auto doc = nlohmann::json::parse(FileUtil::readBinary(path));
|
||||
setDocument(name, doc);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
importDoc("auth", data_dir_ / "auth.json");
|
||||
importDoc("missions", data_dir_ / "missions.json");
|
||||
importDoc("mission_queue", data_dir_ / "mission_queue.json");
|
||||
importDoc("robot_runtime", data_dir_ / "robot_runtime.json");
|
||||
|
||||
const auto state_path = data_dir_ / "state.json";
|
||||
if (std::filesystem::exists(state_path))
|
||||
{
|
||||
try
|
||||
{
|
||||
setDocument("state", nlohmann::json::parse(FileUtil::readBinary(state_path)));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
const auto models_dir = data_dir_ / "models";
|
||||
if (std::filesystem::is_directory(models_dir))
|
||||
{
|
||||
for (const auto& entry : std::filesystem::directory_iterator(models_dir))
|
||||
{
|
||||
if (!entry.is_regular_file() || entry.path().extension() != ".json")
|
||||
continue;
|
||||
try
|
||||
{
|
||||
const auto profile = nlohmann::json::parse(FileUtil::readBinary(entry.path()));
|
||||
setLayoutProfile(profile);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMeta("schema_version", "1");
|
||||
setMeta("json_imported", IdUtil::nowIso8601());
|
||||
std::fprintf(stderr, "SQLite: imported JSON data into %s\n", db_path_.string().c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::init(std::string& err)
|
||||
{
|
||||
if (!openDb(err))
|
||||
return false;
|
||||
if (!applySchema(err))
|
||||
return false;
|
||||
if (!ensureDataDirs(err))
|
||||
return false;
|
||||
if (!migrateFromJsonIfNeeded(err))
|
||||
return false;
|
||||
if (!getMeta("schema_version"))
|
||||
setMeta("schema_version", "1");
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
51
src/storage/database.hpp
Normal file
51
src/storage/database.hpp
Normal file
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database
|
||||
{
|
||||
public:
|
||||
explicit Database(std::filesystem::path data_dir);
|
||||
|
||||
bool init(std::string& err);
|
||||
void close();
|
||||
|
||||
std::filesystem::path dataDir() const { return data_dir_; }
|
||||
std::filesystem::path dbPath() const { return db_path_; }
|
||||
std::filesystem::path mapsDir() const { return data_dir_ / "maps"; }
|
||||
std::filesystem::path soundsDir() const { return data_dir_ / "sounds"; }
|
||||
std::filesystem::path recordingsDir() const { return data_dir_ / "recordings"; }
|
||||
|
||||
bool getDocument(const std::string& name, nlohmann::json& out) const;
|
||||
bool setDocument(const std::string& name, const nlohmann::json& doc);
|
||||
|
||||
std::optional<nlohmann::json> getLayoutProfile(const std::string& id) const;
|
||||
bool setLayoutProfile(const nlohmann::json& profile);
|
||||
bool deleteLayoutProfile(const std::string& id);
|
||||
|
||||
sqlite3* handle() const { return db_; }
|
||||
|
||||
private:
|
||||
std::filesystem::path data_dir_;
|
||||
std::filesystem::path db_path_;
|
||||
sqlite3* db_ = nullptr;
|
||||
mutable std::mutex mu_;
|
||||
|
||||
bool openDb(std::string& err);
|
||||
bool applySchema(std::string& err);
|
||||
bool migrateFromJsonIfNeeded(std::string& err);
|
||||
bool ensureDataDirs(std::string& err);
|
||||
std::optional<std::string> getMeta(const std::string& key) const;
|
||||
bool setMeta(const std::string& key, const std::string& value);
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
338
src/storage/map_store.cpp
Normal file
338
src/storage/map_store.cpp
Normal file
@@ -0,0 +1,338 @@
|
||||
#include "storage/map_store.hpp"
|
||||
|
||||
#include "storage/database.hpp"
|
||||
#include "util/file_util.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||
{
|
||||
auto textOrNull = [&](int col) -> nlohmann::json {
|
||||
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
|
||||
return nullptr;
|
||||
return nlohmann::json(reinterpret_cast<const char*>(sqlite3_column_text(stmt, col)));
|
||||
};
|
||||
auto realOrNull = [&](int col) -> nlohmann::json {
|
||||
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
|
||||
return nullptr;
|
||||
return nlohmann::json(sqlite3_column_double(stmt, col));
|
||||
};
|
||||
|
||||
nlohmann::json zones = nlohmann::json::array();
|
||||
if (sqlite3_column_type(stmt, 11) != SQLITE_NULL)
|
||||
{
|
||||
try
|
||||
{
|
||||
zones = nlohmann::json::parse(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 11)));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
zones = nlohmann::json::array();
|
||||
}
|
||||
}
|
||||
|
||||
return {{"id", textOrNull(0)},
|
||||
{"name", textOrNull(1)},
|
||||
{"description", textOrNull(2)},
|
||||
{"width", realOrNull(3)},
|
||||
{"height", realOrNull(4)},
|
||||
{"resolution", realOrNull(5)},
|
||||
{"origin_x", realOrNull(6)},
|
||||
{"origin_y", realOrNull(7)},
|
||||
{"origin_yaw", realOrNull(8)},
|
||||
{"image_file", textOrNull(9)},
|
||||
{"yaml_file", textOrNull(10)},
|
||||
{"zones", zones},
|
||||
{"created_at", textOrNull(12)},
|
||||
{"updated_at", textOrNull(13)}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MapStore::MapStore(Database& db) : db_(db) {}
|
||||
|
||||
std::filesystem::path MapStore::mapDir(const std::string& id) const
|
||||
{
|
||||
return db_.mapsDir() / id;
|
||||
}
|
||||
|
||||
nlohmann::json MapStore::list() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
nlohmann::json maps = nlohmann::json::array();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, description, width, height, resolution, "
|
||||
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
|
||||
"created_at, updated_at FROM maps ORDER BY name",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return maps;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
maps.push_back(rowToJson(stmt));
|
||||
sqlite3_finalize(stmt);
|
||||
return maps;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> MapStore::find(const std::string& id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, description, width, height, resolution, "
|
||||
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
|
||||
"created_at, updated_at FROM maps WHERE id = ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return std::nullopt;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::optional<nlohmann::json> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
out = rowToJson(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> MapStore::create(const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
if (!payload.is_object())
|
||||
{
|
||||
err = "payload must be an object";
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::string name = StringUtil::trimCopy(payload.value("name", ""));
|
||||
if (name.empty())
|
||||
{
|
||||
err = "name is required";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::string id = payload.value("id", IdUtil::newId());
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
const std::string description = payload.value("description", "");
|
||||
const auto zones = payload.contains("zones") ? payload["zones"] : nlohmann::json::array();
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(mapDir(id), ec);
|
||||
if (ec)
|
||||
{
|
||||
err = "failed to create map directory";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT INTO maps(id, name, description, width, height, resolution, "
|
||||
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
|
||||
"created_at, updated_at) "
|
||||
"VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, description.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (payload.contains("width") && payload["width"].is_number())
|
||||
sqlite3_bind_double(stmt, 4, payload["width"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 4);
|
||||
if (payload.contains("height") && payload["height"].is_number())
|
||||
sqlite3_bind_double(stmt, 5, payload["height"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 5);
|
||||
if (payload.contains("resolution") && payload["resolution"].is_number())
|
||||
sqlite3_bind_double(stmt, 6, payload["resolution"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 6);
|
||||
sqlite3_bind_double(stmt, 7, payload.value("origin_x", 0.0));
|
||||
sqlite3_bind_double(stmt, 8, payload.value("origin_y", 0.0));
|
||||
sqlite3_bind_double(stmt, 9, payload.value("origin_yaw", 0.0));
|
||||
sqlite3_bind_null(stmt, 10);
|
||||
sqlite3_bind_null(stmt, 11);
|
||||
const std::string zones_str = zones.dump();
|
||||
sqlite3_bind_text(stmt, 12, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 13, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 14, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return std::nullopt;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
nlohmann::json created;
|
||||
created["id"] = id;
|
||||
created["name"] = name;
|
||||
created["description"] = description;
|
||||
if (payload.contains("width") && payload["width"].is_number())
|
||||
created["width"] = payload["width"];
|
||||
if (payload.contains("height") && payload["height"].is_number())
|
||||
created["height"] = payload["height"];
|
||||
if (payload.contains("resolution") && payload["resolution"].is_number())
|
||||
created["resolution"] = payload["resolution"];
|
||||
created["origin_x"] = payload.value("origin_x", 0.0);
|
||||
created["origin_y"] = payload.value("origin_y", 0.0);
|
||||
created["origin_yaw"] = payload.value("origin_yaw", 0.0);
|
||||
created["image_file"] = nullptr;
|
||||
created["yaml_file"] = nullptr;
|
||||
created["zones"] = zones;
|
||||
created["created_at"] = now;
|
||||
created["updated_at"] = now;
|
||||
return created;
|
||||
}
|
||||
|
||||
bool MapStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
auto existing = find(id);
|
||||
if (!existing)
|
||||
{
|
||||
err = "map not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
nlohmann::json merged = *existing;
|
||||
for (const char* key : {"name", "description", "width", "height", "resolution", "origin_x", "origin_y", "origin_yaw"})
|
||||
{
|
||||
if (payload.contains(key))
|
||||
merged[key] = payload[key];
|
||||
}
|
||||
if (payload.contains("zones"))
|
||||
merged["zones"] = payload["zones"];
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
const std::string zones_str = merged.value("zones", nlohmann::json::array()).dump();
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE maps SET name=?2, description=?3, width=?4, height=?5, resolution=?6, "
|
||||
"origin_x=?7, origin_y=?8, origin_yaw=?9, zones_json=?10, updated_at=?11 WHERE id=?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, merged.value("name", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, merged.value("description", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (merged["width"].is_number())
|
||||
sqlite3_bind_double(stmt, 4, merged["width"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 4);
|
||||
if (merged["height"].is_number())
|
||||
sqlite3_bind_double(stmt, 5, merged["height"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 5);
|
||||
if (merged["resolution"].is_number())
|
||||
sqlite3_bind_double(stmt, 6, merged["resolution"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 6);
|
||||
sqlite3_bind_double(stmt, 7, merged.value("origin_x", 0.0));
|
||||
sqlite3_bind_double(stmt, 8, merged.value("origin_y", 0.0));
|
||||
sqlite3_bind_double(stmt, 9, merged.value("origin_yaw", 0.0));
|
||||
sqlite3_bind_text(stmt, 10, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 11, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool MapStore::remove(const std::string& id, std::string& err)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM maps WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
if (!ok)
|
||||
{
|
||||
err = "map not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::remove_all(mapDir(id), ec);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> MapStore::imagePath(const std::string& id) const
|
||||
{
|
||||
const auto map = find(id);
|
||||
if (!map || !(*map)["image_file"].is_string())
|
||||
return std::nullopt;
|
||||
const auto path = mapDir(id) / map->value("image_file", "");
|
||||
if (!std::filesystem::exists(path))
|
||||
return std::nullopt;
|
||||
return path;
|
||||
}
|
||||
|
||||
bool MapStore::saveImageFile(const std::string& id,
|
||||
const std::string& filename,
|
||||
const std::string& bytes,
|
||||
std::string& err)
|
||||
{
|
||||
if (!find(id))
|
||||
{
|
||||
err = "map not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(mapDir(id), ec);
|
||||
const auto path = mapDir(id) / filename;
|
||||
if (!FileUtil::writeBinaryAtomic(path, bytes))
|
||||
{
|
||||
err = "failed to write image file";
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE maps SET image_file = ?2, updated_at = ?3 WHERE id = ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, filename.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
34
src/storage/map_store.hpp
Normal file
34
src/storage/map_store.hpp
Normal file
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class MapStore
|
||||
{
|
||||
public:
|
||||
MapStore(Database& db);
|
||||
|
||||
nlohmann::json list() const;
|
||||
std::optional<nlohmann::json> find(const std::string& id) const;
|
||||
std::optional<nlohmann::json> create(const nlohmann::json& payload, std::string& err);
|
||||
bool update(const std::string& id, const nlohmann::json& payload, std::string& err);
|
||||
bool remove(const std::string& id, std::string& err);
|
||||
|
||||
std::filesystem::path mapDir(const std::string& id) const;
|
||||
std::optional<std::filesystem::path> imagePath(const std::string& id) const;
|
||||
bool saveImageFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
|
||||
|
||||
private:
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
257
src/storage/sound_store.cpp
Normal file
257
src/storage/sound_store.cpp
Normal file
@@ -0,0 +1,257 @@
|
||||
#include "storage/sound_store.hpp"
|
||||
|
||||
#include "storage/database.hpp"
|
||||
#include "util/file_util.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||
{
|
||||
auto textOrNull = [&](int col) -> nlohmann::json {
|
||||
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
|
||||
return nullptr;
|
||||
return nlohmann::json(reinterpret_cast<const char*>(sqlite3_column_text(stmt, col)));
|
||||
};
|
||||
return {{"id", textOrNull(0)},
|
||||
{"name", textOrNull(1)},
|
||||
{"description", textOrNull(2)},
|
||||
{"file_name", textOrNull(3)},
|
||||
{"duration_ms", sqlite3_column_type(stmt, 4) == SQLITE_NULL
|
||||
? nlohmann::json(nullptr)
|
||||
: nlohmann::json(sqlite3_column_int(stmt, 4))},
|
||||
{"enabled", sqlite3_column_int(stmt, 5) != 0},
|
||||
{"created_at", textOrNull(6)},
|
||||
{"updated_at", textOrNull(7)}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SoundStore::SoundStore(Database& db) : db_(db) {}
|
||||
|
||||
nlohmann::json SoundStore::list() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
nlohmann::json sounds = nlohmann::json::array();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, description, file_name, duration_ms, enabled, created_at, updated_at "
|
||||
"FROM sounds ORDER BY name",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return sounds;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
sounds.push_back(rowToJson(stmt));
|
||||
sqlite3_finalize(stmt);
|
||||
return sounds;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> SoundStore::find(const std::string& id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, description, file_name, duration_ms, enabled, created_at, updated_at "
|
||||
"FROM sounds WHERE id = ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return std::nullopt;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::optional<nlohmann::json> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
out = rowToJson(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> SoundStore::create(const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
if (!payload.is_object())
|
||||
{
|
||||
err = "payload must be an object";
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::string name = StringUtil::trimCopy(payload.value("name", ""));
|
||||
if (name.empty())
|
||||
{
|
||||
err = "name is required";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::string id = payload.value("id", IdUtil::newId());
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
const std::string description = payload.value("description", "");
|
||||
const bool enabled = payload.value("enabled", true);
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT INTO sounds(id, name, description, file_name, duration_ms, enabled, created_at, updated_at) "
|
||||
"VALUES(?1,?2,?3,NULL,NULL,?4,?5,?6)",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return std::nullopt;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, description.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 4, enabled ? 1 : 0);
|
||||
sqlite3_bind_text(stmt, 5, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return std::nullopt;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return nlohmann::json{{"id", id},
|
||||
{"name", name},
|
||||
{"description", description},
|
||||
{"file_name", nullptr},
|
||||
{"duration_ms", nullptr},
|
||||
{"enabled", enabled},
|
||||
{"created_at", now},
|
||||
{"updated_at", now}};
|
||||
}
|
||||
|
||||
bool SoundStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
auto existing = find(id);
|
||||
if (!existing)
|
||||
{
|
||||
err = "sound not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
nlohmann::json merged = *existing;
|
||||
for (const char* key : {"name", "description", "enabled", "duration_ms"})
|
||||
{
|
||||
if (payload.contains(key))
|
||||
merged[key] = payload[key];
|
||||
}
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE sounds SET name=?2, description=?3, enabled=?4, duration_ms=?5, updated_at=?6 WHERE id=?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, merged.value("name", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, merged.value("description", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 4, merged.value("enabled", true) ? 1 : 0);
|
||||
if (merged["duration_ms"].is_number_integer())
|
||||
sqlite3_bind_int(stmt, 5, merged["duration_ms"].get<int>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 5);
|
||||
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool SoundStore::remove(const std::string& id, std::string& err)
|
||||
{
|
||||
auto existing = find(id);
|
||||
if (!existing)
|
||||
{
|
||||
err = "sound not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM sounds WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (existing->contains("file_name") && (*existing)["file_name"].is_string())
|
||||
{
|
||||
const auto path = db_.soundsDir() / existing->value("file_name", "");
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(path, ec);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> SoundStore::filePath(const std::string& id) const
|
||||
{
|
||||
const auto sound = find(id);
|
||||
if (!sound || !(*sound)["file_name"].is_string())
|
||||
return std::nullopt;
|
||||
const auto path = db_.soundsDir() / sound->value("file_name", "");
|
||||
if (!std::filesystem::exists(path))
|
||||
return std::nullopt;
|
||||
return path;
|
||||
}
|
||||
|
||||
bool SoundStore::saveFile(const std::string& id,
|
||||
const std::string& filename,
|
||||
const std::string& bytes,
|
||||
std::string& err)
|
||||
{
|
||||
if (!find(id))
|
||||
{
|
||||
err = "sound not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(db_.soundsDir(), ec);
|
||||
const auto path = db_.soundsDir() / filename;
|
||||
if (!FileUtil::writeBinaryAtomic(path, bytes))
|
||||
{
|
||||
err = "failed to write sound file";
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE sounds SET file_name = ?2, updated_at = ?3 WHERE id = ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, filename.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
33
src/storage/sound_store.hpp
Normal file
33
src/storage/sound_store.hpp
Normal file
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class SoundStore
|
||||
{
|
||||
public:
|
||||
SoundStore(Database& db);
|
||||
|
||||
nlohmann::json list() const;
|
||||
std::optional<nlohmann::json> find(const std::string& id) const;
|
||||
std::optional<nlohmann::json> create(const nlohmann::json& payload, std::string& err);
|
||||
bool update(const std::string& id, const nlohmann::json& payload, std::string& err);
|
||||
bool remove(const std::string& id, std::string& err);
|
||||
|
||||
std::optional<std::filesystem::path> filePath(const std::string& id) const;
|
||||
bool saveFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
|
||||
|
||||
private:
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "domain/layout_profile.hpp"
|
||||
#include "domain/layout_schema.hpp"
|
||||
#include "storage/database.hpp"
|
||||
#include "util/file_util.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
@@ -20,6 +21,8 @@ std::filesystem::path StateRepository::profileFilePath(const std::string& id) co
|
||||
|
||||
std::optional<nlohmann::json> StateRepository::loadProfileFromDisk(const std::string& id) const
|
||||
{
|
||||
if (auto profile = db_.getLayoutProfile(id))
|
||||
return profile;
|
||||
const auto raw = FileUtil::readBinary(profileFilePath(id));
|
||||
if (raw.empty())
|
||||
return std::nullopt;
|
||||
@@ -37,15 +40,12 @@ bool StateRepository::saveProfileToDisk(const nlohmann::json& profile) const
|
||||
{
|
||||
if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string())
|
||||
return false;
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(modelsDir(), ec);
|
||||
auto body = profile.dump(2);
|
||||
body.push_back('\n');
|
||||
return FileUtil::writeBinaryAtomic(profileFilePath(profile["id"].get<std::string>()), body);
|
||||
return db_.setLayoutProfile(profile);
|
||||
}
|
||||
|
||||
bool StateRepository::deleteProfileFile(const std::string& id) const
|
||||
{
|
||||
db_.deleteLayoutProfile(id);
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(profileFilePath(id), ec);
|
||||
return true;
|
||||
@@ -243,15 +243,14 @@ void StateRepository::bootstrapDefaultState()
|
||||
app_.state["imus"] = profile.contains("imus") ? profile["imus"] : nlohmann::json::array();
|
||||
}
|
||||
|
||||
StateRepository::StateRepository(std::filesystem::path data_path)
|
||||
StateRepository::StateRepository(std::filesystem::path data_path, Database& db) : db_(db)
|
||||
{
|
||||
app_.data_path = std::move(data_path);
|
||||
}
|
||||
|
||||
bool StateRepository::load()
|
||||
{
|
||||
const auto raw = FileUtil::readBinary(app_.data_path);
|
||||
if (raw.empty())
|
||||
if (!db_.getDocument("state", app_.state))
|
||||
{
|
||||
bootstrapDefaultState();
|
||||
save();
|
||||
@@ -259,7 +258,6 @@ bool StateRepository::load()
|
||||
}
|
||||
try
|
||||
{
|
||||
app_.state = nlohmann::json::parse(raw);
|
||||
ensureSchema();
|
||||
save();
|
||||
return true;
|
||||
@@ -309,9 +307,7 @@ bool StateRepository::save() const
|
||||
try
|
||||
{
|
||||
const nlohmann::json disk = globalStateForDisk(app_.state);
|
||||
auto raw = disk.dump(2);
|
||||
raw.push_back('\n');
|
||||
return FileUtil::writeBinaryAtomic(app_.data_path, raw);
|
||||
return db_.setDocument("state", disk);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class StateRepository
|
||||
{
|
||||
public:
|
||||
explicit StateRepository(std::filesystem::path data_path);
|
||||
StateRepository(std::filesystem::path data_path, Database& db);
|
||||
|
||||
AppState& app() { return app_; }
|
||||
const AppState& app() const { return app_; }
|
||||
@@ -30,6 +32,7 @@ public:
|
||||
|
||||
private:
|
||||
AppState app_;
|
||||
Database& db_;
|
||||
|
||||
std::filesystem::path modelsDir() const;
|
||||
std::filesystem::path profileFilePath(const std::string& id) const;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "mission/mission_store.hpp"
|
||||
#include "storage/database.hpp"
|
||||
#include "util/file_util.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
@@ -18,21 +20,27 @@ protected:
|
||||
/ ("lm_test_" + std::to_string(getpid()) + "_"
|
||||
+ std::to_string(seq.fetch_add(1)));
|
||||
std::filesystem::create_directories(dir_);
|
||||
store_path_ = dir_ / "missions.json";
|
||||
std::filesystem::copy_file(std::filesystem::path(TEST_FIXTURE_DIR) / "missions.json",
|
||||
store_path_,
|
||||
std::filesystem::copy_options::overwrite_existing);
|
||||
store_ = std::make_unique<lm::MissionStore>(store_path_);
|
||||
db_ = std::make_unique<lm::Database>(dir_);
|
||||
std::string err;
|
||||
ASSERT_TRUE(db_->init(err)) << err;
|
||||
|
||||
const auto fixture = std::filesystem::path(TEST_FIXTURE_DIR) / "missions.json";
|
||||
const auto raw = lm::FileUtil::readBinary(fixture);
|
||||
auto doc = nlohmann::json::parse(raw);
|
||||
ASSERT_TRUE(db_->setDocument("missions", doc));
|
||||
store_ = std::make_unique<lm::MissionStore>(*db_);
|
||||
}
|
||||
|
||||
void TearDown() override
|
||||
{
|
||||
store_.reset();
|
||||
db_.reset();
|
||||
std::error_code ec;
|
||||
std::filesystem::remove_all(dir_, ec);
|
||||
}
|
||||
|
||||
std::filesystem::path dir_;
|
||||
std::filesystem::path store_path_;
|
||||
std::unique_ptr<lm::Database> db_;
|
||||
std::unique_ptr<lm::MissionStore> store_;
|
||||
};
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
function loadStoreLocal() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_V3);
|
||||
if (!raw) {
|
||||
@@ -145,14 +145,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
let persistTimer = null;
|
||||
|
||||
async function loadStoreFromBackend() {
|
||||
try {
|
||||
const res = await fetch("/api/dashboards", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
loadStoreLocal();
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
store.dashboards = Array.isArray(data.dashboards) ? data.dashboards : [];
|
||||
store.activeDashboardId = data.activeDashboardId || store.dashboards[0]?.id || null;
|
||||
if (!store.dashboards.length) bootstrapDefaultDashboard();
|
||||
else if (!store.activeDashboardId) store.activeDashboardId = store.dashboards[0].id;
|
||||
} catch {
|
||||
loadStoreLocal();
|
||||
}
|
||||
}
|
||||
|
||||
function persistStore() {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_V3,
|
||||
JSON.stringify({
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(syncStoreToBackend, 400);
|
||||
}
|
||||
|
||||
async function syncStoreToBackend() {
|
||||
try {
|
||||
await fetch("/api/dashboards", {
|
||||
credentials: "include",
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dashboards: store.dashboards,
|
||||
activeDashboardId: store.activeDashboardId,
|
||||
})
|
||||
);
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
/* keep in-memory state; retry on next persist */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserGroups() {
|
||||
@@ -765,7 +795,7 @@
|
||||
}
|
||||
|
||||
async function init() {
|
||||
loadStore();
|
||||
await loadStoreFromBackend();
|
||||
await loadUserGroups();
|
||||
bindEvents();
|
||||
setView("list");
|
||||
|
||||
Reference in New Issue
Block a user