Chuyển lưu trữ dữ liệu sang data base
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-17 11:16:30 +07:00
parent 4054d81aaf
commit 098e1b2b69
45 changed files with 1971 additions and 1657 deletions

11
.gitignore vendored
View File

@@ -13,3 +13,14 @@
*.vsix
build/
# Runtime data (SQLite + media)
data/RBS.db
data/RBS.db-wal
data/RBS.db-shm
data/maps/*
!data/maps/.gitkeep
data/sounds/*
!data/sounds/.gitkeep
data/recordings/*
!data/recordings/.gitkeep

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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
```

View File

@@ -1,76 +0,0 @@
{
"groups": [
{
"allow_pin": false,
"id": "group_distributors",
"name": "Distributors",
"permissions": {
"config": "write",
"dashboard": "write",
"integrations": "write",
"missions": "write",
"users": "write"
}
},
{
"allow_pin": false,
"id": "group_administrators",
"name": "Administrators",
"permissions": {
"config": "write",
"dashboard": "write",
"integrations": "write",
"missions": "write",
"users": "write"
}
},
{
"allow_pin": true,
"id": "group_users",
"name": "Users",
"permissions": {
"config": "read",
"dashboard": "write",
"integrations": "read",
"missions": "read",
"users": "none"
}
}
],
"users": [
{
"display_name": "Distributor",
"enabled": true,
"group_id": "group_distributors",
"id": "user_distributor",
"password_hash": "e245409d2efb801adfb55abc4f8298deff27e86d9c3ca11a05e1403de3d4cc44",
"password_salt": "9c23467cf7b338b6cd27dab6f411135a",
"pin_hash": null,
"pin_salt": null,
"username": "Distributor"
},
{
"display_name": "Administrator",
"enabled": true,
"group_id": "group_administrators",
"id": "user_admin",
"password_hash": "d07eb95a7364e6fb9fe2ce152e3617dc0f23bb943263c5ca2f77a4cbbf5d5396",
"password_salt": "804fec3b7b4910d6bdde1fb3782371e5",
"pin_hash": null,
"pin_salt": null,
"username": "Admin"
},
{
"display_name": "Operator",
"enabled": true,
"group_id": "group_users",
"id": "user_operator",
"password_hash": "b9091e9f6bcbd060231cc2f2e0ae028af88db0bca2af068548cb7604329fbdc9",
"password_salt": "d2eedd0b0d2446af5ba875ebcff658f1",
"pin_hash": "8dd8a6d52c7b7b76fde819aae2d5d3e3e06b321f71f61ef2918be879ace49d71",
"pin_salt": "8d0ec0ed4339dafcb0f099a4c77895a2",
"username": "User"
}
],
"version": 1
}

0
data/maps/.gitkeep Normal file
View File

View File

@@ -1,891 +0,0 @@
{
"queue": [
{
"created_at": "2026-06-15T03:25:12Z",
"finished_at": "2026-06-15T03:26:42Z",
"id": "6732b109c5f13b8f",
"log": [
{
"level": "info",
"message": "Loop endless (simulated, max 10000)",
"ts": "2026-06-15T03:25:12Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:12Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:13Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:14Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:14Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:15Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:16Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:17Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:17Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:18Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:18Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:19Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:20Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:21Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:21Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:22Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:23Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:24Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:24Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:25Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:25Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:26Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:27Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:28Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:28Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:29Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:30Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:31Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:31Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:32Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:32Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:33Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:34Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:35Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:35Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:36Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:37Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:38Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:38Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:39Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:39Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:40Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:41Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:42Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:42Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:43Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:44Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:45Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:45Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:46Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:46Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:47Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:48Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:49Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:49Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:50Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:51Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:52Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:52Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:53Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:53Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:54Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:55Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:56Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:56Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:57Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:58Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:25:59Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:25:59Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:00Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:00Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:01Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:02Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:03Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:03Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:04Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:05Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:06Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:06Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:07Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:07Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:08Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:09Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:10Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:10Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:11Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:12Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:13Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:13Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:14Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:14Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:15Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:16Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:17Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:17Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:18Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:19Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:20Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:20Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:21Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:21Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:22Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:23Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:24Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:24Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:25Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:26Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:27Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:27Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:28Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:28Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:29Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:30Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:31Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:31Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:32Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:33Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:34Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:34Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:35Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:35Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:36Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:37Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:38Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:38Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:39Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:40Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:41Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-15T03:26:41Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-15T03:26:42Z"
},
{
"level": "warn",
"message": "Mission hủy bởi operator",
"ts": "2026-06-15T03:26:42Z"
}
],
"mission": {
"actions": [
{
"children": [
{
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
"kind": "action",
"label": "Set PLC register",
"params": {
"action": "set",
"register": 1,
"value": 0
},
"type": "set_plc_register"
},
{
"id": "a1",
"kind": "action",
"label": "Wait",
"params": {
"seconds": 1
},
"type": "wait"
}
],
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
"kind": "action",
"label": "Loop",
"params": {
"count": 0,
"mode": "endless"
},
"type": "loop"
}
],
"description": "",
"group": "Missions",
"id": "5ae9dbcb0722dffb",
"name": "Test run",
"updated_at": "2026-06-15T03:08:55.138Z"
},
"mission_group": "Missions",
"mission_id": "5ae9dbcb0722dffb",
"mission_name": "Test run",
"parameters": {},
"priority": 0,
"robot_id": "default",
"source": "ui",
"started_at": "2026-06-15T03:25:12Z",
"status": "cancelled"
},
{
"created_at": "2026-06-16T09:41:27Z",
"finished_at": "2026-06-16T09:41:41Z",
"id": "29d42c51d3a96bec",
"log": [
{
"level": "info",
"message": "Loop endless (simulated, max 10000)",
"ts": "2026-06-16T09:41:28Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:28Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:28Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:29Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:29Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:30Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:31Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:32Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:32Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:33Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:34Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:35Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:35Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:36Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:36Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:37Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:38Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:39Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:39Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:40Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:41Z"
},
{
"level": "warn",
"message": "Mission hủy bởi operator",
"ts": "2026-06-16T09:41:41Z"
}
],
"mission": {
"actions": [
{
"children": [
{
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
"kind": "action",
"label": "Set PLC register",
"params": {
"action": "set",
"register": 1,
"value": 0
},
"type": "set_plc_register"
},
{
"id": "a1",
"kind": "action",
"label": "Wait",
"params": {
"seconds": 1
},
"type": "wait"
}
],
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
"kind": "action",
"label": "Loop",
"params": {
"count": 0,
"mode": "endless"
},
"type": "loop"
}
],
"description": "",
"group": "Missions",
"id": "5ae9dbcb0722dffb",
"name": "Test run",
"updated_at": "2026-06-15T03:08:55.138Z"
},
"mission_group": "Missions",
"mission_id": "5ae9dbcb0722dffb",
"mission_name": "Test run",
"parameters": {},
"priority": 0,
"robot_id": "default",
"source": "ui",
"started_at": "2026-06-16T09:41:28Z",
"status": "cancelled"
}
],
"runner": {
"current_action": null,
"current_queue_id": null,
"message": "Đã hủy: Test run",
"paused": false,
"state": "idle",
"updated_at": "2026-06-16T09:41:41Z"
}
}

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -1,197 +0,0 @@
{
"created_at": "2026-05-29T08:40:51Z",
"id": "ea89e39c835c0557",
"imus": [
{
"enabled": true,
"frame_id": "imu_link",
"id": "719a21772e114466",
"name": "IMU",
"rate_hz": 100,
"source": "external",
"topic": "imu/data"
}
],
"layout": {
"imuPoses": {
"719a21772e114466": {
"x": 0.06910131801805619,
"y": 0.8135664703630141,
"yaw_deg": 0,
"z": 0.1
}
},
"imuPosesFrame": "robot",
"lidarPoses": {
"40235913b52d8101": {
"theta_deg": -135,
"x": -120,
"y": -90
},
"f4504deeb605e6ed": {
"theta_deg": 45,
"x": 120,
"y": 90
}
},
"lidarPosesFrame": "robot",
"lidarPositions": {},
"map": {
"height": 600,
"width": 800
},
"robot": {
"bicycle": {
"display": {
"L_px": 240.0,
"r_px": 60.0,
"scale_m_per_px": 0.005
},
"drive": {
"joint_name": "rear_wheel_joint"
},
"limits": {
"cmd_vel_timeout_s": 0.25,
"linear": {
"max_acceleration": 0.8,
"max_velocity": 1
}
},
"steer": {
"joint_name": "front_steer_joint",
"max_angle_deg": 60,
"preview_deg": 15
},
"wheel_radius_m": 0.15,
"wheelbase_m": 1.2,
"wheels": [
{
"id": "rear",
"joint_name": "rear_wheel_joint",
"motor": {
"gear_ratio": 20,
"invert": false,
"model": "m2dc10a",
"vendor": "moons"
},
"role": "drive",
"x_m": 0,
"y_m": 0
},
{
"id": "front",
"joint_name": "front_steer_joint",
"motor": {
"gear_ratio": 20,
"invert": false,
"model": "m2dc10a",
"vendor": "moons"
},
"role": "steer",
"x_m": 1.2,
"y_m": 0
}
]
},
"diff": {
"b": 220.0,
"d": 120.0,
"display": {
"b_px": 220.0,
"d_px": 120.0,
"scale_m_per_px": 0.005
},
"limits": {
"angular": {
"max_acceleration": 1.5,
"max_velocity": 1.7
},
"cmd_vel_timeout_s": 0.25,
"linear": {
"max_acceleration": 0.8,
"max_velocity": 1,
"min_acceleration": -0.8,
"min_velocity": -0.5
}
},
"wheel_radius_m": 0.3,
"wheel_radius_multiplier": 1,
"wheel_separation_m": 1.1,
"wheel_separation_multiplier": 1,
"wheels": [
{
"id": "left",
"joint_name": "wheel_left_joint",
"motor": {
"gear_ratio": 10,
"invert": true,
"model": "m2dc10a",
"vendor": "moons"
},
"side": "left",
"y_m": 0.55
},
{
"id": "right",
"joint_name": "wheel_right_joint",
"motor": {
"gear_ratio": 10,
"invert": false,
"model": "m2dc10a",
"vendor": "moons"
},
"side": "right",
"y_m": -0.55
}
]
},
"footprint": [
{
"x": 150,
"y": 120
},
{
"x": 150,
"y": -120
},
{
"x": -150,
"y": -120
},
{
"x": -150,
"y": 120
}
],
"footprint_params": {
"length_m": 1.5,
"radius_m": 1,
"segments": 32,
"sides": 6,
"width_m": 1.2
},
"footprint_shape": "rectangle",
"frame_id": "base_footprint",
"model": "diff",
"x": 400,
"y": 300,
"yaw_deg": 0
}
},
"lidars": [
{
"id": "f4504deeb605e6ed",
"ip": "192.168.1.11",
"name": "Front",
"port": 2112
},
{
"id": "40235913b52d8101",
"ip": "192.168.1.11",
"name": "Back",
"port": 2112
}
],
"name": "T800",
"updated_at": "2026-06-13T07:03:01Z"
}

0
data/recordings/.gitkeep Normal file
View File

View File

@@ -1,13 +0,0 @@
{
"battery_charging": false,
"battery_percent": 54,
"cmd_angular": 0.0,
"cmd_linear": 0.0,
"error": null,
"health": "ok",
"joystick_engaged": false,
"joystick_speed": "fast",
"message": "Waiting for new missions...",
"motion": "running",
"updated_at": "2026-06-16T10:33:19Z"
}

0
data/sounds/.gitkeep Normal file
View File

View File

@@ -1,24 +0,0 @@
{
"active_layout_id": "ea89e39c835c0557",
"layouts": [
{
"created_at": "2026-05-29T08:27:25Z",
"id": "a07ab938d9029ef1",
"imu_count": 1,
"lidar_count": 3,
"model": "bicycle",
"name": "Mặc định",
"updated_at": "2026-05-29T10:09:07Z"
},
{
"created_at": "2026-05-29T08:40:51Z",
"id": "ea89e39c835c0557",
"imu_count": 1,
"lidar_count": 2,
"model": "diff",
"name": "T800",
"updated_at": "2026-06-13T07:03:01Z"
}
],
"version": 3
}

View File

@@ -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"

View File

@@ -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` |

View File

@@ -1,4 +1,4 @@
# Scripts Test3
# Scripts RBS
CLI thống nhất: `./scripts/lm.sh <nhóm> <lệnh>`

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -9,6 +9,10 @@
#include "robot/robot_runtime.hpp"
#include "server/api_server.hpp"
#include "server/static_file_server.hpp"
#include "storage/dashboard_store.hpp"
#include "storage/database.hpp"
#include "storage/map_store.hpp"
#include "storage/sound_store.hpp"
#include "storage/state_repository.hpp"
#include <httplib.h>
@@ -25,14 +29,24 @@ LidarManagerApp::LidarManagerApp(int port,
int LidarManagerApp::run()
{
StateRepository repo(data_path_);
const std::filesystem::path data_dir = data_path_.parent_path();
Database database(data_dir);
std::string db_err;
if (!database.init(db_err))
{
std::fprintf(stderr, "database init failed: %s\n", db_err.c_str());
return 1;
}
StateRepository repo(data_path_, database);
repo.load();
const std::filesystem::path mission_queue_path = data_path_.parent_path() / "mission_queue.json";
const std::filesystem::path missions_store_path = data_path_.parent_path() / "missions.json";
MissionQueue mission_queue(mission_queue_path);
MissionStore mission_store(missions_store_path);
RobotRuntime robot_runtime(data_path_.parent_path() / "robot_runtime.json", mission_queue);
MissionQueue mission_queue(database);
MissionStore mission_store(database);
RobotRuntime robot_runtime(database, mission_queue);
MapStore map_store(database);
SoundStore sound_store(database);
DashboardStore dashboard_store(database);
const auto enqueue_fn = [&mission_store, &mission_queue](const nlohmann::json& request, std::string& err) -> bool {
nlohmann::json payload;
@@ -44,24 +58,33 @@ int LidarManagerApp::run()
ModbusTriggerService modbus(mission_store, enqueue_fn, 5502);
MissionScheduler scheduler(mission_store, enqueue_fn);
AuthService auth(data_path_.parent_path() / "auth.json");
AuthService auth(database);
httplib::Server svr;
svr.set_pre_routing_handler([&auth](const httplib::Request& req, httplib::Response& res) {
return auth.preRoute(req, res);
});
ApiServer api(repo, mission_queue, mission_store, modbus, scheduler, robot_runtime);
ApiServer api(repo,
mission_queue,
mission_store,
modbus,
scheduler,
robot_runtime,
map_store,
sound_store,
dashboard_store);
api.registerRoutes(svr);
auth.registerRoutes(svr);
StaticFileServer::mount(svr, www_root_);
std::fprintf(stderr,
"lidar_manager_web listening on http://0.0.0.0:%d (www=%s, state=%s, models=%s)\n",
"lidar_manager_web listening on http://0.0.0.0:%d (www=%s, db=%s, maps=%s, sounds=%s)\n",
port_,
www_root_.string().c_str(),
data_path_.string().c_str(),
(data_path_.parent_path() / "models").string().c_str());
database.dbPath().string().c_str(),
database.mapsDir().string().c_str(),
database.soundsDir().string().c_str());
std::fprintf(stderr, "MiR REST API: http://0.0.0.0:%d/api/v2.0.0/mission_queue\n", port_);
std::fprintf(stderr, "Modbus TCP triggers: port 5502 (coils 1001-2000)\n");

View File

@@ -1,7 +1,7 @@
#include "auth/auth_service.hpp"
#include "storage/database.hpp"
#include "util/crypto_util.hpp"
#include "util/file_util.hpp"
#include "util/http_util.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
@@ -56,7 +56,7 @@ nlohmann::json makeUser(const std::string& id,
} // namespace
AuthService::AuthService(std::filesystem::path store_path) : store_path_(std::move(store_path))
AuthService::AuthService(Database& db) : db_(db)
{
loadOrSeed();
}
@@ -65,17 +65,8 @@ void AuthService::loadOrSeed()
{
std::lock_guard<std::mutex> lock(mu_);
data_ = nlohmann::json::object();
if (std::filesystem::exists(store_path_))
{
try
{
data_ = nlohmann::json::parse(FileUtil::readBinary(store_path_));
}
catch (...)
{
if (!db_.getDocument("auth", data_))
data_ = nlohmann::json::object();
}
}
if (!data_.contains("version"))
data_["version"] = 1;
@@ -109,10 +100,7 @@ void AuthService::loadOrSeed()
void AuthService::saveUnlocked()
{
const auto parent = store_path_.parent_path();
if (!parent.empty())
std::filesystem::create_directories(parent);
FileUtil::writeBinaryAtomic(store_path_, data_.dump(2));
db_.setDocument("auth", data_);
}
const AuthSession* AuthService::currentSession() const
@@ -164,7 +152,11 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
path.rfind("/api/robots", 0) == 0 || path.rfind("/api/fleet", 0) == 0 ||
path.rfind("/api/modbus", 0) == 0 || path.rfind("/api/v2.0.0/", 0) == 0)
return "integrations";
if (path.rfind("/api/", 0) == 0)
if (path.rfind("/api/dashboards", 0) == 0)
return "dashboard";
if (path.rfind("/api/sounds", 0) == 0)
return "integrations";
if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/recordings", 0) == 0)
return "config";
return std::nullopt;
}

View File

@@ -11,6 +11,8 @@
namespace lm {
class Database;
struct AuthSession
{
std::string token;
@@ -24,7 +26,7 @@ struct AuthSession
class AuthService
{
public:
explicit AuthService(std::filesystem::path store_path);
explicit AuthService(Database& db);
httplib::Server::HandlerResponse preRoute(const httplib::Request& req, httplib::Response& res);
@@ -55,7 +57,7 @@ public:
void registerRoutes(httplib::Server& svr);
private:
std::filesystem::path store_path_;
Database& db_;
mutable std::mutex mu_;
nlohmann::json data_;
std::unordered_map<std::string, AuthSession> sessions_;

View File

@@ -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();

View File

@@ -1,6 +1,6 @@
#include "mission/mission_queue.hpp"
#include "util/file_util.hpp"
#include "storage/database.hpp"
#include "util/id_util.hpp"
#include <chrono>
@@ -40,7 +40,7 @@ double paramNumber(const nlohmann::json& params, const std::string& key, double
} // namespace
MissionQueue::MissionQueue(std::filesystem::path queue_path) : queue_path_(std::move(queue_path))
MissionQueue::MissionQueue(Database& db) : db_(db)
{
load();
ensureRunnerDefaults();
@@ -60,11 +60,9 @@ void MissionQueue::load()
std::lock_guard<std::recursive_mutex> lock(mu_);
queue_ = nlohmann::json::array();
runner_ = nlohmann::json::object();
if (!std::filesystem::exists(queue_path_))
nlohmann::json parsed;
if (!db_.getDocument("mission_queue", parsed))
return;
try
{
const auto parsed = nlohmann::json::parse(FileUtil::readBinary(queue_path_));
if (parsed.is_object())
{
if (parsed.contains("queue") && parsed["queue"].is_array())
@@ -72,18 +70,13 @@ void MissionQueue::load()
if (parsed.contains("runner") && parsed["runner"].is_object())
runner_ = parsed["runner"];
}
}
catch (...)
{
queue_ = nlohmann::json::array();
}
ensureRunnerDefaults();
}
void MissionQueue::saveUnlocked() const
{
const nlohmann::json payload = {{"queue", queue_}, {"runner", runner_}};
FileUtil::writeBinaryAtomic(queue_path_, payload.dump(2));
db_.setDocument("mission_queue", payload);
}
void MissionQueue::ensureRunnerDefaults()

View File

@@ -3,7 +3,6 @@
#include <nlohmann/json.hpp>
#include <atomic>
#include <filesystem>
#include <mutex>
#include <optional>
#include <string>
@@ -11,10 +10,12 @@
namespace lm {
class Database;
class MissionQueue
{
public:
explicit MissionQueue(std::filesystem::path queue_path);
explicit MissionQueue(Database& db);
~MissionQueue();
MissionQueue(const MissionQueue&) = delete;
@@ -34,7 +35,7 @@ public:
private:
enum class LoopControl { None, Break, Continue };
std::filesystem::path queue_path_;
Database& db_;
mutable std::recursive_mutex mu_;
nlohmann::json queue_;
nlohmann::json runner_;

View File

@@ -1,6 +1,6 @@
#include "mission/mission_store.hpp"
#include "util/file_util.hpp"
#include "storage/database.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
@@ -18,7 +18,7 @@ bool coilIdValid(int coil_id)
} // namespace
MissionStore::MissionStore(std::filesystem::path store_path) : store_path_(std::move(store_path))
MissionStore::MissionStore(Database& db) : db_(db)
{
load();
}
@@ -27,21 +27,10 @@ void MissionStore::load()
{
std::lock_guard<std::mutex> lock(mu_);
data_ = nlohmann::json::object();
if (!std::filesystem::exists(store_path_))
{
const bool existed = db_.getDocument("missions", data_);
ensureSchemaUnlocked();
if (!existed)
saveUnlocked();
return;
}
try
{
data_ = nlohmann::json::parse(FileUtil::readBinary(store_path_));
}
catch (...)
{
data_ = nlohmann::json::object();
}
ensureSchemaUnlocked();
}
void MissionStore::ensureSchemaUnlocked()
@@ -69,7 +58,7 @@ void MissionStore::ensureSchemaUnlocked()
void MissionStore::saveUnlocked() const
{
FileUtil::writeBinaryAtomic(store_path_, data_.dump(2));
db_.setDocument("missions", data_);
}
nlohmann::json MissionStore::snapshot() const

View File

@@ -2,17 +2,18 @@
#include <nlohmann/json.hpp>
#include <filesystem>
#include <mutex>
#include <optional>
#include <string>
namespace lm {
class Database;
class MissionStore
{
public:
explicit MissionStore(std::filesystem::path store_path);
explicit MissionStore(Database& db);
nlohmann::json snapshot() const;
bool replace(const nlohmann::json& payload, std::string& err);
@@ -34,7 +35,7 @@ public:
nlohmann::json listRobots() const;
private:
std::filesystem::path store_path_;
Database& db_;
mutable std::mutex mu_;
nlohmann::json data_;

View File

@@ -1,7 +1,7 @@
#include "robot/robot_runtime.hpp"
#include "mission/mission_queue.hpp"
#include "util/file_util.hpp"
#include "storage/database.hpp"
#include "util/id_util.hpp"
#include <algorithm>
@@ -16,8 +16,8 @@ constexpr const char* kDefaultMessage = "Waiting for new missions...";
} // namespace
RobotRuntime::RobotRuntime(std::filesystem::path runtime_path, MissionQueue& mission_queue)
: runtime_path_(std::move(runtime_path)), mission_queue_(mission_queue)
RobotRuntime::RobotRuntime(Database& db, MissionQueue& mission_queue)
: db_(db), mission_queue_(mission_queue)
{
load();
ensureDefaultsUnlocked();
@@ -27,23 +27,14 @@ void RobotRuntime::load()
{
std::lock_guard<std::mutex> lock(mu_);
state_ = nlohmann::json::object();
if (!std::filesystem::exists(runtime_path_))
return;
try
{
const auto parsed = nlohmann::json::parse(FileUtil::readBinary(runtime_path_));
if (parsed.is_object())
nlohmann::json parsed;
if (db_.getDocument("robot_runtime", parsed) && parsed.is_object())
state_ = parsed;
}
catch (...)
{
state_ = nlohmann::json::object();
}
}
void RobotRuntime::saveUnlocked() const
{
FileUtil::writeBinaryAtomic(runtime_path_, state_.dump(2));
db_.setDocument("robot_runtime", state_);
}
void RobotRuntime::ensureDefaultsUnlocked()

View File

@@ -2,18 +2,18 @@
#include <nlohmann/json.hpp>
#include <filesystem>
#include <mutex>
#include <string>
namespace lm {
class Database;
class MissionQueue;
class RobotRuntime
{
public:
explicit RobotRuntime(std::filesystem::path runtime_path, MissionQueue& mission_queue);
explicit RobotRuntime(Database& db, MissionQueue& mission_queue);
nlohmann::json status() const;
bool start(std::string& err);
@@ -24,7 +24,7 @@ public:
void tick();
private:
std::filesystem::path runtime_path_;
Database& db_;
MissionQueue& mission_queue_;
mutable std::mutex mu_;
nlohmann::json state_;

View File

@@ -0,0 +1,34 @@
#include "server/api_server.hpp"
#include "util/http_util.hpp"
namespace lm {
void ApiServer::registerDashboardRoutes(httplib::Server& svr)
{
svr.Get("/api/dashboards", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = dashboard_store_.snapshot().dump();
});
svr.Put("/api/dashboards", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
if (!dashboard_store_.replace(body, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = dashboard_store_.snapshot().dump();
});
}
} // namespace lm

View File

@@ -0,0 +1,197 @@
#include "server/api_server.hpp"
#include "util/file_util.hpp"
#include "util/http_util.hpp"
namespace lm {
void ApiServer::registerMediaRoutes(httplib::Server& svr)
{
svr.Get("/api/maps", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"maps", map_store_.list()}}).dump();
});
svr.Get(R"(/api/maps/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
const auto map = map_store_.find(id);
if (!map)
return HttpUtil::jsonError(res, 404, "map not found");
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = map->dump();
});
svr.Post("/api/maps", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
const auto created = map_store_.create(body, err);
if (!created)
return HttpUtil::jsonError(res, 400, err);
res.status = 201;
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = created->dump();
});
svr.Put(R"(/api/maps/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
if (!map_store_.update(id, body, err))
return HttpUtil::jsonError(res, 404, err);
const auto updated = map_store_.find(id);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
});
svr.Delete(R"(/api/maps/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
std::string err;
if (!map_store_.remove(id, err))
return HttpUtil::jsonError(res, 404, err);
res.status = 204;
});
svr.Get(R"(/api/maps/([^/]+)/image$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
const auto path = map_store_.imagePath(id);
if (!path)
return HttpUtil::jsonError(res, 404, "map image not found");
res.set_header("Content-Type", HttpUtil::contentTypeForPath(*path));
res.body = FileUtil::readBinary(*path);
});
svr.Post(R"(/api/maps/([^/]+)/image$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
if (!req.form.has_file("file"))
return HttpUtil::jsonError(res, 400, "file is required");
const auto& file = req.form.get_file("file");
const std::string filename = file.filename.empty() ? "map.png" : file.filename;
std::string err;
if (!map_store_.saveImageFile(id, filename, file.content, err))
return HttpUtil::jsonError(res, 400, err);
const auto updated = map_store_.find(id);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
});
svr.Get("/api/sounds", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"sounds", sound_store_.list()}}).dump();
});
svr.Get(R"(/api/sounds/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
const auto sound = sound_store_.find(id);
if (!sound)
return HttpUtil::jsonError(res, 404, "sound not found");
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = sound->dump();
});
svr.Post("/api/sounds", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
const auto created = sound_store_.create(body, err);
if (!created)
return HttpUtil::jsonError(res, 400, err);
res.status = 201;
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = created->dump();
});
svr.Put(R"(/api/sounds/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
if (!sound_store_.update(id, body, err))
return HttpUtil::jsonError(res, 404, err);
const auto updated = sound_store_.find(id);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
});
svr.Delete(R"(/api/sounds/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
std::string err;
if (!sound_store_.remove(id, err))
return HttpUtil::jsonError(res, 404, err);
res.status = 204;
});
svr.Get(R"(/api/sounds/([^/]+)/file$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
const auto path = sound_store_.filePath(id);
if (!path)
return HttpUtil::jsonError(res, 404, "sound file not found");
res.set_header("Content-Type", HttpUtil::contentTypeForPath(*path));
res.body = FileUtil::readBinary(*path);
});
svr.Post(R"(/api/sounds/([^/]+)/file$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
if (!req.form.has_file("file"))
return HttpUtil::jsonError(res, 400, "file is required");
const auto& file = req.form.get_file("file");
const std::string filename = file.filename.empty() ? (id + ".wav") : file.filename;
std::string err;
if (!sound_store_.saveFile(id, filename, file.content, err))
return HttpUtil::jsonError(res, 400, err);
const auto updated = sound_store_.find(id);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
});
svr.Get("/api/recordings", [](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"recordings", nlohmann::json::array()}}).dump();
});
}
} // namespace lm

View File

@@ -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

View File

@@ -7,6 +7,9 @@
#include "mission/mission_store.hpp"
#include "mission/modbus_trigger_service.hpp"
#include "robot/robot_runtime.hpp"
#include "storage/dashboard_store.hpp"
#include "storage/map_store.hpp"
#include "storage/sound_store.hpp"
#include "storage/state_repository.hpp"
namespace lm {
@@ -19,7 +22,10 @@ public:
MissionStore& mission_store,
ModbusTriggerService& modbus,
MissionScheduler& scheduler,
RobotRuntime& robot_runtime);
RobotRuntime& robot_runtime,
MapStore& map_store,
SoundStore& sound_store,
DashboardStore& dashboard_store);
void registerRoutes(httplib::Server& svr);
@@ -30,6 +36,9 @@ private:
ModbusTriggerService& modbus_;
MissionScheduler& scheduler_;
RobotRuntime& robot_runtime_;
MapStore& map_store_;
SoundStore& sound_store_;
DashboardStore& dashboard_store_;
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
std::optional<nlohmann::json> enqueueMission(const nlohmann::json& request, std::string& err);
@@ -38,6 +47,8 @@ private:
void registerMirV2Routes(httplib::Server& svr);
void registerIntegrationRoutes(httplib::Server& svr);
void registerRobotRoutes(httplib::Server& svr);
void registerMediaRoutes(httplib::Server& svr);
void registerDashboardRoutes(httplib::Server& svr);
};
} // namespace lm

View File

@@ -0,0 +1,351 @@
#include "storage/dashboard_store.hpp"
#include "storage/database.hpp"
#include "util/id_util.hpp"
#include <sqlite3.h>
namespace lm {
namespace {
const char* kDefaultId = "dashboard_default";
nlohmann::json loadWidgets(sqlite3* db, const std::string& dashboard_id)
{
nlohmann::json widgets = nlohmann::json::array();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db,
"SELECT id, type, title, mission_id, mission_group, config_json, sort_order "
"FROM dashboard_widgets WHERE dashboard_id = ?1 ORDER BY sort_order, id",
-1,
&stmt,
nullptr) != SQLITE_OK)
return widgets;
sqlite3_bind_text(stmt, 1, dashboard_id.c_str(), -1, SQLITE_TRANSIENT);
while (sqlite3_step(stmt) == SQLITE_ROW)
{
nlohmann::json w;
w["id"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
w["type"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
w["title"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
if (sqlite3_column_type(stmt, 3) != SQLITE_NULL)
w["mission_id"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
if (sqlite3_column_type(stmt, 4) != SQLITE_NULL)
w["group"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 4));
const char* cfg = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 5));
if (cfg)
{
try
{
const auto extra = nlohmann::json::parse(cfg);
if (extra.is_object())
{
for (auto it = extra.begin(); it != extra.end(); ++it)
{
if (!w.contains(it.key()))
w[it.key()] = it.value();
}
}
}
catch (...)
{
}
}
widgets.push_back(w);
}
sqlite3_finalize(stmt);
return widgets;
}
nlohmann::json loadEditGroups(sqlite3* db, const std::string& dashboard_id)
{
nlohmann::json groups = nlohmann::json::array();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db,
"SELECT group_id FROM dashboard_edit_groups WHERE dashboard_id = ?1 ORDER BY group_id",
-1,
&stmt,
nullptr) != SQLITE_OK)
return groups;
sqlite3_bind_text(stmt, 1, dashboard_id.c_str(), -1, SQLITE_TRANSIENT);
while (sqlite3_step(stmt) == SQLITE_ROW)
groups.push_back(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0)));
sqlite3_finalize(stmt);
return groups;
}
std::optional<std::string> loadActiveDashboardId(sqlite3* db)
{
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db, "SELECT active_dashboard_id FROM dashboard_state WHERE id = 1", -1, &stmt, nullptr) !=
SQLITE_OK)
return std::nullopt;
std::optional<std::string> out;
if (sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_type(stmt, 0) != SQLITE_NULL)
out = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
sqlite3_finalize(stmt);
return out;
}
} // namespace
DashboardStore::DashboardStore(Database& db) : db_(db)
{
std::lock_guard<std::mutex> lock(mu_);
ensureDefaultsUnlocked();
}
void DashboardStore::ensureDefaultsUnlocked()
{
sqlite3_stmt* count_stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), "SELECT COUNT(*) FROM dashboards", -1, &count_stmt, nullptr) != SQLITE_OK)
return;
int count = 0;
if (sqlite3_step(count_stmt) == SQLITE_ROW)
count = sqlite3_column_int(count_stmt, 0);
sqlite3_finalize(count_stmt);
if (count > 0)
return;
const std::string now = IdUtil::nowIso8601();
sqlite3_stmt* ins = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"INSERT INTO dashboards(id, name, created_by, created_by_user, is_default, sort_order, "
"created_at, updated_at) VALUES(?1,?2,?3,NULL,1,0,?4,?4)",
-1,
&ins,
nullptr) != SQLITE_OK)
return;
sqlite3_bind_text(ins, 1, kDefaultId, -1, SQLITE_STATIC);
sqlite3_bind_text(ins, 2, "Default Dashboard", -1, SQLITE_STATIC);
sqlite3_bind_text(ins, 3, "MiR", -1, SQLITE_STATIC);
sqlite3_bind_text(ins, 4, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(ins);
sqlite3_finalize(ins);
for (const char* gid :
{"group_administrators", "group_distributors", "group_users"})
{
sqlite3_stmt* g = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"INSERT OR IGNORE INTO dashboard_edit_groups(dashboard_id, group_id) VALUES(?1,?2)",
-1,
&g,
nullptr) != SQLITE_OK)
continue;
sqlite3_bind_text(g, 1, kDefaultId, -1, SQLITE_STATIC);
sqlite3_bind_text(g, 2, gid, -1, SQLITE_STATIC);
sqlite3_step(g);
sqlite3_finalize(g);
}
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"INSERT OR REPLACE INTO dashboard_state(id, active_dashboard_id) VALUES(1, ?1)",
-1,
&st,
nullptr) == SQLITE_OK)
{
sqlite3_bind_text(st, 1, kDefaultId, -1, SQLITE_STATIC);
sqlite3_step(st);
sqlite3_finalize(st);
}
}
nlohmann::json DashboardStore::snapshot() const
{
std::lock_guard<std::mutex> lock(mu_);
nlohmann::json dashboards = nlohmann::json::array();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"SELECT id, name, created_by, created_by_user, is_default FROM dashboards ORDER BY sort_order, name",
-1,
&stmt,
nullptr) != SQLITE_OK)
return {{"dashboards", dashboards}, {"activeDashboardId", nullptr}};
while (sqlite3_step(stmt) == SQLITE_ROW)
{
const std::string id = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
nlohmann::json d;
d["id"] = id;
d["name"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
d["createdBy"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
if (sqlite3_column_type(stmt, 3) != SQLITE_NULL)
d["createdByUser"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
else
d["createdByUser"] = nullptr;
d["isDefault"] = sqlite3_column_int(stmt, 4) != 0;
d["editGroups"] = loadEditGroups(db_.handle(), id);
d["widgets"] = loadWidgets(db_.handle(), id);
dashboards.push_back(d);
}
sqlite3_finalize(stmt);
std::string active_id = kDefaultId;
if (auto active = loadActiveDashboardId(db_.handle()))
active_id = *active;
if (dashboards.empty())
return {{"dashboards", nlohmann::json::array()}, {"activeDashboardId", nullptr}};
return {{"dashboards", dashboards}, {"activeDashboardId", active_id}};
}
bool DashboardStore::replace(const nlohmann::json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be an object";
return false;
}
if (!payload.contains("dashboards") || !payload["dashboards"].is_array())
{
err = "dashboards array is required";
return false;
}
std::lock_guard<std::mutex> lock(mu_);
sqlite3* db = db_.handle();
char* msg = nullptr;
if (sqlite3_exec(db, "BEGIN IMMEDIATE", nullptr, nullptr, &msg) != SQLITE_OK)
{
err = msg ? msg : "begin failed";
sqlite3_free(msg);
return false;
}
auto rollback = [&]() {
sqlite3_exec(db, "ROLLBACK", nullptr, nullptr, nullptr);
};
if (sqlite3_exec(db, "DELETE FROM dashboard_widgets", nullptr, nullptr, &msg) != SQLITE_OK ||
sqlite3_exec(db, "DELETE FROM dashboard_edit_groups", nullptr, nullptr, &msg) != SQLITE_OK ||
sqlite3_exec(db, "DELETE FROM dashboards", nullptr, nullptr, &msg) != SQLITE_OK)
{
err = msg ? msg : "clear failed";
sqlite3_free(msg);
rollback();
return false;
}
const std::string now = IdUtil::nowIso8601();
int sort = 0;
for (const auto& d : payload["dashboards"])
{
if (!d.is_object() || !d.contains("id"))
continue;
const std::string id = d.value("id", IdUtil::newId());
sqlite3_stmt* ins = nullptr;
if (sqlite3_prepare_v2(db,
"INSERT INTO dashboards(id, name, created_by, created_by_user, is_default, sort_order, "
"created_at, updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?7)",
-1,
&ins,
nullptr) != SQLITE_OK)
{
rollback();
err = "insert dashboard failed";
return false;
}
sqlite3_bind_text(ins, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ins, 2, d.value("name", "Dashboard").c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ins, 3, d.value("createdBy", "").c_str(), -1, SQLITE_TRANSIENT);
if (d.contains("createdByUser") && !d["createdByUser"].is_null())
sqlite3_bind_text(ins, 4, d["createdByUser"].get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
else
sqlite3_bind_null(ins, 4);
sqlite3_bind_int(ins, 5, d.value("isDefault", false) ? 1 : 0);
sqlite3_bind_int(ins, 6, sort++);
sqlite3_bind_text(ins, 7, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(ins);
sqlite3_finalize(ins);
if (d.contains("editGroups") && d["editGroups"].is_array())
{
for (const auto& g : d["editGroups"])
{
if (!g.is_string())
continue;
sqlite3_stmt* gs = nullptr;
if (sqlite3_prepare_v2(db,
"INSERT INTO dashboard_edit_groups(dashboard_id, group_id) VALUES(?1,?2)",
-1,
&gs,
nullptr) == SQLITE_OK)
{
sqlite3_bind_text(gs, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(gs, 2, g.get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(gs);
sqlite3_finalize(gs);
}
}
}
if (d.contains("widgets") && d["widgets"].is_array())
{
int wsort = 0;
for (const auto& w : d["widgets"])
{
if (!w.is_object())
continue;
nlohmann::json config = nlohmann::json::object();
for (auto it = w.begin(); it != w.end(); ++it)
{
if (it.key() != "id" && it.key() != "type" && it.key() != "title" && it.key() != "mission_id" &&
it.key() != "group")
config[it.key()] = it.value();
}
sqlite3_stmt* ws = nullptr;
if (sqlite3_prepare_v2(db,
"INSERT INTO dashboard_widgets(id, dashboard_id, type, title, mission_id, mission_group, "
"config_json, sort_order) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
-1,
&ws,
nullptr) != SQLITE_OK)
continue;
const std::string wid = w.value("id", IdUtil::newId());
sqlite3_bind_text(ws, 1, wid.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ws, 2, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ws, 3, w.value("type", "unknown").c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ws, 4, w.value("title", "").c_str(), -1, SQLITE_TRANSIENT);
if (w.contains("mission_id") && w["mission_id"].is_string())
sqlite3_bind_text(ws, 5, w["mission_id"].get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
else
sqlite3_bind_null(ws, 5);
if (w.contains("group") && w["group"].is_string())
sqlite3_bind_text(ws, 6, w["group"].get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
else
sqlite3_bind_null(ws, 6);
const std::string cfg = config.dump();
sqlite3_bind_text(ws, 7, cfg.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(ws, 8, wsort++);
sqlite3_step(ws);
sqlite3_finalize(ws);
}
}
}
const std::string active = payload.value("activeDashboardId", kDefaultId);
sqlite3_stmt* ast = nullptr;
if (sqlite3_prepare_v2(db,
"INSERT OR REPLACE INTO dashboard_state(id, active_dashboard_id) VALUES(1, ?1)",
-1,
&ast,
nullptr) == SQLITE_OK)
{
sqlite3_bind_text(ast, 1, active.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(ast);
sqlite3_finalize(ast);
}
if (sqlite3_exec(db, "COMMIT", nullptr, nullptr, &msg) != SQLITE_OK)
{
err = msg ? msg : "commit failed";
sqlite3_free(msg);
rollback();
return false;
}
return true;
}
} // namespace lm

View File

@@ -0,0 +1,28 @@
#pragma once
#include <nlohmann/json.hpp>
#include <mutex>
#include <optional>
#include <string>
namespace lm {
class Database;
class DashboardStore
{
public:
explicit DashboardStore(Database& db);
nlohmann::json snapshot() const;
bool replace(const nlohmann::json& payload, std::string& err);
private:
Database& db_;
mutable std::mutex mu_;
void ensureDefaultsUnlocked();
};
} // namespace lm

412
src/storage/database.cpp Normal file
View File

@@ -0,0 +1,412 @@
#include "storage/database.hpp"
#include "util/file_util.hpp"
#include "util/id_util.hpp"
#include <sqlite3.h>
#include <cstdio>
namespace lm {
namespace {
bool execSql(sqlite3* db, const char* sql, std::string& err)
{
char* msg = nullptr;
const int rc = sqlite3_exec(db, sql, nullptr, nullptr, &msg);
if (rc != SQLITE_OK)
{
err = msg ? msg : sqlite3_errstr(rc);
sqlite3_free(msg);
return false;
}
return true;
}
const char* kSchemaSql = R"SQL(
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS documents (
name TEXT PRIMARY KEY,
content TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS layout_profiles (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS maps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
width REAL,
height REAL,
resolution REAL,
origin_x REAL DEFAULT 0,
origin_y REAL DEFAULT 0,
origin_yaw REAL DEFAULT 0,
image_file TEXT,
yaml_file TEXT,
zones_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sounds (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
file_name TEXT,
duration_ms INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS recordings (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
map_id TEXT,
file_path TEXT,
started_at TEXT,
ended_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS dashboards (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
created_by_user TEXT,
is_default INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS dashboard_edit_groups (
dashboard_id TEXT NOT NULL,
group_id TEXT NOT NULL,
PRIMARY KEY (dashboard_id, group_id),
FOREIGN KEY (dashboard_id) REFERENCES dashboards(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dashboard_widgets (
id TEXT PRIMARY KEY,
dashboard_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
mission_id TEXT,
mission_group TEXT,
config_json TEXT NOT NULL DEFAULT '{}',
sort_order INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (dashboard_id) REFERENCES dashboards(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dashboard_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
active_dashboard_id TEXT
);
)SQL";
} // namespace
Database::Database(std::filesystem::path data_dir)
: data_dir_(std::move(data_dir)), db_path_(data_dir_ / "RBS.db")
{
}
void Database::close()
{
if (db_)
{
sqlite3_close(db_);
db_ = nullptr;
}
}
bool Database::openDb(std::string& err)
{
std::error_code ec;
std::filesystem::create_directories(data_dir_, ec);
const auto legacy_path = data_dir_ / "test3.db";
if (!std::filesystem::exists(db_path_) && std::filesystem::exists(legacy_path))
{
std::filesystem::rename(legacy_path, db_path_, ec);
for (const char* suffix : {"-wal", "-shm"})
{
const auto from = legacy_path.string() + suffix;
const auto to = db_path_.string() + suffix;
if (std::filesystem::exists(from))
std::filesystem::rename(from, to, ec);
}
}
const int rc = sqlite3_open(db_path_.string().c_str(), &db_);
if (rc != SQLITE_OK)
{
err = sqlite3_errmsg(db_);
db_ = nullptr;
return false;
}
sqlite3_busy_timeout(db_, 5000);
if (!execSql(db_, "PRAGMA journal_mode=WAL;", err))
return false;
if (!execSql(db_, "PRAGMA synchronous=NORMAL;", err))
return false;
if (!execSql(db_, "PRAGMA foreign_keys=ON;", err))
return false;
return true;
}
bool Database::ensureDataDirs(std::string& err)
{
std::error_code ec;
for (const auto& dir : {mapsDir(), soundsDir(), recordingsDir()})
{
if (!std::filesystem::create_directories(dir, ec) && ec)
{
err = "failed to create directory: " + dir.string();
return false;
}
}
return true;
}
bool Database::applySchema(std::string& err)
{
return execSql(db_, kSchemaSql, err);
}
std::optional<std::string> Database::getMeta(const std::string& key) const
{
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "SELECT value FROM meta WHERE key = ?1", -1, &stmt, nullptr) != SQLITE_OK)
return std::nullopt;
sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT);
std::optional<std::string> out;
if (sqlite3_step(stmt) == SQLITE_ROW)
{
const char* val = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (val)
out = val;
}
sqlite3_finalize(stmt);
return out;
}
bool Database::setMeta(const std::string& key, const std::string& value)
{
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT INTO meta(key, value) VALUES(?1, ?2) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
-1,
&stmt,
nullptr) != SQLITE_OK)
return false;
sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, value.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return ok;
}
bool Database::getDocument(const std::string& name, nlohmann::json& out) const
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "SELECT content FROM documents WHERE name = ?1", -1, &stmt, nullptr) != SQLITE_OK)
return false;
sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT);
bool found = false;
if (sqlite3_step(stmt) == SQLITE_ROW)
{
const char* text = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (text)
{
try
{
out = nlohmann::json::parse(text);
found = true;
}
catch (...)
{
found = false;
}
}
}
sqlite3_finalize(stmt);
return found;
}
bool Database::setDocument(const std::string& name, const nlohmann::json& doc)
{
std::lock_guard<std::mutex> lock(mu_);
const std::string now = IdUtil::nowIso8601();
const std::string body = doc.dump();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT INTO documents(name, content, updated_at) VALUES(?1, ?2, ?3) "
"ON CONFLICT(name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at",
-1,
&stmt,
nullptr) != SQLITE_OK)
return false;
sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, body.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return ok;
}
std::optional<nlohmann::json> Database::getLayoutProfile(const std::string& id) const
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "SELECT content FROM layout_profiles WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
return std::nullopt;
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
std::optional<nlohmann::json> out;
if (sqlite3_step(stmt) == SQLITE_ROW)
{
const char* text = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (text)
{
try
{
out = nlohmann::json::parse(text);
}
catch (...)
{
out = std::nullopt;
}
}
}
sqlite3_finalize(stmt);
return out;
}
bool Database::setLayoutProfile(const nlohmann::json& profile)
{
if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string())
return false;
const std::string id = profile["id"].get<std::string>();
const std::string now = IdUtil::nowIso8601();
const std::string body = profile.dump();
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT INTO layout_profiles(id, content, updated_at) VALUES(?1, ?2, ?3) "
"ON CONFLICT(id) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at",
-1,
&stmt,
nullptr) != SQLITE_OK)
return false;
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, body.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return ok;
}
bool Database::deleteLayoutProfile(const std::string& id)
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM layout_profiles WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
return false;
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return ok;
}
bool Database::migrateFromJsonIfNeeded(std::string& err)
{
if (getMeta("json_imported"))
return true;
const auto importDoc = [&](const std::string& name, const std::filesystem::path& path) {
if (!std::filesystem::exists(path))
return;
try
{
const auto doc = nlohmann::json::parse(FileUtil::readBinary(path));
setDocument(name, doc);
}
catch (...)
{
}
};
importDoc("auth", data_dir_ / "auth.json");
importDoc("missions", data_dir_ / "missions.json");
importDoc("mission_queue", data_dir_ / "mission_queue.json");
importDoc("robot_runtime", data_dir_ / "robot_runtime.json");
const auto state_path = data_dir_ / "state.json";
if (std::filesystem::exists(state_path))
{
try
{
setDocument("state", nlohmann::json::parse(FileUtil::readBinary(state_path)));
}
catch (...)
{
}
}
const auto models_dir = data_dir_ / "models";
if (std::filesystem::is_directory(models_dir))
{
for (const auto& entry : std::filesystem::directory_iterator(models_dir))
{
if (!entry.is_regular_file() || entry.path().extension() != ".json")
continue;
try
{
const auto profile = nlohmann::json::parse(FileUtil::readBinary(entry.path()));
setLayoutProfile(profile);
}
catch (...)
{
}
}
}
setMeta("schema_version", "1");
setMeta("json_imported", IdUtil::nowIso8601());
std::fprintf(stderr, "SQLite: imported JSON data into %s\n", db_path_.string().c_str());
return true;
}
bool Database::init(std::string& err)
{
if (!openDb(err))
return false;
if (!applySchema(err))
return false;
if (!ensureDataDirs(err))
return false;
if (!migrateFromJsonIfNeeded(err))
return false;
if (!getMeta("schema_version"))
setMeta("schema_version", "1");
return true;
}
} // namespace lm

51
src/storage/database.hpp Normal file
View File

@@ -0,0 +1,51 @@
#pragma once
#include <nlohmann/json.hpp>
#include <filesystem>
#include <mutex>
#include <optional>
#include <string>
struct sqlite3;
namespace lm {
class Database
{
public:
explicit Database(std::filesystem::path data_dir);
bool init(std::string& err);
void close();
std::filesystem::path dataDir() const { return data_dir_; }
std::filesystem::path dbPath() const { return db_path_; }
std::filesystem::path mapsDir() const { return data_dir_ / "maps"; }
std::filesystem::path soundsDir() const { return data_dir_ / "sounds"; }
std::filesystem::path recordingsDir() const { return data_dir_ / "recordings"; }
bool getDocument(const std::string& name, nlohmann::json& out) const;
bool setDocument(const std::string& name, const nlohmann::json& doc);
std::optional<nlohmann::json> getLayoutProfile(const std::string& id) const;
bool setLayoutProfile(const nlohmann::json& profile);
bool deleteLayoutProfile(const std::string& id);
sqlite3* handle() const { return db_; }
private:
std::filesystem::path data_dir_;
std::filesystem::path db_path_;
sqlite3* db_ = nullptr;
mutable std::mutex mu_;
bool openDb(std::string& err);
bool applySchema(std::string& err);
bool migrateFromJsonIfNeeded(std::string& err);
bool ensureDataDirs(std::string& err);
std::optional<std::string> getMeta(const std::string& key) const;
bool setMeta(const std::string& key, const std::string& value);
};
} // namespace lm

338
src/storage/map_store.cpp Normal file
View File

@@ -0,0 +1,338 @@
#include "storage/map_store.hpp"
#include "storage/database.hpp"
#include "util/file_util.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
#include <sqlite3.h>
namespace lm {
namespace {
nlohmann::json rowToJson(sqlite3_stmt* stmt)
{
auto textOrNull = [&](int col) -> nlohmann::json {
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
return nullptr;
return nlohmann::json(reinterpret_cast<const char*>(sqlite3_column_text(stmt, col)));
};
auto realOrNull = [&](int col) -> nlohmann::json {
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
return nullptr;
return nlohmann::json(sqlite3_column_double(stmt, col));
};
nlohmann::json zones = nlohmann::json::array();
if (sqlite3_column_type(stmt, 11) != SQLITE_NULL)
{
try
{
zones = nlohmann::json::parse(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 11)));
}
catch (...)
{
zones = nlohmann::json::array();
}
}
return {{"id", textOrNull(0)},
{"name", textOrNull(1)},
{"description", textOrNull(2)},
{"width", realOrNull(3)},
{"height", realOrNull(4)},
{"resolution", realOrNull(5)},
{"origin_x", realOrNull(6)},
{"origin_y", realOrNull(7)},
{"origin_yaw", realOrNull(8)},
{"image_file", textOrNull(9)},
{"yaml_file", textOrNull(10)},
{"zones", zones},
{"created_at", textOrNull(12)},
{"updated_at", textOrNull(13)}};
}
} // namespace
MapStore::MapStore(Database& db) : db_(db) {}
std::filesystem::path MapStore::mapDir(const std::string& id) const
{
return db_.mapsDir() / id;
}
nlohmann::json MapStore::list() const
{
std::lock_guard<std::mutex> lock(mu_);
nlohmann::json maps = nlohmann::json::array();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"SELECT id, name, description, width, height, resolution, "
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
"created_at, updated_at FROM maps ORDER BY name",
-1,
&stmt,
nullptr) != SQLITE_OK)
return maps;
while (sqlite3_step(stmt) == SQLITE_ROW)
maps.push_back(rowToJson(stmt));
sqlite3_finalize(stmt);
return maps;
}
std::optional<nlohmann::json> MapStore::find(const std::string& id) const
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"SELECT id, name, description, width, height, resolution, "
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
"created_at, updated_at FROM maps WHERE id = ?1",
-1,
&stmt,
nullptr) != SQLITE_OK)
return std::nullopt;
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
std::optional<nlohmann::json> out;
if (sqlite3_step(stmt) == SQLITE_ROW)
out = rowToJson(stmt);
sqlite3_finalize(stmt);
return out;
}
std::optional<nlohmann::json> MapStore::create(const nlohmann::json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be an object";
return std::nullopt;
}
const std::string name = StringUtil::trimCopy(payload.value("name", ""));
if (name.empty())
{
err = "name is required";
return std::nullopt;
}
const std::string id = payload.value("id", IdUtil::newId());
const std::string now = IdUtil::nowIso8601();
const std::string description = payload.value("description", "");
const auto zones = payload.contains("zones") ? payload["zones"] : nlohmann::json::array();
std::error_code ec;
std::filesystem::create_directories(mapDir(id), ec);
if (ec)
{
err = "failed to create map directory";
return std::nullopt;
}
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"INSERT INTO maps(id, name, description, width, height, resolution, "
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
"created_at, updated_at) "
"VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
-1,
&stmt,
nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return std::nullopt;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, description.c_str(), -1, SQLITE_TRANSIENT);
if (payload.contains("width") && payload["width"].is_number())
sqlite3_bind_double(stmt, 4, payload["width"].get<double>());
else
sqlite3_bind_null(stmt, 4);
if (payload.contains("height") && payload["height"].is_number())
sqlite3_bind_double(stmt, 5, payload["height"].get<double>());
else
sqlite3_bind_null(stmt, 5);
if (payload.contains("resolution") && payload["resolution"].is_number())
sqlite3_bind_double(stmt, 6, payload["resolution"].get<double>());
else
sqlite3_bind_null(stmt, 6);
sqlite3_bind_double(stmt, 7, payload.value("origin_x", 0.0));
sqlite3_bind_double(stmt, 8, payload.value("origin_y", 0.0));
sqlite3_bind_double(stmt, 9, payload.value("origin_yaw", 0.0));
sqlite3_bind_null(stmt, 10);
sqlite3_bind_null(stmt, 11);
const std::string zones_str = zones.dump();
sqlite3_bind_text(stmt, 12, zones_str.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 13, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 14, now.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(stmt) != SQLITE_DONE)
{
err = sqlite3_errmsg(db_.handle());
sqlite3_finalize(stmt);
return std::nullopt;
}
sqlite3_finalize(stmt);
nlohmann::json created;
created["id"] = id;
created["name"] = name;
created["description"] = description;
if (payload.contains("width") && payload["width"].is_number())
created["width"] = payload["width"];
if (payload.contains("height") && payload["height"].is_number())
created["height"] = payload["height"];
if (payload.contains("resolution") && payload["resolution"].is_number())
created["resolution"] = payload["resolution"];
created["origin_x"] = payload.value("origin_x", 0.0);
created["origin_y"] = payload.value("origin_y", 0.0);
created["origin_yaw"] = payload.value("origin_yaw", 0.0);
created["image_file"] = nullptr;
created["yaml_file"] = nullptr;
created["zones"] = zones;
created["created_at"] = now;
created["updated_at"] = now;
return created;
}
bool MapStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
{
auto existing = find(id);
if (!existing)
{
err = "map not found";
return false;
}
nlohmann::json merged = *existing;
for (const char* key : {"name", "description", "width", "height", "resolution", "origin_x", "origin_y", "origin_yaw"})
{
if (payload.contains(key))
merged[key] = payload[key];
}
if (payload.contains("zones"))
merged["zones"] = payload["zones"];
const std::string now = IdUtil::nowIso8601();
const std::string zones_str = merged.value("zones", nlohmann::json::array()).dump();
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"UPDATE maps SET name=?2, description=?3, width=?4, height=?5, resolution=?6, "
"origin_x=?7, origin_y=?8, origin_yaw=?9, zones_json=?10, updated_at=?11 WHERE id=?1",
-1,
&stmt,
nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return false;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, merged.value("name", "").c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, merged.value("description", "").c_str(), -1, SQLITE_TRANSIENT);
if (merged["width"].is_number())
sqlite3_bind_double(stmt, 4, merged["width"].get<double>());
else
sqlite3_bind_null(stmt, 4);
if (merged["height"].is_number())
sqlite3_bind_double(stmt, 5, merged["height"].get<double>());
else
sqlite3_bind_null(stmt, 5);
if (merged["resolution"].is_number())
sqlite3_bind_double(stmt, 6, merged["resolution"].get<double>());
else
sqlite3_bind_null(stmt, 6);
sqlite3_bind_double(stmt, 7, merged.value("origin_x", 0.0));
sqlite3_bind_double(stmt, 8, merged.value("origin_y", 0.0));
sqlite3_bind_double(stmt, 9, merged.value("origin_yaw", 0.0));
sqlite3_bind_text(stmt, 10, zones_str.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 11, now.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
if (!ok)
err = sqlite3_errmsg(db_.handle());
sqlite3_finalize(stmt);
return ok;
}
bool MapStore::remove(const std::string& id, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM maps WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return false;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
if (!ok)
{
err = "map not found";
return false;
}
std::error_code ec;
std::filesystem::remove_all(mapDir(id), ec);
return true;
}
std::optional<std::filesystem::path> MapStore::imagePath(const std::string& id) const
{
const auto map = find(id);
if (!map || !(*map)["image_file"].is_string())
return std::nullopt;
const auto path = mapDir(id) / map->value("image_file", "");
if (!std::filesystem::exists(path))
return std::nullopt;
return path;
}
bool MapStore::saveImageFile(const std::string& id,
const std::string& filename,
const std::string& bytes,
std::string& err)
{
if (!find(id))
{
err = "map not found";
return false;
}
std::error_code ec;
std::filesystem::create_directories(mapDir(id), ec);
const auto path = mapDir(id) / filename;
if (!FileUtil::writeBinaryAtomic(path, bytes))
{
err = "failed to write image file";
return false;
}
const std::string now = IdUtil::nowIso8601();
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"UPDATE maps SET image_file = ?2, updated_at = ?3 WHERE id = ?1",
-1,
&stmt,
nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return false;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, filename.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
if (!ok)
err = sqlite3_errmsg(db_.handle());
sqlite3_finalize(stmt);
return ok;
}
} // namespace lm

34
src/storage/map_store.hpp Normal file
View File

@@ -0,0 +1,34 @@
#pragma once
#include <nlohmann/json.hpp>
#include <filesystem>
#include <mutex>
#include <optional>
#include <string>
namespace lm {
class Database;
class MapStore
{
public:
MapStore(Database& db);
nlohmann::json list() const;
std::optional<nlohmann::json> find(const std::string& id) const;
std::optional<nlohmann::json> create(const nlohmann::json& payload, std::string& err);
bool update(const std::string& id, const nlohmann::json& payload, std::string& err);
bool remove(const std::string& id, std::string& err);
std::filesystem::path mapDir(const std::string& id) const;
std::optional<std::filesystem::path> imagePath(const std::string& id) const;
bool saveImageFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
private:
Database& db_;
mutable std::mutex mu_;
};
} // namespace lm

257
src/storage/sound_store.cpp Normal file
View File

@@ -0,0 +1,257 @@
#include "storage/sound_store.hpp"
#include "storage/database.hpp"
#include "util/file_util.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
#include <sqlite3.h>
namespace lm {
namespace {
nlohmann::json rowToJson(sqlite3_stmt* stmt)
{
auto textOrNull = [&](int col) -> nlohmann::json {
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
return nullptr;
return nlohmann::json(reinterpret_cast<const char*>(sqlite3_column_text(stmt, col)));
};
return {{"id", textOrNull(0)},
{"name", textOrNull(1)},
{"description", textOrNull(2)},
{"file_name", textOrNull(3)},
{"duration_ms", sqlite3_column_type(stmt, 4) == SQLITE_NULL
? nlohmann::json(nullptr)
: nlohmann::json(sqlite3_column_int(stmt, 4))},
{"enabled", sqlite3_column_int(stmt, 5) != 0},
{"created_at", textOrNull(6)},
{"updated_at", textOrNull(7)}};
}
} // namespace
SoundStore::SoundStore(Database& db) : db_(db) {}
nlohmann::json SoundStore::list() const
{
std::lock_guard<std::mutex> lock(mu_);
nlohmann::json sounds = nlohmann::json::array();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"SELECT id, name, description, file_name, duration_ms, enabled, created_at, updated_at "
"FROM sounds ORDER BY name",
-1,
&stmt,
nullptr) != SQLITE_OK)
return sounds;
while (sqlite3_step(stmt) == SQLITE_ROW)
sounds.push_back(rowToJson(stmt));
sqlite3_finalize(stmt);
return sounds;
}
std::optional<nlohmann::json> SoundStore::find(const std::string& id) const
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"SELECT id, name, description, file_name, duration_ms, enabled, created_at, updated_at "
"FROM sounds WHERE id = ?1",
-1,
&stmt,
nullptr) != SQLITE_OK)
return std::nullopt;
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
std::optional<nlohmann::json> out;
if (sqlite3_step(stmt) == SQLITE_ROW)
out = rowToJson(stmt);
sqlite3_finalize(stmt);
return out;
}
std::optional<nlohmann::json> SoundStore::create(const nlohmann::json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be an object";
return std::nullopt;
}
const std::string name = StringUtil::trimCopy(payload.value("name", ""));
if (name.empty())
{
err = "name is required";
return std::nullopt;
}
const std::string id = payload.value("id", IdUtil::newId());
const std::string now = IdUtil::nowIso8601();
const std::string description = payload.value("description", "");
const bool enabled = payload.value("enabled", true);
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"INSERT INTO sounds(id, name, description, file_name, duration_ms, enabled, created_at, updated_at) "
"VALUES(?1,?2,?3,NULL,NULL,?4,?5,?6)",
-1,
&stmt,
nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return std::nullopt;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, description.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 4, enabled ? 1 : 0);
sqlite3_bind_text(stmt, 5, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(stmt) != SQLITE_DONE)
{
err = sqlite3_errmsg(db_.handle());
sqlite3_finalize(stmt);
return std::nullopt;
}
sqlite3_finalize(stmt);
return nlohmann::json{{"id", id},
{"name", name},
{"description", description},
{"file_name", nullptr},
{"duration_ms", nullptr},
{"enabled", enabled},
{"created_at", now},
{"updated_at", now}};
}
bool SoundStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
{
auto existing = find(id);
if (!existing)
{
err = "sound not found";
return false;
}
nlohmann::json merged = *existing;
for (const char* key : {"name", "description", "enabled", "duration_ms"})
{
if (payload.contains(key))
merged[key] = payload[key];
}
const std::string now = IdUtil::nowIso8601();
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"UPDATE sounds SET name=?2, description=?3, enabled=?4, duration_ms=?5, updated_at=?6 WHERE id=?1",
-1,
&stmt,
nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return false;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, merged.value("name", "").c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, merged.value("description", "").c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 4, merged.value("enabled", true) ? 1 : 0);
if (merged["duration_ms"].is_number_integer())
sqlite3_bind_int(stmt, 5, merged["duration_ms"].get<int>());
else
sqlite3_bind_null(stmt, 5);
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
if (!ok)
err = sqlite3_errmsg(db_.handle());
sqlite3_finalize(stmt);
return ok;
}
bool SoundStore::remove(const std::string& id, std::string& err)
{
auto existing = find(id);
if (!existing)
{
err = "sound not found";
return false;
}
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM sounds WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return false;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
if (existing->contains("file_name") && (*existing)["file_name"].is_string())
{
const auto path = db_.soundsDir() / existing->value("file_name", "");
std::error_code ec;
std::filesystem::remove(path, ec);
}
return ok;
}
std::optional<std::filesystem::path> SoundStore::filePath(const std::string& id) const
{
const auto sound = find(id);
if (!sound || !(*sound)["file_name"].is_string())
return std::nullopt;
const auto path = db_.soundsDir() / sound->value("file_name", "");
if (!std::filesystem::exists(path))
return std::nullopt;
return path;
}
bool SoundStore::saveFile(const std::string& id,
const std::string& filename,
const std::string& bytes,
std::string& err)
{
if (!find(id))
{
err = "sound not found";
return false;
}
std::error_code ec;
std::filesystem::create_directories(db_.soundsDir(), ec);
const auto path = db_.soundsDir() / filename;
if (!FileUtil::writeBinaryAtomic(path, bytes))
{
err = "failed to write sound file";
return false;
}
const std::string now = IdUtil::nowIso8601();
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"UPDATE sounds SET file_name = ?2, updated_at = ?3 WHERE id = ?1",
-1,
&stmt,
nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return false;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, filename.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
if (!ok)
err = sqlite3_errmsg(db_.handle());
sqlite3_finalize(stmt);
return ok;
}
} // namespace lm

View File

@@ -0,0 +1,33 @@
#pragma once
#include <nlohmann/json.hpp>
#include <filesystem>
#include <mutex>
#include <optional>
#include <string>
namespace lm {
class Database;
class SoundStore
{
public:
SoundStore(Database& db);
nlohmann::json list() const;
std::optional<nlohmann::json> find(const std::string& id) const;
std::optional<nlohmann::json> create(const nlohmann::json& payload, std::string& err);
bool update(const std::string& id, const nlohmann::json& payload, std::string& err);
bool remove(const std::string& id, std::string& err);
std::optional<std::filesystem::path> filePath(const std::string& id) const;
bool saveFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
private:
Database& db_;
mutable std::mutex mu_;
};
} // namespace lm

View File

@@ -2,6 +2,7 @@
#include "domain/layout_profile.hpp"
#include "domain/layout_schema.hpp"
#include "storage/database.hpp"
#include "util/file_util.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
@@ -20,6 +21,8 @@ std::filesystem::path StateRepository::profileFilePath(const std::string& id) co
std::optional<nlohmann::json> StateRepository::loadProfileFromDisk(const std::string& id) const
{
if (auto profile = db_.getLayoutProfile(id))
return profile;
const auto raw = FileUtil::readBinary(profileFilePath(id));
if (raw.empty())
return std::nullopt;
@@ -37,15 +40,12 @@ bool StateRepository::saveProfileToDisk(const nlohmann::json& profile) const
{
if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string())
return false;
std::error_code ec;
std::filesystem::create_directories(modelsDir(), ec);
auto body = profile.dump(2);
body.push_back('\n');
return FileUtil::writeBinaryAtomic(profileFilePath(profile["id"].get<std::string>()), body);
return db_.setLayoutProfile(profile);
}
bool StateRepository::deleteProfileFile(const std::string& id) const
{
db_.deleteLayoutProfile(id);
std::error_code ec;
std::filesystem::remove(profileFilePath(id), ec);
return true;
@@ -243,15 +243,14 @@ void StateRepository::bootstrapDefaultState()
app_.state["imus"] = profile.contains("imus") ? profile["imus"] : nlohmann::json::array();
}
StateRepository::StateRepository(std::filesystem::path data_path)
StateRepository::StateRepository(std::filesystem::path data_path, Database& db) : db_(db)
{
app_.data_path = std::move(data_path);
}
bool StateRepository::load()
{
const auto raw = FileUtil::readBinary(app_.data_path);
if (raw.empty())
if (!db_.getDocument("state", app_.state))
{
bootstrapDefaultState();
save();
@@ -259,7 +258,6 @@ bool StateRepository::load()
}
try
{
app_.state = nlohmann::json::parse(raw);
ensureSchema();
save();
return true;
@@ -309,9 +307,7 @@ bool StateRepository::save() const
try
{
const nlohmann::json disk = globalStateForDisk(app_.state);
auto raw = disk.dump(2);
raw.push_back('\n');
return FileUtil::writeBinaryAtomic(app_.data_path, raw);
return db_.setDocument("state", disk);
}
catch (...)
{

View File

@@ -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;

View File

@@ -1,4 +1,6 @@
#include "mission/mission_store.hpp"
#include "storage/database.hpp"
#include "util/file_util.hpp"
#include <gtest/gtest.h>
@@ -18,21 +20,27 @@ protected:
/ ("lm_test_" + std::to_string(getpid()) + "_"
+ std::to_string(seq.fetch_add(1)));
std::filesystem::create_directories(dir_);
store_path_ = dir_ / "missions.json";
std::filesystem::copy_file(std::filesystem::path(TEST_FIXTURE_DIR) / "missions.json",
store_path_,
std::filesystem::copy_options::overwrite_existing);
store_ = std::make_unique<lm::MissionStore>(store_path_);
db_ = std::make_unique<lm::Database>(dir_);
std::string err;
ASSERT_TRUE(db_->init(err)) << err;
const auto fixture = std::filesystem::path(TEST_FIXTURE_DIR) / "missions.json";
const auto raw = lm::FileUtil::readBinary(fixture);
auto doc = nlohmann::json::parse(raw);
ASSERT_TRUE(db_->setDocument("missions", doc));
store_ = std::make_unique<lm::MissionStore>(*db_);
}
void TearDown() override
{
store_.reset();
db_.reset();
std::error_code ec;
std::filesystem::remove_all(dir_, ec);
}
std::filesystem::path dir_;
std::filesystem::path store_path_;
std::unique_ptr<lm::Database> db_;
std::unique_ptr<lm::MissionStore> store_;
};

View File

@@ -128,7 +128,7 @@
}
}
function loadStore() {
function loadStoreLocal() {
try {
const raw = localStorage.getItem(STORAGE_KEY_V3);
if (!raw) {
@@ -145,14 +145,44 @@
}
}
let persistTimer = null;
async function loadStoreFromBackend() {
try {
const res = await fetch("/api/dashboards", { credentials: "include" });
if (!res.ok) {
loadStoreLocal();
return;
}
const data = await res.json();
store.dashboards = Array.isArray(data.dashboards) ? data.dashboards : [];
store.activeDashboardId = data.activeDashboardId || store.dashboards[0]?.id || null;
if (!store.dashboards.length) bootstrapDefaultDashboard();
else if (!store.activeDashboardId) store.activeDashboardId = store.dashboards[0].id;
} catch {
loadStoreLocal();
}
}
function persistStore() {
localStorage.setItem(
STORAGE_KEY_V3,
JSON.stringify({
clearTimeout(persistTimer);
persistTimer = setTimeout(syncStoreToBackend, 400);
}
async function syncStoreToBackend() {
try {
await fetch("/api/dashboards", {
credentials: "include",
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dashboards: store.dashboards,
activeDashboardId: store.activeDashboardId,
})
);
}),
});
} catch {
/* keep in-memory state; retry on next persist */
}
}
async function loadUserGroups() {
@@ -765,7 +795,7 @@
}
async function init() {
loadStore();
await loadStoreFromBackend();
await loadUserGroups();
bindEvents();
setView("list");