diff --git a/.gitignore b/.gitignore index 5dde2a7..442d997 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,15 @@ # Built Visual Studio Code Extensions *.vsix -build/ \ No newline at end of file +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 \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 09ac2d0..cef0a6e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/Dockerfile b/Dockerfile index 585aa5e..a907d85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index cd015ae..a970aec 100644 --- a/README.md +++ b/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 ``` diff --git a/data/auth.json b/data/auth.json deleted file mode 100644 index 50bb09c..0000000 --- a/data/auth.json +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/data/maps/.gitkeep b/data/maps/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/mission_queue.json b/data/mission_queue.json deleted file mode 100644 index 5ef4f8a..0000000 --- a/data/mission_queue.json +++ /dev/null @@ -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" - } -} \ No newline at end of file diff --git a/data/missions.json b/data/missions.json deleted file mode 100644 index 9d443fd..0000000 --- a/data/missions.json +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/data/models/a07ab938d9029ef1.json b/data/models/a07ab938d9029ef1.json deleted file mode 100644 index c6abbf7..0000000 --- a/data/models/a07ab938d9029ef1.json +++ /dev/null @@ -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" -} diff --git a/data/models/ea89e39c835c0557.json b/data/models/ea89e39c835c0557.json deleted file mode 100644 index f2b5b72..0000000 --- a/data/models/ea89e39c835c0557.json +++ /dev/null @@ -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" -} diff --git a/data/recordings/.gitkeep b/data/recordings/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/robot_runtime.json b/data/robot_runtime.json deleted file mode 100644 index 9e8b8ec..0000000 --- a/data/robot_runtime.json +++ /dev/null @@ -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" -} \ No newline at end of file diff --git a/data/sounds/.gitkeep b/data/sounds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/state.json b/data/state.json deleted file mode 100644 index c235b58..0000000 --- a/data/state.json +++ /dev/null @@ -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 -} diff --git a/docker-compose.yml b/docker-compose.yml index 3aa080b..87232f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/docs/Reference_guide.md b/docs/Reference_guide.md index c0c154e..fba1f58 100644 --- a/docs/Reference_guide.md +++ b/docs/Reference_guide.md @@ -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` | diff --git a/scripts/README.md b/scripts/README.md index 4391b97..e08617b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,4 +1,4 @@ -# Scripts Test3 +# Scripts RBS CLI thống nhất: `./scripts/lm.sh ` diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index ee941e9..fcf3f8c 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -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)" diff --git a/scripts/lm.sh b/scripts/lm.sh index 0c028fb..aee3e4c 100755 --- a/scripts/lm.sh +++ b/scripts/lm.sh @@ -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)" diff --git a/src/app/lidar_manager_app.cpp b/src/app/lidar_manager_app.cpp index 35d31a8..4797f11 100644 --- a/src/app/lidar_manager_app.cpp +++ b/src/app/lidar_manager_app.cpp @@ -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 @@ -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"); diff --git a/src/auth/auth_service.cpp b/src/auth/auth_service.cpp index 5adddf0..ccd656f 100644 --- a/src/auth/auth_service.cpp +++ b/src/auth/auth_service.cpp @@ -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 lock(mu_); data_ = nlohmann::json::object(); - if (std::filesystem::exists(store_path_)) - { - try - { - data_ = nlohmann::json::parse(FileUtil::readBinary(store_path_)); - } - catch (...) - { - data_ = nlohmann::json::object(); - } - } + 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 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; } diff --git a/src/auth/auth_service.hpp b/src/auth/auth_service.hpp index 365d728..6969a3b 100644 --- a/src/auth/auth_service.hpp +++ b/src/auth/auth_service.hpp @@ -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 sessions_; diff --git a/src/main.cpp b/src/main.cpp index c9dd331..55943b7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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(); diff --git a/src/mission/mission_queue.cpp b/src/mission/mission_queue.cpp index 0de8e2b..6dfb464 100644 --- a/src/mission/mission_queue.cpp +++ b/src/mission/mission_queue.cpp @@ -1,6 +1,6 @@ #include "mission/mission_queue.hpp" -#include "util/file_util.hpp" +#include "storage/database.hpp" #include "util/id_util.hpp" #include @@ -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,22 +60,15 @@ void MissionQueue::load() std::lock_guard 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 + if (parsed.is_object()) { - const auto parsed = nlohmann::json::parse(FileUtil::readBinary(queue_path_)); - if (parsed.is_object()) - { - if (parsed.contains("queue") && parsed["queue"].is_array()) - queue_ = parsed["queue"]; - if (parsed.contains("runner") && parsed["runner"].is_object()) - runner_ = parsed["runner"]; - } - } - catch (...) - { - queue_ = nlohmann::json::array(); + if (parsed.contains("queue") && parsed["queue"].is_array()) + queue_ = parsed["queue"]; + if (parsed.contains("runner") && parsed["runner"].is_object()) + runner_ = parsed["runner"]; } ensureRunnerDefaults(); } @@ -83,7 +76,7 @@ void MissionQueue::load() 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() diff --git a/src/mission/mission_queue.hpp b/src/mission/mission_queue.hpp index 2308138..3a90d04 100644 --- a/src/mission/mission_queue.hpp +++ b/src/mission/mission_queue.hpp @@ -3,7 +3,6 @@ #include #include -#include #include #include #include @@ -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_; diff --git a/src/mission/mission_store.cpp b/src/mission/mission_store.cpp index a7c57db..b4f24db 100644 --- a/src/mission/mission_store.cpp +++ b/src/mission/mission_store.cpp @@ -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 lock(mu_); data_ = nlohmann::json::object(); - if (!std::filesystem::exists(store_path_)) - { - ensureSchemaUnlocked(); - saveUnlocked(); - return; - } - try - { - data_ = nlohmann::json::parse(FileUtil::readBinary(store_path_)); - } - catch (...) - { - data_ = nlohmann::json::object(); - } + const bool existed = db_.getDocument("missions", data_); ensureSchemaUnlocked(); + if (!existed) + saveUnlocked(); } 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 diff --git a/src/mission/mission_store.hpp b/src/mission/mission_store.hpp index fd0b27d..b5dd76f 100644 --- a/src/mission/mission_store.hpp +++ b/src/mission/mission_store.hpp @@ -2,17 +2,18 @@ #include -#include #include #include #include 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_; diff --git a/src/robot/robot_runtime.cpp b/src/robot/robot_runtime.cpp index ffef40c..d4bbc35 100644 --- a/src/robot/robot_runtime.cpp +++ b/src/robot/robot_runtime.cpp @@ -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 @@ -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 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()) - state_ = parsed; - } - catch (...) - { - state_ = nlohmann::json::object(); - } + nlohmann::json parsed; + if (db_.getDocument("robot_runtime", parsed) && parsed.is_object()) + state_ = parsed; } void RobotRuntime::saveUnlocked() const { - FileUtil::writeBinaryAtomic(runtime_path_, state_.dump(2)); + db_.setDocument("robot_runtime", state_); } void RobotRuntime::ensureDefaultsUnlocked() diff --git a/src/robot/robot_runtime.hpp b/src/robot/robot_runtime.hpp index e361762..75fb940 100644 --- a/src/robot/robot_runtime.hpp +++ b/src/robot/robot_runtime.hpp @@ -2,18 +2,18 @@ #include -#include #include #include 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_; diff --git a/src/server/api_dashboard_routes.cpp b/src/server/api_dashboard_routes.cpp new file mode 100644 index 0000000..78e7914 --- /dev/null +++ b/src/server/api_dashboard_routes.cpp @@ -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 diff --git a/src/server/api_media_routes.cpp b/src/server/api_media_routes.cpp new file mode 100644 index 0000000..c161cff --- /dev/null +++ b/src/server/api_media_routes.cpp @@ -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 diff --git a/src/server/api_server.cpp b/src/server/api_server.cpp index 3d70ed4..232cd76 100644 --- a/src/server/api_server.cpp +++ b/src/server/api_server.cpp @@ -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 diff --git a/src/server/api_server.hpp b/src/server/api_server.hpp index 43c35ae..52bf6c7 100644 --- a/src/server/api_server.hpp +++ b/src/server/api_server.hpp @@ -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 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 diff --git a/src/storage/dashboard_store.cpp b/src/storage/dashboard_store.cpp new file mode 100644 index 0000000..e87e271 --- /dev/null +++ b/src/storage/dashboard_store.cpp @@ -0,0 +1,351 @@ +#include "storage/dashboard_store.hpp" + +#include "storage/database.hpp" +#include "util/id_util.hpp" + +#include + +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(sqlite3_column_text(stmt, 0)); + w["type"] = reinterpret_cast(sqlite3_column_text(stmt, 1)); + w["title"] = reinterpret_cast(sqlite3_column_text(stmt, 2)); + if (sqlite3_column_type(stmt, 3) != SQLITE_NULL) + w["mission_id"] = reinterpret_cast(sqlite3_column_text(stmt, 3)); + if (sqlite3_column_type(stmt, 4) != SQLITE_NULL) + w["group"] = reinterpret_cast(sqlite3_column_text(stmt, 4)); + const char* cfg = reinterpret_cast(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(sqlite3_column_text(stmt, 0))); + sqlite3_finalize(stmt); + return groups; +} + +std::optional 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 out; + if (sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_type(stmt, 0) != SQLITE_NULL) + out = reinterpret_cast(sqlite3_column_text(stmt, 0)); + sqlite3_finalize(stmt); + return out; +} + +} // namespace + +DashboardStore::DashboardStore(Database& db) : db_(db) +{ + std::lock_guard 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 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(sqlite3_column_text(stmt, 0)); + nlohmann::json d; + d["id"] = id; + d["name"] = reinterpret_cast(sqlite3_column_text(stmt, 1)); + d["createdBy"] = reinterpret_cast(sqlite3_column_text(stmt, 2)); + if (sqlite3_column_type(stmt, 3) != SQLITE_NULL) + d["createdByUser"] = reinterpret_cast(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 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().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().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().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().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 diff --git a/src/storage/dashboard_store.hpp b/src/storage/dashboard_store.hpp new file mode 100644 index 0000000..e6af995 --- /dev/null +++ b/src/storage/dashboard_store.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include +#include +#include + +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 diff --git a/src/storage/database.cpp b/src/storage/database.cpp new file mode 100644 index 0000000..a5729e8 --- /dev/null +++ b/src/storage/database.cpp @@ -0,0 +1,412 @@ +#include "storage/database.hpp" + +#include "util/file_util.hpp" +#include "util/id_util.hpp" + +#include + +#include + +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 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 out; + if (sqlite3_step(stmt) == SQLITE_ROW) + { + const char* val = reinterpret_cast(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 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(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 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 Database::getLayoutProfile(const std::string& id) const +{ + std::lock_guard 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 out; + if (sqlite3_step(stmt) == SQLITE_ROW) + { + const char* text = reinterpret_cast(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(); + const std::string now = IdUtil::nowIso8601(); + const std::string body = profile.dump(); + + std::lock_guard 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 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 diff --git a/src/storage/database.hpp b/src/storage/database.hpp new file mode 100644 index 0000000..bad7868 --- /dev/null +++ b/src/storage/database.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include + +#include +#include +#include +#include + +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 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 getMeta(const std::string& key) const; + bool setMeta(const std::string& key, const std::string& value); +}; + +} // namespace lm diff --git a/src/storage/map_store.cpp b/src/storage/map_store.cpp new file mode 100644 index 0000000..75f56c3 --- /dev/null +++ b/src/storage/map_store.cpp @@ -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 + +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(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(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 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 MapStore::find(const std::string& id) const +{ + std::lock_guard 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 out; + if (sqlite3_step(stmt) == SQLITE_ROW) + out = rowToJson(stmt); + sqlite3_finalize(stmt); + return out; +} + +std::optional 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 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()); + else + sqlite3_bind_null(stmt, 4); + if (payload.contains("height") && payload["height"].is_number()) + sqlite3_bind_double(stmt, 5, payload["height"].get()); + else + sqlite3_bind_null(stmt, 5); + if (payload.contains("resolution") && payload["resolution"].is_number()) + sqlite3_bind_double(stmt, 6, payload["resolution"].get()); + 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 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()); + else + sqlite3_bind_null(stmt, 4); + if (merged["height"].is_number()) + sqlite3_bind_double(stmt, 5, merged["height"].get()); + else + sqlite3_bind_null(stmt, 5); + if (merged["resolution"].is_number()) + sqlite3_bind_double(stmt, 6, merged["resolution"].get()); + 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 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 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 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 diff --git a/src/storage/map_store.hpp b/src/storage/map_store.hpp new file mode 100644 index 0000000..5122bdc --- /dev/null +++ b/src/storage/map_store.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace lm { + +class Database; + +class MapStore +{ +public: + MapStore(Database& db); + + nlohmann::json list() const; + std::optional find(const std::string& id) const; + std::optional 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 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 diff --git a/src/storage/sound_store.cpp b/src/storage/sound_store.cpp new file mode 100644 index 0000000..779fdb6 --- /dev/null +++ b/src/storage/sound_store.cpp @@ -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 + +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(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 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 SoundStore::find(const std::string& id) const +{ + std::lock_guard 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 out; + if (sqlite3_step(stmt) == SQLITE_ROW) + out = rowToJson(stmt); + sqlite3_finalize(stmt); + return out; +} + +std::optional 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 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 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()); + 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 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 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 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 diff --git a/src/storage/sound_store.hpp b/src/storage/sound_store.hpp new file mode 100644 index 0000000..b613186 --- /dev/null +++ b/src/storage/sound_store.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace lm { + +class Database; + +class SoundStore +{ +public: + SoundStore(Database& db); + + nlohmann::json list() const; + std::optional find(const std::string& id) const; + std::optional 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 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 diff --git a/src/storage/state_repository.cpp b/src/storage/state_repository.cpp index efebb29..0c6e1c8 100644 --- a/src/storage/state_repository.cpp +++ b/src/storage/state_repository.cpp @@ -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 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()), 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 (...) { diff --git a/src/storage/state_repository.hpp b/src/storage/state_repository.hpp index 494286f..d9a3258 100644 --- a/src/storage/state_repository.hpp +++ b/src/storage/state_repository.hpp @@ -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; diff --git a/tests/test_mission_store.cpp b/tests/test_mission_store.cpp index ccf6902..e49ac9f 100644 --- a/tests/test_mission_store.cpp +++ b/tests/test_mission_store.cpp @@ -1,4 +1,6 @@ #include "mission/mission_store.hpp" +#include "storage/database.hpp" +#include "util/file_util.hpp" #include @@ -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(store_path_); + db_ = std::make_unique(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(*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 db_; std::unique_ptr store_; }; diff --git a/www/dashboard.js b/www/dashboard.js index f4ddae1..bd78617 100644 --- a/www/dashboard.js +++ b/www/dashboard.js @@ -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({ - dashboards: store.dashboards, - activeDashboardId: store.activeDashboardId, - }) - ); + 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");