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