Compare commits

..

11 Commits

Author SHA1 Message Date
199f8c0537 4.7.2 delete maps
Some checks are pending
Test / test (push) Waiting to run
2026-06-21 09:18:14 +02:00
7a850937b0 fix origin
Some checks failed
Test / test (push) Has been cancelled
2026-06-21 09:09:19 +02:00
064c9b5758 update draw line and shape
Some checks failed
Test / test (push) Has been cancelled
2026-06-21 06:31:18 +02:00
365a15c32a update full objects type
Some checks failed
Test / test (push) Has been cancelled
2026-06-20 11:43:48 +02:00
90e8e9d252 Xong phần map viewer
Some checks failed
Test / test (push) Has been cancelled
2026-06-20 09:18:19 +02:00
819323f8c8 add function map viewer
Some checks failed
Test / test (push) Has been cancelled
2026-06-20 10:53:49 +07:00
a6cf06d7eb Add phần create map by upload
Some checks failed
Test / test (push) Has been cancelled
2026-06-19 11:52:21 +07:00
098e1b2b69 Chuyển lưu trữ dữ liệu sang data base
Some checks failed
Test / test (push) Has been cancelled
2026-06-17 11:16:30 +07:00
4054d81aaf add function create dashboard
Some checks failed
Test / test (push) Has been cancelled
2026-06-17 10:19:14 +07:00
a2e87aeb29 Add function Language
Some checks failed
Test / test (push) Has been cancelled
2026-06-16 16:44:04 +07:00
1156e1ab29 add top bar 2026-06-16 11:17:28 +07:00
82 changed files with 19458 additions and 2031 deletions

15
.gitignore vendored
View File

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

View File

@@ -27,6 +27,8 @@ if(NOT nlohmann_json_POPULATED)
FetchContent_Populate(nlohmann_json) 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,11 @@ 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/site_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
@@ -47,10 +54,14 @@ add_executable(lidar_manager_web
src/mission/mission_enqueue.cpp src/mission/mission_enqueue.cpp
src/mission/modbus_trigger_service.cpp src/mission/modbus_trigger_service.cpp
src/mission/mission_scheduler.cpp src/mission/mission_scheduler.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_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"
@@ -81,6 +92,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
@@ -102,7 +114,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)

View File

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

BIN
RBS.db Normal file

Binary file not shown.

BIN
RBS.db-shm Normal file

Binary file not shown.

BIN
RBS.db-wal Normal file

Binary file not shown.

View File

@@ -1,22 +1,24 @@
# LiDAR Manager 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
``` ```

53
data/Denso_1/Denso_1.pgm Normal file

File diff suppressed because one or more lines are too long

BIN
data/Denso_1/Denso_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
data/Denso_1/Denso_1.xloc Normal file

Binary file not shown.

View File

@@ -0,0 +1,6 @@
image: Denso_1.png
resolution: 0.050000
origin: [-12.238091, -13.200000, 0.0]
negate: 0
occupied_thresh: 0.65
free_thresh: 0.196

View File

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

0
data/maps/.gitkeep Normal file
View File

View File

@@ -1,724 +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"
}
],
"runner": {
"current_action": null,
"current_queue_id": null,
"message": "Đã hủy: Test run",
"paused": false,
"state": "idle",
"updated_at": "2026-06-15T03:26:42Z"
}
}

View File

@@ -1,85 +0,0 @@
{
"dashboard": {
"widgets": []
},
"groups": [
"Missions",
"Move",
"Logic",
"I/O",
"Cart",
"Misc"
],
"missions": [
{
"actions": [
{
"children": [
{
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
"kind": "action",
"label": "Set PLC register",
"params": {
"action": "set",
"register": 1,
"value": 0
},
"type": "set_plc_register"
},
{
"id": "a1",
"kind": "action",
"label": "Wait",
"params": {
"seconds": 1
},
"type": "wait"
}
],
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
"kind": "action",
"label": "Loop",
"params": {
"count": 0,
"mode": "endless"
},
"type": "loop"
}
],
"description": "",
"group": "Missions",
"id": "5ae9dbcb0722dffb",
"name": "Test run",
"updated_at": "2026-06-15T03:08:55.138Z"
},
{
"actions": [
{
"id": "a1",
"kind": "action",
"label": "Wait",
"params": {
"seconds": 1
},
"type": "wait"
}
],
"description": "",
"group": "Missions",
"id": "68950059fc0bd633",
"name": "Test run 3",
"updated_at": "2026-06-13T04:45:08Z"
}
],
"robots": [
{
"id": "default",
"name": "Robot chính",
"online": true,
"serial": "PX-001"
}
],
"schedules": [],
"triggers": [],
"version": 1
}

View File

@@ -1,224 +0,0 @@
{
"created_at": "2026-05-29T08:27:25Z",
"id": "a07ab938d9029ef1",
"imus": [
{
"enabled": true,
"frame_id": "imu_link",
"id": "f7ddb6d2c3c1c5cf",
"name": "IMU test",
"rate_hz": 100,
"source": "onboard",
"topic": "imu/data"
}
],
"layout": {
"imuPoses": {
"f7ddb6d2c3c1c5cf": {
"x": 196.14886948882076,
"y": 0.1286840744156286,
"yaw_deg": 0,
"z": 0.1
}
},
"imuPosesFrame": "robot",
"lidarPoses": {
"02c4b7f4de7bd639": {
"theta_deg": 45,
"x": 215,
"y": 40
},
"1e591c93c581f705": {
"theta_deg": -45,
"x": 215.39984362180326,
"y": -40
},
"242be6d6e782ecdf": {
"theta_deg": 180,
"x": 145,
"y": -0.3738614899159438
}
},
"lidarPosesFrame": "robot",
"lidarPositions": {},
"map": {
"height": 600,
"width": 800
},
"robot": {
"bicycle": {
"display": {
"L_px": 240.0,
"r_px": 60.0,
"scale_m_per_px": 0.005
},
"drive": {
"joint_name": "rear_wheel_joint"
},
"limits": {
"cmd_vel_timeout_s": 0.25,
"linear": {
"max_acceleration": 0.8,
"max_velocity": 1
}
},
"steer": {
"joint_name": "front_steer_joint",
"max_angle_deg": 60,
"preview_deg": 15
},
"wheel_radius_m": 0.15,
"wheelbase_m": 1.2,
"wheels": [
{
"id": "rear",
"joint_name": "rear_wheel_joint",
"motor": {
"gear_ratio": 20,
"invert": false,
"model": "m2dc10a",
"vendor": "moons"
},
"role": "drive",
"x_m": 0,
"y_m": 0
},
{
"id": "front",
"joint_name": "front_steer_joint",
"motor": {
"gear_ratio": 20,
"invert": false,
"model": "m2dc10a",
"vendor": "moons"
},
"role": "steer",
"x_m": 1.2,
"y_m": 0
}
]
},
"diff": {
"b": 200.0,
"d": 120.0,
"display": {
"b_px": 200.0,
"d_px": 120.0,
"scale_m_per_px": 0.005
},
"limits": {
"angular": {
"max_acceleration": 1.5,
"max_velocity": 1.7
},
"cmd_vel_timeout_s": 0.25,
"linear": {
"max_acceleration": 0.8,
"max_velocity": 1,
"min_acceleration": -0.4,
"min_velocity": -0.5
}
},
"wheel_radius_m": 0.3,
"wheel_radius_multiplier": 1,
"wheel_separation_m": 1,
"wheel_separation_multiplier": 1,
"wheels": [
{
"id": "left",
"joint_name": "wheel_left_joint",
"motor": {
"gear_ratio": 20,
"invert": false,
"model": "m2dc10a",
"vendor": "moons"
},
"side": "left",
"y_m": 0.5
},
{
"id": "right",
"joint_name": "wheel_right_joint",
"motor": {
"gear_ratio": 20,
"invert": false,
"model": "m2dc10a",
"vendor": "moons"
},
"side": "right",
"y_m": -0.5
}
]
},
"footprint": [
{
"x": 249.49596246923238,
"y": 76.53128468019501
},
{
"x": 252.05138984920825,
"y": -73.40426803273583
},
{
"x": 146.0988213814129,
"y": -73.14624094113161
},
{
"x": 146.4579317148541,
"y": -36.76005121552378
},
{
"x": -24.190052366845578,
"y": -36.232153738354725
},
{
"x": -23.18092513013994,
"y": 31.895774646867324
},
{
"x": 149.1507088069675,
"y": 31.363038836025066
},
{
"x": 148.2973527630072,
"y": 77.68471811183447
}
],
"footprint_params": {
"length_m": 1.69,
"radius_m": 0.8432486399759678,
"segments": 32,
"sides": 6,
"width_m": 1.28
},
"footprint_shape": "custom",
"frame_id": "base_footprint",
"model": "bicycle",
"x": 400,
"y": 300,
"yaw_deg": 0
}
},
"lidars": [
{
"id": "02c4b7f4de7bd639",
"ip": "192.168.1.11",
"name": "Front",
"port": 2112
},
{
"id": "1e591c93c581f705",
"ip": "192.168.1.12",
"name": "Back",
"port": 2112
},
{
"id": "242be6d6e782ecdf",
"ip": "192.168.1.15",
"name": "Oile",
"port": 2112
}
],
"name": "Mặc định",
"updated_at": "2026-05-29T10:09:07Z"
}

View File

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

0
data/recordings/.gitkeep Normal file
View File

0
data/sounds/.gitkeep Normal file
View File

View File

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

View File

@@ -1,7 +1,9 @@
name: rbs
services: 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"

View File

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

View File

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

View File

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

View File

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

View File

@@ -99,6 +99,29 @@ assert_json_true "auth me" "$TMP/me.json" 'doc.get("user",{}).get("group_name")
assert_code "GET /" 200 "$TMP/index.html" -X GET "$BASE/" assert_code "GET /" 200 "$TMP/index.html" -X GET "$BASE/"
assert_code "GET /auth.js" 200 "$TMP/auth.js" -X GET "$BASE/auth.js" assert_code "GET /auth.js" 200 "$TMP/auth.js" -X GET "$BASE/auth.js"
assert_code "GET /missions.js" 200 "$TMP/missions.js" -X GET "$BASE/missions.js" assert_code "GET /missions.js" 200 "$TMP/missions.js" -X GET "$BASE/missions.js"
assert_code "GET /topbar.js" 200 "$TMP/topbar.js" -X GET "$BASE/topbar.js"
assert_code "GET /api/robot/status" 200 "$TMP/robot_status.json" -X GET "$BASE/api/robot/status"
assert_json_true "robot status motion" "$TMP/robot_status.json" 'doc.get("motion") in ("paused", "running")'
assert_json_true "robot status battery" "$TMP/robot_status.json" 'doc.get("battery_percent", 0) >= 0'
assert_code "POST /api/robot/start" 200 "$TMP/robot_start.json" \
-X POST "$BASE/api/robot/start" -H 'Content-Type: application/json' -d '{}'
assert_json_true "robot started" "$TMP/robot_start.json" 'doc.get("motion") == "running"'
assert_code "POST /api/robot/pause" 200 "$TMP/robot_pause.json" \
-X POST "$BASE/api/robot/pause" -H 'Content-Type: application/json' -d '{}'
assert_json_true "robot paused" "$TMP/robot_pause.json" 'doc.get("motion") == "paused"'
assert_code "POST /api/robot/errors/reset" 200 "$TMP/robot_reset.json" \
-X POST "$BASE/api/robot/errors/reset" -H 'Content-Type: application/json' -d '{}'
assert_json_true "robot health ok" "$TMP/robot_reset.json" 'doc.get("health") == "ok"'
assert_code "PUT /api/auth/profile" 200 "$TMP/profile.json" \
-X PUT "$BASE/api/auth/profile" \
-H 'Content-Type: application/json' \
-d '{"display_name":"Admin Test"}'
assert_json_true "profile display_name" "$TMP/profile.json" 'doc.get("user",{}).get("display_name") == "Admin Test"'
assert_code "GET /api/state" 200 "$TMP/state.json" -X GET "$BASE/api/state" assert_code "GET /api/state" 200 "$TMP/state.json" -X GET "$BASE/api/state"

View File

@@ -6,8 +6,14 @@
#include "mission/mission_scheduler.hpp" #include "mission/mission_scheduler.hpp"
#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 "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/site_store.hpp"
#include "storage/sound_store.hpp"
#include "storage/state_repository.hpp" #include "storage/state_repository.hpp"
#include <httplib.h> #include <httplib.h>
@@ -15,6 +21,24 @@
namespace lm { namespace lm {
namespace {
bool isProjectRootDataPath(const std::filesystem::path& parent)
{
return parent.empty() || parent == std::filesystem::path(".");
}
std::filesystem::path resolveDataPath(std::filesystem::path data_path)
{
if (!isProjectRootDataPath(data_path.parent_path()))
return data_path;
if (data_path.filename() == "state.json")
return std::filesystem::path("data") / "state.json";
return std::filesystem::path("data") / "RBS.db";
}
} // namespace
LidarManagerApp::LidarManagerApp(int port, LidarManagerApp::LidarManagerApp(int port,
std::filesystem::path www_root, std::filesystem::path www_root,
std::filesystem::path data_path) std::filesystem::path data_path)
@@ -24,13 +48,27 @@ LidarManagerApp::LidarManagerApp(int port,
int LidarManagerApp::run() int LidarManagerApp::run()
{ {
StateRepository repo(data_path_); data_path_ = resolveDataPath(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);
SiteStore site_store(database);
site_store.ensureDefaultSiteId();
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;
@@ -42,24 +80,34 @@ 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); ApiServer api(repo,
mission_queue,
mission_store,
modbus,
scheduler,
robot_runtime,
map_store,
site_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");

View File

@@ -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"
@@ -22,6 +22,17 @@ nlohmann::json defaultPermissionsAllWrite()
{ {
return {{"dashboard", "write"}, return {{"dashboard", "write"},
{"config", "write"}, {"config", "write"},
{"maps", "write"},
{"missions", "write"},
{"integrations", "write"},
{"users", "write"}};
}
nlohmann::json defaultPermissionsAdministrator()
{
return {{"dashboard", "write"},
{"config", "none"},
{"maps", "write"},
{"missions", "write"}, {"missions", "write"},
{"integrations", "write"}, {"integrations", "write"},
{"users", "write"}}; {"users", "write"}};
@@ -30,7 +41,8 @@ nlohmann::json defaultPermissionsAllWrite()
nlohmann::json defaultPermissionsUserGroup() nlohmann::json defaultPermissionsUserGroup()
{ {
return {{"dashboard", "write"}, return {{"dashboard", "write"},
{"config", "read"}, {"config", "none"},
{"maps", "none"},
{"missions", "read"}, {"missions", "read"},
{"integrations", "read"}, {"integrations", "read"},
{"users", "none"}}; {"users", "none"}};
@@ -56,7 +68,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 +77,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;
@@ -89,7 +92,7 @@ void AuthService::loadOrSeed()
{{"id", "group_administrators"}, {{"id", "group_administrators"},
{"name", "Administrators"}, {"name", "Administrators"},
{"allow_pin", false}, {"allow_pin", false},
{"permissions", defaultPermissionsAllWrite()}}, {"permissions", defaultPermissionsAdministrator()}},
{{"id", "group_users"}, {{"id", "group_users"},
{"name", "Users"}, {"name", "Users"},
{"allow_pin", true}, {"allow_pin", true},
@@ -104,20 +107,60 @@ void AuthService::loadOrSeed()
makeUser("user_operator", "User", "user", "group_users", "Operator"), makeUser("user_operator", "User", "user", "group_users", "Operator"),
}); });
} }
if (data_.value("version", 1) < 2)
{
if (data_.contains("groups") && data_["groups"].is_array())
{
for (auto& g : data_["groups"])
{
if (!g.contains("permissions") || !g["permissions"].is_object())
g["permissions"] = nlohmann::json::object();
const std::string id = g.value("id", "");
if (id == "group_distributors")
g["permissions"]["config"] = "write";
else
g["permissions"]["config"] = "none";
}
}
data_["version"] = 2;
}
if (data_.value("version", 1) < 3)
{
if (data_.contains("groups") && data_["groups"].is_array())
{
for (auto& g : data_["groups"])
{
if (!g.contains("permissions") || !g["permissions"].is_object())
g["permissions"] = nlohmann::json::object();
const std::string id = g.value("id", "");
if (id == "group_distributors" || id == "group_administrators")
g["permissions"]["maps"] = "write";
else
g["permissions"]["maps"] = "none";
}
}
data_["version"] = 3;
}
saveUnlocked(); saveUnlocked();
} }
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 bool AuthService::canDeleteMap(const nlohmann::json& map, const AuthSession& session)
{ {
return tls_session_; if (map.contains("created_by_group") && map["created_by_group"].is_string())
{
const std::string map_group = map["created_by_group"].get<std::string>();
if (!map_group.empty())
return map_group == session.group_id;
}
return true;
} }
std::string AuthService::extractToken(const httplib::Request& req) const std::string AuthService::extractToken(const httplib::Request& req) const
@@ -148,7 +191,8 @@ bool AuthService::isPublicApiPath(const std::string& path, const std::string& me
{ {
if (method == "OPTIONS") if (method == "OPTIONS")
return true; return true;
return path == "/api/health" || path == "/api/auth/login" || path == "/api/auth/logout"; return path == "/api/health" || path == "/api/auth/login" || path == "/api/auth/logout" ||
path == "/api/auth/me";
} }
std::optional<std::string> AuthService::resourceForApiPath(const std::string& path) std::optional<std::string> AuthService::resourceForApiPath(const std::string& path)
@@ -157,13 +201,24 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
return std::nullopt; return std::nullopt;
if (path.rfind("/api/users", 0) == 0 || path.rfind("/api/user_groups", 0) == 0) if (path.rfind("/api/users", 0) == 0 || path.rfind("/api/user_groups", 0) == 0)
return "users"; return "users";
if (path.rfind("/api/missions", 0) == 0 || path.rfind("/api/mission_queue", 0) == 0) if (path.rfind("/api/missions", 0) == 0 || path.rfind("/api/mission_queue", 0) == 0 ||
path.rfind("/api/robot", 0) == 0)
return "missions"; return "missions";
if (path.rfind("/api/triggers", 0) == 0 || path.rfind("/api/schedules", 0) == 0 || if (path.rfind("/api/triggers", 0) == 0 || path.rfind("/api/schedules", 0) == 0 ||
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 == "/api/robot/active_map")
return "maps";
if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/sites", 0) == 0 ||
path.rfind("/api/recordings", 0) == 0)
return "maps";
if (path.rfind("/api/layout", 0) == 0 || path.rfind("/api/lidars", 0) == 0 ||
path.rfind("/api/imus", 0) == 0)
return "config"; return "config";
return std::nullopt; return std::nullopt;
} }
@@ -410,6 +465,47 @@ bool AuthService::changePassword(const std::string& token,
return false; return false;
} }
std::optional<nlohmann::json> AuthService::changeProfile(const std::string& token,
const nlohmann::json& payload,
std::string& err)
{
if (!payload.is_object())
{
err = "invalid payload";
return std::nullopt;
}
std::lock_guard<std::mutex> lock(mu_);
const auto it = sessions_.find(token);
if (it == sessions_.end())
{
err = "not authenticated";
return std::nullopt;
}
for (auto& user : data_["users"])
{
if (user.value("id", "") != it->second.user_id)
continue;
if (payload.contains("display_name") && payload["display_name"].is_string())
{
const std::string name = StringUtil::trimCopy(payload["display_name"].get<std::string>());
if (name.empty())
{
err = "display_name cannot be empty";
return std::nullopt;
}
user["display_name"] = name;
}
saveUnlocked();
const auto* group = findGroupByIdUnlocked(user.value("group_id", ""));
return userPublicView(user, group ? *group : nlohmann::json::object());
}
err = "user not found";
return std::nullopt;
}
nlohmann::json AuthService::listGroups() const nlohmann::json AuthService::listGroups() const
{ {
std::lock_guard<std::mutex> lock(mu_); std::lock_guard<std::mutex> lock(mu_);
@@ -595,6 +691,10 @@ bool AuthService::authorizeApiRequest(const httplib::Request& req, httplib::Resp
return true; return true;
const bool write = requiresWrite(req.method); const bool write = requiresWrite(req.method);
if (!write && req.path.rfind("/api/maps", 0) == 0 &&
permissionAllows(it->second.permissions, "dashboard", false))
return true;
if (!permissionAllows(it->second.permissions, *resource, write)) if (!permissionAllows(it->second.permissions, *resource, write))
{ {
HttpUtil::jsonError(res, 403, "insufficient permissions"); HttpUtil::jsonError(res, 403, "insufficient permissions");
@@ -666,16 +766,16 @@ void AuthService::registerRoutes(httplib::Server& svr)
}); });
svr.Get("/api/auth/me", [this](const httplib::Request& req, httplib::Response& res) { svr.Get("/api/auth/me", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
const std::string token = extractToken(req); const std::string token = extractToken(req);
const auto info = sessionInfo(token); const auto info = sessionInfo(token);
if (!info) if (!info)
{ {
HttpUtil::jsonError(res, 401, "not authenticated"); res.set_content(R"({"user":null})", "application/json; charset=utf-8");
return; return;
} }
nlohmann::json out = {{"user", *info}}; res.set_content(nlohmann::json({{"user", *info}}).dump(), "application/json; charset=utf-8");
res.set_content(out.dump(), "application/json; charset=utf-8");
HttpUtil::addCors(res);
}); });
svr.Put("/api/auth/password", [this](const httplib::Request& req, httplib::Response& res) { svr.Put("/api/auth/password", [this](const httplib::Request& req, httplib::Response& res) {
@@ -704,6 +804,31 @@ void AuthService::registerRoutes(httplib::Server& svr)
HttpUtil::addCors(res); HttpUtil::addCors(res);
}); });
svr.Put("/api/auth/profile", [this](const httplib::Request& req, httplib::Response& res) {
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
HttpUtil::jsonError(res, 400, "invalid json");
return;
}
const std::string token = extractToken(req);
std::string err;
const auto profile = changeProfile(token, body, err);
if (!profile)
{
HttpUtil::jsonError(res, 400, err);
return;
}
nlohmann::json out = {{"user", *profile}};
res.set_content(out.dump(), "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
svr.Get("/api/user_groups", [this](const httplib::Request&, httplib::Response& res) { svr.Get("/api/user_groups", [this](const httplib::Request&, httplib::Response& res) {
nlohmann::json out = {{"groups", listGroups()}}; nlohmann::json out = {{"groups", listGroups()}};
res.set_content(out.dump(), "application/json; charset=utf-8"); res.set_content(out.dump(), "application/json; charset=utf-8");

View File

@@ -11,6 +11,8 @@
namespace lm { namespace lm {
class Database;
struct AuthSession struct AuthSession
{ {
std::string token; std::string token;
@@ -24,11 +26,12 @@ 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);
const AuthSession* currentSession() const; static const AuthSession* activeSession() { return tls_session_; }
static bool canDeleteMap(const nlohmann::json& map, const AuthSession& session);
std::optional<nlohmann::json> loginPassword(const std::string& username, std::optional<nlohmann::json> loginPassword(const std::string& username,
const std::string& password, const std::string& password,
@@ -40,6 +43,9 @@ public:
const std::string& current_password, const std::string& current_password,
const std::string& new_password, const std::string& new_password,
std::string& err); std::string& err);
std::optional<nlohmann::json> changeProfile(const std::string& token,
const nlohmann::json& payload,
std::string& err);
nlohmann::json listGroups() const; nlohmann::json listGroups() const;
nlohmann::json listUsers() const; nlohmann::json listUsers() const;
@@ -52,7 +58,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_;

View File

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

View File

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

View File

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

View File

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

View File

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

283
src/robot/robot_runtime.cpp Normal file
View File

@@ -0,0 +1,283 @@
#include "robot/robot_runtime.hpp"
#include "mission/mission_queue.hpp"
#include "storage/database.hpp"
#include "util/id_util.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
namespace lm {
namespace {
constexpr const char* kDefaultMessage = "Waiting for new missions...";
} // namespace
RobotRuntime::RobotRuntime(Database& db, MissionQueue& mission_queue)
: db_(db), mission_queue_(mission_queue)
{
load();
ensureDefaultsUnlocked();
}
void RobotRuntime::load()
{
std::lock_guard<std::mutex> lock(mu_);
state_ = nlohmann::json::object();
nlohmann::json parsed;
if (db_.getDocument("robot_runtime", parsed) && parsed.is_object())
state_ = parsed;
}
void RobotRuntime::saveUnlocked() const
{
db_.setDocument("robot_runtime", state_);
}
void RobotRuntime::ensureDefaultsUnlocked()
{
if (!state_.is_object())
state_ = nlohmann::json::object();
if (!state_.contains("motion"))
state_["motion"] = "paused";
if (!state_.contains("health"))
state_["health"] = "ok";
if (!state_.contains("message"))
state_["message"] = kDefaultMessage;
if (!state_.contains("error"))
state_["error"] = nullptr;
if (!state_.contains("battery_percent"))
state_["battery_percent"] = 54;
if (!state_.contains("battery_charging"))
state_["battery_charging"] = false;
if (!state_.contains("joystick_engaged"))
state_["joystick_engaged"] = false;
if (!state_.contains("joystick_speed"))
state_["joystick_speed"] = "fast";
if (!state_.contains("cmd_linear"))
state_["cmd_linear"] = 0.0;
if (!state_.contains("cmd_angular"))
state_["cmd_angular"] = 0.0;
if (!state_.contains("updated_at"))
state_["updated_at"] = IdUtil::nowIso8601();
if (!state_.contains("active_map_id"))
state_["active_map_id"] = nullptr;
if (!state_.contains("pose") || !state_["pose"].is_object())
state_["pose"] = {{"x", 0.0}, {"y", 0.0}, {"yaw", 0.0}};
saveUnlocked();
}
nlohmann::json RobotRuntime::buildStatusUnlocked() const
{
const auto runner = mission_queue_.runnerStatus();
const auto queue = mission_queue_.list();
int pending = 0;
if (queue.is_array())
{
for (const auto& item : queue)
{
if (item.value("status", "") == "pending")
++pending;
}
}
const std::string motion = state_.value("motion", "paused");
const std::string health = state_.value("health", "ok");
const std::string runner_state = runner.value("state", "idle");
std::string message = state_.value("message", kDefaultMessage);
if (health == "ok")
{
if (!runner.value("message", "").empty())
message = runner.value("message", "");
else if (runner_state == "idle" && motion == "paused" && pending == 0)
message = kDefaultMessage;
}
nlohmann::json body = {{"motion", motion},
{"health", health},
{"message", message},
{"error", state_.contains("error") ? state_["error"] : nullptr},
{"battery_percent", state_.value("battery_percent", 54)},
{"battery_charging", state_.value("battery_charging", false)},
{"joystick_engaged", state_.value("joystick_engaged", false)},
{"joystick_speed", state_.value("joystick_speed", "fast")},
{"cmd_linear", state_.value("cmd_linear", 0.0)},
{"cmd_angular", state_.value("cmd_angular", 0.0)},
{"active_map_id", state_.contains("active_map_id") ? state_["active_map_id"] : nullptr},
{"pose", state_.contains("pose") ? state_["pose"] : nlohmann::json::object({{"x", 0.0}, {"y", 0.0}, {"yaw", 0.0}})},
{"runner", runner},
{"queue_pending", pending},
{"updated_at", state_.value("updated_at", "")}};
return body;
}
nlohmann::json RobotRuntime::status() const
{
std::lock_guard<std::mutex> lock(mu_);
return buildStatusUnlocked();
}
bool RobotRuntime::start(std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (state_.value("health", "ok") == "error")
{
err = "cannot start while robot is in error state — reset error first";
return false;
}
state_["motion"] = "running";
state_["message"] = "Robot running";
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
if (runner_state == "paused")
return mission_queue_.resume(err);
return true;
}
bool RobotRuntime::pause(std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
state_["motion"] = "paused";
state_["message"] = "Robot paused";
state_["joystick_engaged"] = false;
state_["cmd_linear"] = 0.0;
state_["cmd_angular"] = 0.0;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
if (runner_state == "running" || runner_state == "paused")
return mission_queue_.pause(err);
return true;
}
bool RobotRuntime::resetError(std::string& err)
{
(void)err;
std::lock_guard<std::mutex> lock(mu_);
state_["health"] = "ok";
state_["error"] = nullptr;
state_["message"] = kDefaultMessage;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
bool RobotRuntime::setCmdVel(double linear, double angular, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (state_.value("health", "ok") == "error")
{
err = "robot is in error state";
return false;
}
if (!state_.value("joystick_engaged", false))
{
err = "joystick is not engaged";
return false;
}
if (state_.value("motion", "paused") != "running")
{
err = "robot motion is paused — press START first";
return false;
}
const std::string speed = state_.value("joystick_speed", "fast");
double scale = 1.0;
if (speed == "medium")
scale = 0.55;
else if (speed == "slow")
scale = 0.25;
linear = std::clamp(linear, -1.0, 1.0) * scale;
angular = std::clamp(angular, -1.0, 1.0) * scale;
state_["cmd_linear"] = linear;
state_["cmd_angular"] = angular;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
bool RobotRuntime::setJoystick(bool engaged, const std::string& speed, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (engaged && state_.value("health", "ok") == "error")
{
err = "cannot engage joystick while robot is in error state";
return false;
}
if (engaged && state_.value("motion", "paused") != "running")
{
err = "start the robot before engaging the joystick";
return false;
}
state_["joystick_engaged"] = engaged;
if (!speed.empty())
{
if (speed != "fast" && speed != "medium" && speed != "slow")
{
err = "speed must be fast, medium, or slow";
return false;
}
state_["joystick_speed"] = speed;
}
if (!engaged)
{
state_["cmd_linear"] = 0.0;
state_["cmd_angular"] = 0.0;
}
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
bool RobotRuntime::setActiveMap(const std::string& map_id, std::string& err)
{
(void)err;
std::lock_guard<std::mutex> lock(mu_);
state_["active_map_id"] = map_id;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
void RobotRuntime::clearActiveMapIf(const std::string& map_id)
{
std::lock_guard<std::mutex> lock(mu_);
if (state_.value("active_map_id", "") == map_id)
{
state_["active_map_id"] = nullptr;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
}
}
void RobotRuntime::tick()
{
std::lock_guard<std::mutex> lock(mu_);
const bool running = state_.value("motion", "paused") == "running";
const bool joy = state_.value("joystick_engaged", false);
double battery = state_.value("battery_percent", 54.0);
if (running && (joy || std::abs(state_.value("cmd_linear", 0.0)) > 0.01))
battery = std::max(0.0, battery - 0.02);
else if (state_.value("battery_charging", false))
battery = std::min(100.0, battery + 0.05);
else if (!running)
battery = std::min(100.0, battery + 0.005);
state_["battery_percent"] = static_cast<int>(std::lround(battery));
saveUnlocked();
}
} // namespace lm

View File

@@ -0,0 +1,40 @@
#pragma once
#include <nlohmann/json.hpp>
#include <mutex>
#include <string>
namespace lm {
class Database;
class MissionQueue;
class RobotRuntime
{
public:
explicit RobotRuntime(Database& db, MissionQueue& mission_queue);
nlohmann::json status() const;
bool start(std::string& err);
bool pause(std::string& err);
bool resetError(std::string& err);
bool setCmdVel(double linear, double angular, std::string& err);
bool setJoystick(bool engaged, const std::string& speed, std::string& err);
bool setActiveMap(const std::string& map_id, std::string& err);
void clearActiveMapIf(const std::string& map_id);
void tick();
private:
Database& db_;
MissionQueue& mission_queue_;
mutable std::mutex mu_;
nlohmann::json state_;
void load();
void saveUnlocked() const;
void ensureDefaultsUnlocked();
nlohmann::json buildStatusUnlocked() const;
};
} // namespace lm

View File

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

View File

@@ -0,0 +1,333 @@
#include "server/api_server.hpp"
#include "auth/auth_service.hpp"
#include "util/file_util.hpp"
#include "util/http_util.hpp"
namespace lm {
void ApiServer::registerMediaRoutes(httplib::Server& svr)
{
svr.Get("/api/sites", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"sites", site_store_.list()}}).dump();
});
svr.Post("/api/sites", [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 = site_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/sites/([^/]+)$)", [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 (!site_store_.update(id, body, err))
return HttpUtil::jsonError(res, 404, err);
const auto updated = site_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/sites/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
std::string err;
if (!site_store_.remove(id, err))
return HttpUtil::jsonError(res, 400, err);
res.status = 204;
});
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");
}
if (const AuthSession* session = AuthService::activeSession())
{
body["created_by_user"] = session->user_id;
body["created_by_group"] = session->group_id;
}
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];
const auto map = map_store_.find(id);
if (!map)
return HttpUtil::jsonError(res, 404, "map not found");
if (const char* disabled = std::getenv("LM_AUTH_DISABLED"); !disabled || std::string(disabled) != "1")
{
const AuthSession* session = AuthService::activeSession();
if (!session)
return HttpUtil::jsonError(res, 401, "authentication required");
if (!AuthService::canDeleteMap(*map, *session))
return HttpUtil::jsonError(res, 403, "cannot delete map from another user group");
}
std::string err;
if (!map_store_.remove(id, err))
return HttpUtil::jsonError(res, 404, err);
robot_runtime_.clearActiveMapIf(id);
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.Get(R"(/api/maps/([^/]+)/image/base$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
const auto path = map_store_.baseImagePath(id);
if (!path)
return HttpUtil::jsonError(res, 404, "map base image not found");
res.set_header("Content-Type", HttpUtil::contentTypeForPath(*path));
res.body = FileUtil::readBinary(*path);
});
svr.Post(R"(/api/maps/([^/]+)/image/composite$)", [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");
std::string err;
if (!map_store_.saveCompositeImageFile(id, 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.Post(R"(/api/maps/([^/]+)/image/base$)", [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");
std::string err;
if (!map_store_.saveBaseImageFile(id, 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.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(R"(/api/maps/([^/]+)/yaml$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
const auto path = map_store_.yamlPath(id);
if (!path)
return HttpUtil::jsonError(res, 404, "map yaml not found");
res.set_header("Content-Type", "text/yaml; charset=utf-8");
res.body = FileUtil::readBinary(*path);
});
svr.Post(R"(/api/maps/([^/]+)/yaml$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
if (req.body.empty())
return HttpUtil::jsonError(res, 400, "yaml body is required");
std::string err;
if (!map_store_.saveYamlFile(id, req.body, err))
return HttpUtil::jsonError(res, 400, err);
const auto updated = map_store_.find(id);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
});
svr.Get("/api/sounds", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"sounds", sound_store_.list()}}).dump();
});
svr.Get(R"(/api/sounds/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
const auto sound = sound_store_.find(id);
if (!sound)
return HttpUtil::jsonError(res, 404, "sound not found");
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = sound->dump();
});
svr.Post("/api/sounds", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
const auto created = sound_store_.create(body, err);
if (!created)
return HttpUtil::jsonError(res, 400, err);
res.status = 201;
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = created->dump();
});
svr.Put(R"(/api/sounds/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
if (!sound_store_.update(id, body, err))
return HttpUtil::jsonError(res, 404, err);
const auto updated = sound_store_.find(id);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
});
svr.Delete(R"(/api/sounds/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
std::string err;
if (!sound_store_.remove(id, err))
return HttpUtil::jsonError(res, 404, err);
res.status = 204;
});
svr.Get(R"(/api/sounds/([^/]+)/file$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
const auto path = sound_store_.filePath(id);
if (!path)
return HttpUtil::jsonError(res, 404, "sound file not found");
res.set_header("Content-Type", HttpUtil::contentTypeForPath(*path));
res.body = FileUtil::readBinary(*path);
});
svr.Post(R"(/api/sounds/([^/]+)/file$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
if (!req.form.has_file("file"))
return HttpUtil::jsonError(res, 400, "file is required");
const auto& file = req.form.get_file("file");
const std::string filename = file.filename.empty() ? (id + ".wav") : file.filename;
std::string err;
if (!sound_store_.saveFile(id, filename, file.content, err))
return HttpUtil::jsonError(res, 400, err);
const auto updated = sound_store_.find(id);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
});
svr.Get("/api/recordings", [](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"recordings", nlohmann::json::array()}}).dump();
});
}
} // namespace lm

View File

@@ -0,0 +1,108 @@
#include "server/api_server.hpp"
#include "util/http_util.hpp"
namespace lm {
void ApiServer::registerRobotRoutes(httplib::Server& svr)
{
svr.Get("/api/robot/status", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
robot_runtime_.tick();
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/start", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!robot_runtime_.start(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/pause", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!robot_runtime_.pause(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/errors/reset", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!robot_runtime_.resetError(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/cmd_vel", [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");
}
const double linear = body.value("linear", body.value("vx", 0.0));
const double angular = body.value("angular", body.value("omega", 0.0));
std::string err;
if (!robot_runtime_.setCmdVel(linear, angular, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/joystick", [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");
}
if (!body.contains("engaged") || !body["engaged"].is_boolean())
return HttpUtil::jsonError(res, 400, "engaged (boolean) is required");
const std::string speed = body.value("speed", "");
std::string err;
if (!robot_runtime_.setJoystick(body["engaged"].get<bool>(), speed, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/active_map", [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");
}
const std::string map_id = body.value("map_id", "");
if (map_id.empty())
return HttpUtil::jsonError(res, 400, "map_id is required");
if (!map_store_.find(map_id))
return HttpUtil::jsonError(res, 404, "map not found");
std::string err;
if (!robot_runtime_.setActiveMap(map_id, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
}
} // namespace lm

View File

@@ -14,12 +14,22 @@ ApiServer::ApiServer(StateRepository& repo,
MissionQueue& mission_queue, MissionQueue& mission_queue,
MissionStore& mission_store, MissionStore& mission_store,
ModbusTriggerService& modbus, ModbusTriggerService& modbus,
MissionScheduler& scheduler) MissionScheduler& scheduler,
RobotRuntime& robot_runtime,
MapStore& map_store,
SiteStore& site_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),
map_store_(map_store),
site_store_(site_store),
sound_store_(sound_store),
dashboard_store_(dashboard_store)
{ {
} }
@@ -538,6 +548,9 @@ void ApiServer::registerRoutes(httplib::Server& svr)
registerMissionRoutes(svr); registerMissionRoutes(svr);
registerIntegrationRoutes(svr); registerIntegrationRoutes(svr);
registerMirV2Routes(svr); registerMirV2Routes(svr);
registerRobotRoutes(svr);
registerMediaRoutes(svr);
registerDashboardRoutes(svr);
} }
} // namespace lm } // namespace lm

View File

@@ -6,6 +6,11 @@
#include "mission/mission_scheduler.hpp" #include "mission/mission_scheduler.hpp"
#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 "storage/dashboard_store.hpp"
#include "storage/map_store.hpp"
#include "storage/site_store.hpp"
#include "storage/sound_store.hpp"
#include "storage/state_repository.hpp" #include "storage/state_repository.hpp"
namespace lm { namespace lm {
@@ -17,7 +22,12 @@ public:
MissionQueue& mission_queue, MissionQueue& mission_queue,
MissionStore& mission_store, MissionStore& mission_store,
ModbusTriggerService& modbus, ModbusTriggerService& modbus,
MissionScheduler& scheduler); MissionScheduler& scheduler,
RobotRuntime& robot_runtime,
MapStore& map_store,
SiteStore& site_store,
SoundStore& sound_store,
DashboardStore& dashboard_store);
void registerRoutes(httplib::Server& svr); void registerRoutes(httplib::Server& svr);
@@ -27,6 +37,11 @@ private:
MissionStore& mission_store_; MissionStore& mission_store_;
ModbusTriggerService& modbus_; ModbusTriggerService& modbus_;
MissionScheduler& scheduler_; MissionScheduler& scheduler_;
RobotRuntime& robot_runtime_;
MapStore& map_store_;
SiteStore& site_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);
@@ -34,6 +49,9 @@ private:
void registerMissionRoutes(httplib::Server& svr); void registerMissionRoutes(httplib::Server& svr);
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 registerMediaRoutes(httplib::Server& svr);
void registerDashboardRoutes(httplib::Server& svr);
}; };
} // namespace lm } // namespace lm

View File

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

View File

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

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

@@ -0,0 +1,566 @@
#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 sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at 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 '',
site_id TEXT,
created_by TEXT NOT NULL DEFAULT '',
created_by_user TEXT,
created_by_group TEXT,
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,
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE SET 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::migrateLegacyMapsDir(std::string& err)
{
if (data_dir_.filename() != "data")
return true;
const std::filesystem::path legacy = "maps";
const auto target = mapsDir();
if (!std::filesystem::is_directory(legacy) || legacy == target)
return true;
std::error_code ec;
std::filesystem::create_directories(target, ec);
if (ec)
{
err = "failed to create maps directory: " + target.string();
return false;
}
for (const auto& entry : std::filesystem::directory_iterator(legacy, ec))
{
if (ec || !entry.is_directory())
continue;
const auto dest = target / entry.path().filename();
if (std::filesystem::exists(dest))
continue;
std::filesystem::rename(entry.path(), dest, ec);
if (ec)
{
ec.clear();
std::filesystem::copy(entry.path(), dest, std::filesystem::copy_options::recursive, ec);
if (ec)
{
err = "failed to migrate map directory: " + entry.path().string();
return false;
}
std::filesystem::remove_all(entry.path(), ec);
ec.clear();
}
}
return true;
}
bool Database::applySchema(std::string& err)
{
return execSql(db_, kSchemaSql, err);
}
namespace {
bool tableHasColumn(sqlite3* db, const char* table, const char* column)
{
std::string sql = "PRAGMA table_info(";
sql += table;
sql += ")";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
return false;
bool found = false;
while (sqlite3_step(stmt) == SQLITE_ROW)
{
const char* name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
if (name && column == std::string(name))
{
found = true;
break;
}
}
sqlite3_finalize(stmt);
return found;
}
} // namespace
bool Database::applySchemaMigrations(std::string& err)
{
std::string ver = getMeta("schema_version").value_or("1");
if (ver == "1")
{
if (!execSql(db_,
"CREATE TABLE IF NOT EXISTS sites ("
"id TEXT PRIMARY KEY, name TEXT NOT NULL, "
"created_at TEXT NOT NULL, updated_at TEXT NOT NULL)",
err))
return false;
if (!tableHasColumn(db_, "maps", "site_id"))
{
if (!execSql(db_, "ALTER TABLE maps ADD COLUMN site_id TEXT", err))
return false;
}
if (!tableHasColumn(db_, "maps", "created_by"))
{
if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", err))
return false;
}
const std::string now = IdUtil::nowIso8601();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT OR IGNORE INTO sites(id, name, created_at, updated_at) "
"VALUES('site_configuration', 'ConfigurationSite', ?1, ?1)",
-1,
&stmt,
nullptr) == SQLITE_OK)
{
sqlite3_bind_text(stmt, 1, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
if (sqlite3_prepare_v2(db_,
"UPDATE maps SET site_id = 'site_configuration' WHERE site_id IS NULL OR site_id = ''",
-1,
&stmt,
nullptr) == SQLITE_OK)
{
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
setMeta("schema_version", "2");
}
ver = getMeta("schema_version").value_or("1");
if (ver == "2")
{
if (!tableHasColumn(db_, "maps", "created_by_user"))
{
if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by_user TEXT", err))
return false;
}
if (!tableHasColumn(db_, "maps", "created_by_group"))
{
if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by_group TEXT", err))
return false;
}
setMeta("schema_version", "3");
}
return true;
}
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 (!applySchemaMigrations(err))
return false;
if (!ensureDataDirs(err))
return false;
if (!migrateLegacyMapsDir(err))
return false;
if (!migrateFromJsonIfNeeded(err))
return false;
if (!getMeta("schema_version"))
setMeta("schema_version", "1");
return true;
}
} // namespace lm

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

@@ -0,0 +1,53 @@
#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 applySchemaMigrations(std::string& err);
bool migrateFromJsonIfNeeded(std::string& err);
bool ensureDataDirs(std::string& err);
bool migrateLegacyMapsDir(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

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

@@ -0,0 +1,534 @@
#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 {
constexpr const char* kBaseImageName = "map_base.png";
constexpr const char* kMapSelect =
"SELECT id, name, description, site_id, created_by, created_by_user, created_by_group, "
"width, height, resolution, origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
"created_at, updated_at FROM maps";
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, 15) != SQLITE_NULL)
{
try
{
zones = nlohmann::json::parse(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 15)));
}
catch (...)
{
zones = nlohmann::json::array();
}
}
return {{"id", textOrNull(0)},
{"name", textOrNull(1)},
{"description", textOrNull(2)},
{"site_id", textOrNull(3)},
{"created_by", textOrNull(4)},
{"created_by_user", textOrNull(5)},
{"created_by_group", textOrNull(6)},
{"width", realOrNull(7)},
{"height", realOrNull(8)},
{"resolution", realOrNull(9)},
{"origin_x", realOrNull(10)},
{"origin_y", realOrNull(11)},
{"origin_yaw", realOrNull(12)},
{"image_file", textOrNull(13)},
{"yaml_file", textOrNull(14)},
{"zones", zones},
{"created_at", textOrNull(16)},
{"updated_at", textOrNull(17)}};
}
} // 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();
std::string sql = std::string(kMapSelect) + " ORDER BY name";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -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_);
std::string sql = std::string(kMapSelect) + " WHERE id = ?1";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -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 std::string site_id = payload.value("site_id", "");
const std::string created_by = payload.value("created_by", "");
const std::string created_by_user = payload.value("created_by_user", "");
const std::string created_by_group = payload.value("created_by_group", "");
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, site_id, created_by, created_by_user, "
"created_by_group, 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,?15,?16,?17,?18)",
-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 (site_id.empty())
sqlite3_bind_null(stmt, 4);
else
sqlite3_bind_text(stmt, 4, site_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 5, created_by.c_str(), -1, SQLITE_TRANSIENT);
if (created_by_user.empty())
sqlite3_bind_null(stmt, 6);
else
sqlite3_bind_text(stmt, 6, created_by_user.c_str(), -1, SQLITE_TRANSIENT);
if (created_by_group.empty())
sqlite3_bind_null(stmt, 7);
else
sqlite3_bind_text(stmt, 7, created_by_group.c_str(), -1, SQLITE_TRANSIENT);
if (payload.contains("width") && payload["width"].is_number())
sqlite3_bind_double(stmt, 8, payload["width"].get<double>());
else
sqlite3_bind_null(stmt, 8);
if (payload.contains("height") && payload["height"].is_number())
sqlite3_bind_double(stmt, 9, payload["height"].get<double>());
else
sqlite3_bind_null(stmt, 9);
if (payload.contains("resolution") && payload["resolution"].is_number())
sqlite3_bind_double(stmt, 10, payload["resolution"].get<double>());
else
sqlite3_bind_null(stmt, 10);
sqlite3_bind_double(stmt, 11, payload.value("origin_x", 0.0));
sqlite3_bind_double(stmt, 12, payload.value("origin_y", 0.0));
sqlite3_bind_double(stmt, 13, payload.value("origin_yaw", 0.0));
sqlite3_bind_null(stmt, 14);
sqlite3_bind_null(stmt, 15);
const std::string zones_str = zones.dump();
sqlite3_bind_text(stmt, 16, zones_str.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 17, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 18, 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;
created["site_id"] = site_id.empty() ? nullptr : nlohmann::json(site_id);
created["created_by"] = created_by;
if (!created_by_user.empty())
created["created_by_user"] = created_by_user;
if (!created_by_group.empty())
created["created_by_group"] = created_by_group;
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", "site_id", "width", "height", "resolution", "origin_x", "origin_y", "origin_yaw"})
{
if (payload.contains(key))
merged[key] = payload[key];
}
if (payload.contains("zones"))
{
if (!payload["zones"].is_array())
{
err = "zones must be an array";
return false;
}
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, site_id=?4, width=?5, height=?6, resolution=?7, "
"origin_x=?8, origin_y=?9, origin_yaw=?10, zones_json=?11, updated_at=?12 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);
const std::string site_id = merged.value("site_id", "");
if (site_id.empty())
sqlite3_bind_null(stmt, 4);
else
sqlite3_bind_text(stmt, 4, site_id.c_str(), -1, SQLITE_TRANSIENT);
if (merged["width"].is_number())
sqlite3_bind_double(stmt, 5, merged["width"].get<double>());
else
sqlite3_bind_null(stmt, 5);
if (merged["height"].is_number())
sqlite3_bind_double(stmt, 6, merged["height"].get<double>());
else
sqlite3_bind_null(stmt, 6);
if (merged["resolution"].is_number())
sqlite3_bind_double(stmt, 7, merged["resolution"].get<double>());
else
sqlite3_bind_null(stmt, 7);
sqlite3_bind_double(stmt, 8, merged.value("origin_x", 0.0));
sqlite3_bind_double(stmt, 9, merged.value("origin_y", 0.0));
sqlite3_bind_double(stmt, 10, merged.value("origin_yaw", 0.0));
sqlite3_bind_text(stmt, 11, zones_str.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 12, 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;
}
std::optional<std::filesystem::path> MapStore::baseImagePath(const std::string& id) const
{
const auto base = mapDir(id) / kBaseImageName;
if (std::filesystem::exists(base))
return base;
return imagePath(id);
}
std::optional<std::filesystem::path> MapStore::yamlPath(const std::string& id) const
{
const auto map = find(id);
if (!map || !(*map)["yaml_file"].is_string())
return std::nullopt;
const auto path = mapDir(id) / map->value("yaml_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 auto base_path = mapDir(id) / kBaseImageName;
if (!FileUtil::writeBinaryAtomic(base_path, bytes))
{
err = "failed to write base 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;
}
bool MapStore::saveCompositeImageFile(const std::string& id, const std::string& bytes, std::string& err)
{
const auto map = find(id);
if (!map)
{
err = "map not found";
return false;
}
const std::string filename = map->value("image_file", "map.png");
if (filename.empty())
{
err = "map has no image file";
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 composite image file";
return false;
}
const auto base_path = mapDir(id) / kBaseImageName;
if (!std::filesystem::exists(base_path))
{
if (!FileUtil::writeBinaryAtomic(base_path, bytes))
{
err = "failed to initialize base 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 updated_at = ?2 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, 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::saveBaseImageFile(const std::string& id, 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) / kBaseImageName;
if (!FileUtil::writeBinaryAtomic(path, bytes))
{
err = "failed to write base 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 updated_at = ?2 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, 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::saveYamlFile(const std::string& id, const std::string& yaml_text, std::string& err)
{
if (!find(id))
{
err = "map not found";
return false;
}
constexpr const char* kYamlName = "map.yaml";
std::error_code ec;
std::filesystem::create_directories(mapDir(id), ec);
const auto path = mapDir(id) / kYamlName;
if (!FileUtil::writeBinaryAtomic(path, yaml_text))
{
err = "failed to write yaml 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 yaml_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, kYamlName, -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

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

@@ -0,0 +1,42 @@
#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;
/** Scan/original floor plan (map_base.png); falls back to composite image if missing. */
std::optional<std::filesystem::path> baseImagePath(const std::string& id) const;
std::optional<std::filesystem::path> yamlPath(const std::string& id) const;
bool saveImageFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
/** Save flattened composite (map.png) without touching map_base.png. */
bool saveCompositeImageFile(const std::string& id, const std::string& bytes, std::string& err);
/** Save base scan layer (map_base.png) only. */
bool saveBaseImageFile(const std::string& id, const std::string& bytes, std::string& err);
bool saveYamlFile(const std::string& id, const std::string& yaml_text, std::string& err);
private:
Database& db_;
mutable std::mutex mu_;
};
} // namespace lm

242
src/storage/site_store.cpp Normal file
View File

@@ -0,0 +1,242 @@
#include "storage/site_store.hpp"
#include "storage/database.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
#include <sqlite3.h>
namespace lm {
namespace {
constexpr const char* kDefaultSiteId = "site_configuration";
constexpr const char* kDefaultSiteName = "ConfigurationSite";
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)},
{"created_at", textOrNull(2)},
{"updated_at", textOrNull(3)}};
}
} // namespace
SiteStore::SiteStore(Database& db) : db_(db) {}
std::string SiteStore::ensureDefaultSiteId()
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), "SELECT id FROM sites WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
return kDefaultSiteId;
sqlite3_bind_text(stmt, 1, kDefaultSiteId, -1, SQLITE_STATIC);
bool exists = sqlite3_step(stmt) == SQLITE_ROW;
sqlite3_finalize(stmt);
if (exists)
return kDefaultSiteId;
const std::string now = IdUtil::nowIso8601();
if (sqlite3_prepare_v2(db_.handle(),
"INSERT INTO sites(id, name, created_at, updated_at) VALUES(?1,?2,?3,?4)",
-1,
&stmt,
nullptr) != SQLITE_OK)
return kDefaultSiteId;
sqlite3_bind_text(stmt, 1, kDefaultSiteId, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, kDefaultSiteName, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 4, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
return kDefaultSiteId;
}
nlohmann::json SiteStore::list() const
{
std::lock_guard<std::mutex> lock(mu_);
nlohmann::json sites = nlohmann::json::array();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"SELECT id, name, created_at, updated_at FROM sites ORDER BY name",
-1,
&stmt,
nullptr) != SQLITE_OK)
return sites;
while (sqlite3_step(stmt) == SQLITE_ROW)
sites.push_back(rowToJson(stmt));
sqlite3_finalize(stmt);
return sites;
}
std::optional<nlohmann::json> SiteStore::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, created_at, updated_at FROM sites 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> SiteStore::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();
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"INSERT INTO sites(id, name, created_at, updated_at) VALUES(?1,?2,?3,?4)",
-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, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 4, 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}, {"created_at", now}, {"updated_at", now}};
}
bool SiteStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
{
auto existing = find(id);
if (!existing)
{
err = "site not found";
return false;
}
nlohmann::json merged = *existing;
if (payload.contains("name"))
merged["name"] = payload["name"];
const std::string now = IdUtil::nowIso8601();
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"UPDATE sites SET 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, merged.value("name", "").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;
}
bool SiteStore::remove(const std::string& id, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* check = nullptr;
if (sqlite3_prepare_v2(db_.handle(), "SELECT id FROM sites WHERE id = ?1", -1, &check, nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return false;
}
sqlite3_bind_text(check, 1, id.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(check) != SQLITE_ROW)
{
sqlite3_finalize(check);
err = "site not found";
return false;
}
sqlite3_finalize(check);
if (sqlite3_prepare_v2(db_.handle(),
"SELECT COUNT(*) FROM maps WHERE site_id = ?1",
-1,
&check,
nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return false;
}
sqlite3_bind_text(check, 1, id.c_str(), -1, SQLITE_TRANSIENT);
int map_count = 0;
if (sqlite3_step(check) == SQLITE_ROW)
map_count = sqlite3_column_int(check, 0);
sqlite3_finalize(check);
if (map_count > 0)
{
err = "site has maps and cannot be deleted";
return false;
}
if (sqlite3_prepare_v2(db_.handle(), "SELECT COUNT(*) FROM sites", -1, &check, nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return false;
}
int site_count = 0;
if (sqlite3_step(check) == SQLITE_ROW)
site_count = sqlite3_column_int(check, 0);
sqlite3_finalize(check);
if (site_count <= 1)
{
err = "cannot delete the last site";
return false;
}
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM sites 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;
if (!ok)
err = sqlite3_errmsg(db_.handle());
sqlite3_finalize(stmt);
return ok;
}
} // namespace lm

View File

@@ -0,0 +1,30 @@
#pragma once
#include <nlohmann/json.hpp>
#include <mutex>
#include <optional>
#include <string>
namespace lm {
class Database;
class SiteStore
{
public:
explicit SiteStore(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::string ensureDefaultSiteId();
private:
Database& db_;
mutable std::mutex mu_;
};
} // namespace lm

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

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

View File

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

View File

@@ -2,6 +2,7 @@
#include "domain/layout_profile.hpp" #include "domain/layout_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 (...)
{ {

View File

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

1724
src/third_party/stb_image_write.h vendored Normal file

File diff suppressed because it is too large Load Diff

212
src/util/map_image_util.cpp Normal file
View File

@@ -0,0 +1,212 @@
#include "util/map_image_util.hpp"
#include "util/string_util.hpp"
#include <algorithm>
#include <cctype>
#include <sstream>
#define STB_IMAGE_WRITE_IMPLEMENTATION
#define STB_IMAGE_WRITE_STATIC
#include "third_party/stb_image_write.h"
namespace lm {
namespace {
std::string lowerExt(const std::string& filename)
{
const auto dot = filename.rfind('.');
if (dot == std::string::npos)
return "";
std::string ext = filename.substr(dot);
return StringUtil::toLower(ext);
}
bool isPngExtension(const std::string& ext)
{
return ext == ".png";
}
bool isPgmExtension(const std::string& ext)
{
return ext == ".pgm";
}
bool pngMagic(const std::string& bytes)
{
static const unsigned char kSig[] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
if (bytes.size() < 8)
return false;
return std::equal(std::begin(kSig), std::end(kSig), bytes.begin());
}
bool pgmMagic(const std::string& bytes)
{
return bytes.size() >= 2 && bytes[0] == 'P' && (bytes[1] == '5' || bytes[1] == '2');
}
void applyNegate(std::vector<uint8_t>& gray)
{
for (auto& px : gray)
px = static_cast<uint8_t>(255 - px);
}
} // namespace
bool MapImageUtil::decodePgm(const std::string& bytes, std::vector<uint8_t>& gray, ImageSize& size, std::string& err)
{
if (bytes.size() < 4 || bytes[0] != 'P')
{
err = "not a PGM image";
return false;
}
if (bytes[1] != '5')
{
err = "only binary PGM (P5) is supported";
return false;
}
std::size_t i = 2;
auto skipSpace = [&]() {
while (i < bytes.size() && (bytes[i] == ' ' || bytes[i] == '\t' || bytes[i] == '\r' || bytes[i] == '\n'))
++i;
};
auto readInt = [&](int& out) -> bool {
for (;;)
{
skipSpace();
if (i < bytes.size() && bytes[i] == '#')
{
while (i < bytes.size() && bytes[i] != '\n')
++i;
continue;
}
if (i >= bytes.size() || !std::isdigit(static_cast<unsigned char>(bytes[i])))
return false;
out = 0;
while (i < bytes.size() && std::isdigit(static_cast<unsigned char>(bytes[i])))
{
out = out * 10 + (bytes[i] - '0');
++i;
}
return true;
}
};
skipSpace();
if (!readInt(size.width) || !readInt(size.height))
{
err = "invalid PGM header";
return false;
}
int maxval = 0;
if (!readInt(maxval))
{
err = "invalid PGM header";
return false;
}
if (size.width <= 0 || size.height <= 0 || maxval <= 0 || maxval > 65535)
{
err = "invalid PGM dimensions";
return false;
}
if (i < bytes.size() && bytes[i] == '\r')
++i;
if (i < bytes.size() && bytes[i] == '\n')
++i;
const std::size_t data_size = static_cast<std::size_t>(size.width) * static_cast<std::size_t>(size.height);
if (bytes.size() - i < data_size)
{
err = "PGM data truncated";
return false;
}
gray.assign(bytes.begin() + static_cast<std::ptrdiff_t>(i),
bytes.begin() + static_cast<std::ptrdiff_t>(i + data_size));
if (maxval != 255)
{
const double scale = 255.0 / static_cast<double>(maxval);
for (auto& px : gray)
px = static_cast<uint8_t>(std::min(255.0, px * scale));
}
return true;
}
bool MapImageUtil::pngDimensions(const std::string& bytes, ImageSize& size, std::string& err)
{
if (!pngMagic(bytes))
{
err = "not a PNG image";
return false;
}
if (bytes.size() < 24)
{
err = "PNG header truncated";
return false;
}
auto readU32 = [&](std::size_t pos) -> int {
return (static_cast<unsigned char>(bytes[pos]) << 24) | (static_cast<unsigned char>(bytes[pos + 1]) << 16) |
(static_cast<unsigned char>(bytes[pos + 2]) << 8) | static_cast<unsigned char>(bytes[pos + 3]);
};
size.width = readU32(16);
size.height = readU32(20);
if (size.width <= 0 || size.height <= 0)
{
err = "invalid PNG dimensions";
return false;
}
return true;
}
bool MapImageUtil::toPngBytes(const std::string& bytes,
const std::string& filename_hint,
int negate,
std::string& png_out,
ImageSize& size,
std::string& err)
{
const std::string ext = lowerExt(filename_hint);
const bool as_png = isPngExtension(ext) || pngMagic(bytes);
const bool as_pgm = isPgmExtension(ext) || (!as_png && pgmMagic(bytes));
if (as_png)
{
if (!pngDimensions(bytes, size, err))
return false;
if (negate == 0)
{
png_out = bytes;
return true;
}
err = "PNG negate not supported";
return false;
}
if (!as_pgm)
{
err = "image must be PNG or PGM";
return false;
}
std::vector<uint8_t> gray;
if (!decodePgm(bytes, gray, size, err))
return false;
if (negate != 0)
applyNegate(gray);
int out_len = 0;
unsigned char* png_mem = stbi_write_png_to_mem(gray.data(), size.width, size.width, size.height, 1, &out_len);
if (!png_mem || out_len <= 0)
{
err = "failed to encode PNG";
return false;
}
png_out.assign(reinterpret_cast<char*>(png_mem), static_cast<std::size_t>(out_len));
STBIW_FREE(png_mem);
return true;
}
} // namespace lm

View File

@@ -0,0 +1,36 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace lm {
struct ImageSize
{
int width = 0;
int height = 0;
};
class MapImageUtil
{
public:
/** Decode P5 PGM (grayscale). */
static bool decodePgm(const std::string& bytes, std::vector<uint8_t>& gray, ImageSize& size, std::string& err);
/** Read PNG width/height from IHDR without full decode. */
static bool pngDimensions(const std::string& bytes, ImageSize& size, std::string& err);
/**
* Normalize map image to PNG bytes (always output filename map.png).
* Supports PNG passthrough or PGM conversion; optional negate (ROS map_server).
*/
static bool toPngBytes(const std::string& bytes,
const std::string& filename_hint,
int negate,
std::string& png_out,
ImageSize& size,
std::string& err);
};
} // namespace lm

166
src/util/ros_map_yaml.cpp Normal file
View File

@@ -0,0 +1,166 @@
#include "util/ros_map_yaml.hpp"
#include "util/string_util.hpp"
#include <sstream>
namespace lm {
namespace {
std::string stripComment(const std::string& line)
{
const auto pos = line.find('#');
if (pos == std::string::npos)
return line;
return line.substr(0, pos);
}
std::string trim(const std::string& s)
{
return StringUtil::trimCopy(s);
}
bool parseOriginArray(const std::string& value, RosMapYaml& out)
{
const auto start = value.find('[');
const auto end = value.find(']');
if (start == std::string::npos || end == std::string::npos || end <= start)
return false;
std::string inner = value.substr(start + 1, end - start - 1);
for (char& c : inner)
{
if (c == ',')
c = ' ';
}
std::istringstream iss(inner);
double x = 0, y = 0, yaw = 0;
if (!(iss >> x >> y))
return false;
iss >> yaw;
out.origin_x = x;
out.origin_y = y;
out.origin_yaw = yaw;
return true;
}
double parseDouble(const std::string& value, bool& ok)
{
ok = false;
try
{
size_t idx = 0;
const double v = std::stod(value, &idx);
if (idx > 0)
ok = true;
return v;
}
catch (...)
{
return 0.0;
}
}
int parseInt(const std::string& value, bool& ok)
{
ok = false;
try
{
size_t idx = 0;
const int v = std::stoi(value, &idx);
if (idx > 0)
ok = true;
return v;
}
catch (...)
{
return 0;
}
}
} // namespace
std::optional<RosMapYaml> RosMapYamlParser::parse(const std::string& yaml_text, std::string& err)
{
RosMapYaml out;
std::istringstream stream(yaml_text);
std::string line;
bool has_resolution = false;
while (std::getline(stream, line))
{
line = trim(stripComment(line));
if (line.empty())
continue;
const auto colon = line.find(':');
if (colon == std::string::npos)
continue;
const std::string key = trim(line.substr(0, colon));
const std::string value = trim(line.substr(colon + 1));
if (value.empty() && key != "image")
continue;
if (key == "image")
{
out.image = value;
}
else if (key == "resolution")
{
bool ok = false;
out.resolution = parseDouble(value, ok);
if (!ok || out.resolution <= 0.0)
{
err = "invalid resolution in yaml";
return std::nullopt;
}
has_resolution = true;
}
else if (key == "origin")
{
if (!parseOriginArray(value, out))
{
err = "invalid origin in yaml";
return std::nullopt;
}
}
else if (key == "negate")
{
bool ok = false;
out.negate = parseInt(value, ok);
if (!ok)
out.negate = 0;
}
else if (key == "occupied_thresh")
{
bool ok = false;
out.occupied_thresh = parseDouble(value, ok);
if (!ok)
out.occupied_thresh = 0.65;
}
else if (key == "free_thresh")
{
bool ok = false;
out.free_thresh = parseDouble(value, ok);
if (!ok)
out.free_thresh = 0.196;
}
}
if (out.image.empty())
{
err = "yaml missing image field";
return std::nullopt;
}
if (!has_resolution)
{
err = "yaml missing resolution field";
return std::nullopt;
}
return out;
}
} // namespace lm

26
src/util/ros_map_yaml.hpp Normal file
View File

@@ -0,0 +1,26 @@
#pragma once
#include <optional>
#include <string>
namespace lm {
struct RosMapYaml
{
std::string image;
double resolution = 0.05;
double origin_x = 0.0;
double origin_y = 0.0;
double origin_yaw = 0.0;
int negate = 0;
double occupied_thresh = 0.65;
double free_thresh = 0.196;
};
class RosMapYamlParser
{
public:
static std::optional<RosMapYaml> parse(const std::string& yaml_text, std::string& err);
};
} // namespace lm

View File

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

View File

@@ -1,14 +1,18 @@
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const statusEl = el("status"); const statusEl = el("status");
const listEl = el("lidarList"); const listEl = el("lidarList");
const lidarFormHintEl = el("lidarFormHint"); const lidarFormHintEl = el("lidarFormHint");
const pageTitleEl = document.querySelector(".pageTitle");
const navItemEls = Array.from(document.querySelectorAll(".navItem[data-page]"));
const pageOverviewEl = el("pageOverview"); const pageOverviewEl = el("pageOverview");
const pageConfigEl = el("pageConfig"); const pageConfigEl = el("pageConfig");
const pageMapsEl = el("pageMaps");
const pageMissionsEl = el("pageMissions"); const pageMissionsEl = el("pageMissions");
const pageIntegrationsEl = el("pageIntegrations"); const pageIntegrationsEl = el("pageIntegrations");
const pageSoundsEl = el("pageSounds");
const pageMonitoringEl = el("pageMonitoring");
const pageHelpEl = el("pageHelp");
const contentEl = document.querySelector(".content"); const contentEl = document.querySelector(".content");
const contentRightEl = el("contentRight"); const contentRightEl = el("contentRight");
const overviewBackendEl = el("overviewBackend"); const overviewBackendEl = el("overviewBackend");
@@ -121,45 +125,43 @@ const state = {
}; };
function setActivePage(page) { function setActivePage(page) {
const valid = ["dashboard", "config", "missions", "integrations"]; const valid = ["dashboard", "config", "maps", "missions", "sounds", "integrations", "monitoring", "help"];
let p = valid.includes(page) ? page : "config"; let p = valid.includes(page) ? page : "missions";
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) { if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v)); const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
p = fallback || "dashboard"; p = fallback || "dashboard";
} }
if (page === "overview") p = "dashboard"; if (page === "overview") p = "dashboard";
navItemEls.forEach((a) => {
const on = (a.dataset.page || "") === p;
a.classList.toggle("active", on);
if (on) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
});
const titles = {
dashboard: "Dashboard",
config: "Cấu Hình",
missions: "Missions",
integrations: "Tích hợp",
};
if (pageTitleEl) pageTitleEl.textContent = titles[p] || "Cấu Hình";
if (pageOverviewEl) pageOverviewEl.hidden = p !== "dashboard"; if (pageOverviewEl) pageOverviewEl.hidden = p !== "dashboard";
if (pageConfigEl) pageConfigEl.hidden = p !== "config"; if (pageConfigEl) pageConfigEl.hidden = p !== "config";
if (pageMapsEl) pageMapsEl.hidden = p !== "maps";
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions"; if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds";
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations"; if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
if (configSplitterEl) configSplitterEl.hidden = p !== "config"; if (configSplitterEl) configSplitterEl.hidden = p !== "config";
if (contentRightEl) contentRightEl.hidden = p !== "config"; if (contentRightEl) contentRightEl.hidden = p !== "config";
if (contentEl) { if (contentEl) {
contentEl.classList.toggle("content--dashboard", p === "dashboard"); contentEl.classList.toggle("content--dashboard", p === "dashboard");
contentEl.classList.toggle("content--config", p === "config"); contentEl.classList.toggle("content--config", p === "config");
contentEl.classList.toggle("content--maps", p === "maps");
contentEl.classList.toggle("content--missions", p === "missions"); contentEl.classList.toggle("content--missions", p === "missions");
contentEl.classList.toggle("content--sounds", p === "sounds");
contentEl.classList.toggle("content--integrations", p === "integrations"); contentEl.classList.toggle("content--integrations", p === "integrations");
contentEl.classList.toggle("content--monitoring", p === "monitoring");
contentEl.classList.toggle("content--help", p === "help");
} }
if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config";
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow(); if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide(); else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
if (p === "maps" && window.MapsApp) window.MapsApp.onPageShow();
if (p === "sounds" && window.SoundsApp) window.SoundsApp.onPageShow();
else if (window.SoundsApp?.onPageHide) window.SoundsApp.onPageHide();
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow(); if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide(); else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow(); if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
else if (window.IntegrationsApp?.onPageHide) window.IntegrationsApp.onPageHide(); else if (window.IntegrationsApp?.onPageHide) window.IntegrationsApp.onPageHide();
window.NavApp?.syncFromPage?.(p);
try { try {
localStorage.setItem("activePage", p); localStorage.setItem("activePage", p);
} catch { } catch {
@@ -168,25 +170,12 @@ function setActivePage(page) {
} }
function initNavigation() { function initNavigation() {
navItemEls.forEach((a) => { if (window.NavApp?.init) window.NavApp.init();
a.addEventListener("click", (evt) => { else setActivePage("missions");
evt.preventDefault();
setActivePage(a.dataset.page || "config");
});
});
// Restore last page, default to config (màn hình chính).
let initial = "config";
try {
const saved = localStorage.getItem("activePage");
if (saved === "dashboard" || saved === "overview" || saved === "config" || saved === "missions" || saved === "integrations") {
initial = saved === "overview" ? "dashboard" : saved;
}
} catch {
/* ignore */
}
setActivePage(initial);
} }
window.LmApp = { setActivePage };
function setLeftPaneWidth(px) { function setLeftPaneWidth(px) {
const v = Math.round(clamp(Number(px), 320, 720)); const v = Math.round(clamp(Number(px), 320, 720));
document.documentElement.style.setProperty("--leftPaneW", `${v}px`); document.documentElement.style.setProperty("--leftPaneW", `${v}px`);
@@ -638,7 +627,7 @@ function findDuplicateImuFrame(frameId, excludeId = null) {
function clearCanvasSelection() { function clearCanvasSelection() {
state.selectedId = null; state.selectedId = null;
state.selectedImuId = null; state.selectedImuId = null;
selectedText.textContent = "none"; selectedText.textContent = t("common.none");
setSelectedRelText(); setSelectedRelText();
} }
@@ -1706,7 +1695,10 @@ function updateLayoutActiveHint() {
if (!layoutActiveHintEl) return; if (!layoutActiveHintEl) return;
const name = state.activeLayoutName || "—"; const name = state.activeLayoutName || "—";
const dirty = state.layoutDirty ? " • chưa lưu" : ""; const dirty = state.layoutDirty ? " • chưa lưu" : "";
layoutActiveHintEl.textContent = `Đang chỉnh: ${name}${dirty}`; layoutActiveHintEl.textContent = t("config.layout.editingHint", {
name,
dirty: dirty ? t("config.layout.unsavedDirty") : "",
});
} }
function renderLayoutSelect() { function renderLayoutSelect() {
@@ -1792,7 +1784,7 @@ async function deleteActiveLayoutFromUI() {
return; return;
} }
const name = state.activeLayoutName || state.activeLayoutId; const name = state.activeLayoutName || state.activeLayoutId;
if (!window.confirm(`Xóa layout «${name}»? Hành động không hoàn tác.`)) return; if (!window.confirm(t("config.layout.deleteConfirm", { name }))) return;
await api(`/api/layouts/${state.activeLayoutId}`, { method: "DELETE" }); await api(`/api/layouts/${state.activeLayoutId}`, { method: "DELETE" });
state.viewInitialized = false; state.viewInitialized = false;
await loadAll(); await loadAll();
@@ -2147,7 +2139,7 @@ function setSelectedRelText() {
function renderList() { function renderList() {
if (!state.lidars.length) { if (!state.lidars.length) {
listEl.innerHTML = `<div class="item"><div class="itemName">Chưa có LiDAR</div><div class="itemMeta">Hãy thêm LiDAR ở form phía trên.</div></div>`; listEl.innerHTML = `<div class="item"><div class="itemName">${t("config.lidar.empty")}</div><div class="itemMeta">${t("config.lidar.emptyHint")}</div></div>`;
return; return;
} }
@@ -2253,7 +2245,7 @@ function updateImuItemPoseUI(id) {
function renderImuList() { function renderImuList() {
if (!imuListEl) return; if (!imuListEl) return;
if (!state.imus.length) { if (!state.imus.length) {
imuListEl.innerHTML = `<div class="item"><div class="itemName">Chưa có IMU</div><div class="itemMeta">Thêm IMU ở form phía trên.</div></div>`; imuListEl.innerHTML = `<div class="item"><div class="itemName">${t("config.imu.empty")}</div><div class="itemMeta">${t("config.imu.emptyHint")}</div></div>`;
return; return;
} }
@@ -3126,7 +3118,7 @@ async function loadAll() {
state.selectedImuId = null; state.selectedImuId = null;
} }
if (!state.selectedId && !state.selectedImuId) { if (!state.selectedId && !state.selectedImuId) {
selectedText.textContent = "none"; selectedText.textContent = t("common.none");
} }
setSelectedRelText(); setSelectedRelText();
renderList(); renderList();
@@ -3139,7 +3131,10 @@ async function loadAll() {
overviewActiveModelEl.textContent = state.layout?.robot?.model || "diff"; overviewActiveModelEl.textContent = state.layout?.robot?.model || "diff";
} }
if (overviewActiveSensorsEl) { if (overviewActiveSensorsEl) {
overviewActiveSensorsEl.textContent = `${state.lidars.length} LiDAR • ${state.imus.length} IMU`; overviewActiveSensorsEl.textContent = t("dashboard.system.sensorCount", {
lidars: state.lidars.length,
imus: state.imus.length,
});
} }
if (!state.viewInitialized) { if (!state.viewInitialized) {
fitViewToWorld(); fitViewToWorld();
@@ -3155,7 +3150,7 @@ async function loadAll() {
} }
} }
el("refreshBtn").addEventListener("click", async () => { el("refreshBtn")?.addEventListener("click", async () => {
try { try {
state.viewInitialized = false; state.viewInitialized = false;
await loadAll(); await loadAll();
@@ -3398,7 +3393,7 @@ window.addEventListener("keyup", (evt) => {
if (evt.key === "Shift") canvasWrap.classList.remove("shift-pan"); if (evt.key === "Shift") canvasWrap.classList.remove("shift-pan");
}); });
saveLayoutBtn.addEventListener("click", async () => { saveLayoutBtn?.addEventListener("click", async () => {
try { try {
await saveCurrentLayout(); await saveCurrentLayout();
setStatus(`Đã lưu layout «${state.activeLayoutName || ""}»`); setStatus(`Đã lưu layout «${state.activeLayoutName || ""}»`);
@@ -3413,16 +3408,16 @@ saveLayoutBtn.addEventListener("click", async () => {
await api("/api/health"); await api("/api/health");
await loadMotorCatalog(); await loadMotorCatalog();
await loadAll(); await loadAll();
selectedText.textContent = "none"; selectedText.textContent = t("common.none");
selectedRelText.textContent = "—"; selectedRelText.textContent = "—";
setStatus("Sẵn sàng"); setStatus(t("app.status.ready"));
} catch (e) { } catch (e) {
const msg = String(e.message || e); const msg = String(e.message || e);
if (overviewBackendEl) overviewBackendEl.textContent = `Lỗi: ${msg}`; if (overviewBackendEl) overviewBackendEl.textContent = t("common.error", { msg });
if (msg.includes("stack") || msg.includes("Maximum call")) { if (msg.includes("stack") || msg.includes("Maximum call")) {
setStatus(`Lỗi JavaScript: ${msg}`); setStatus(`${t("app.status.jsError")}: ${msg}`);
} else { } else {
setStatus(`Không kết nối được backend: ${msg}`); setStatus(`${t("app.status.backendError")}: ${msg}`);
} }
} }
}; };
@@ -3430,3 +3425,15 @@ saveLayoutBtn.addEventListener("click", async () => {
else window.AuthApp?.whenReady(() => { boot(); }); else window.AuthApp?.whenReady(() => { boot(); });
})(); })();
window.addEventListener("lm:locale-change", () => {
if (typeof renderList === "function") renderList();
if (typeof renderImuList === "function") renderImuList();
if (typeof renderLayoutSelect === "function") renderLayoutSelect();
if (typeof renderLayoutSelect === "function") renderLayoutSelect();
if (typeof updateLayoutActiveHint === "function") updateLayoutActiveHint();
if (typeof renderMotorWheels === "function") renderMotorWheels();
if (typeof renderBicycleMotorWheels === "function") renderBicycleMotorWheels();
if (typeof updateOverview === "function") updateOverview();
window.I18n?.applyDOM?.();
});

View File

@@ -12,10 +12,8 @@
const loginPinErrorEl = el("loginPinError"); const loginPinErrorEl = el("loginPinError");
const loginTabPasswordEl = el("loginTabPassword"); const loginTabPasswordEl = el("loginTabPassword");
const loginTabPinEl = el("loginTabPin"); const loginTabPinEl = el("loginTabPin");
const userMenuBtnEl = el("userMenuBtn"); const userMenuBtnEl = el("mirUserBtn");
const userMenuPanelEl = el("userMenuPanel"); const userMenuPanelEl = el("mirUserPanel");
const userMenuNameEl = el("userMenuName");
const userMenuGroupEl = el("userMenuGroup");
const changePasswordDialogEl = el("changePasswordDialog"); const changePasswordDialogEl = el("changePasswordDialog");
const changePasswordFormEl = el("changePasswordForm"); const changePasswordFormEl = el("changePasswordForm");
const changePasswordErrorEl = el("changePasswordError"); const changePasswordErrorEl = el("changePasswordError");
@@ -26,6 +24,8 @@
let pinDigits = []; let pinDigits = [];
let pinSubmitting = false; let pinSubmitting = false;
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
async function apiJson(path, opts = {}) { async function apiJson(path, opts = {}) {
const res = await fetch(path, { const res = await fetch(path, {
credentials: "include", credentials: "include",
@@ -84,9 +84,9 @@
} catch (e) { } catch (e) {
const msg = String(e.message || ""); const msg = String(e.message || "");
if (msg.includes("invalid pin") || msg.includes("401")) { if (msg.includes("invalid pin") || msg.includes("401")) {
showError("Mã PIN không hợp lệ. Liên hệ quản trị viên.", "pin"); showError(t("login.error.invalidPin"), "pin");
} else { } else {
showError(msg || "Mã PIN không hợp lệ", "pin"); showError(msg || t("login.error.invalidPinShort"), "pin");
} }
resetPin(); resetPin();
setLoginLoading(false); setLoginLoading(false);
@@ -112,7 +112,7 @@
function setLoginLoading(loading) { function setLoginLoading(loading) {
loginScreenEl?.classList.toggle("is-loading", loading); loginScreenEl?.classList.toggle("is-loading", loading);
document.querySelectorAll(".loginSubmitLabel").forEach((label) => { document.querySelectorAll(".loginSubmitLabel").forEach((label) => {
label.textContent = loading ? "Đang đăng nhập…" : "Đăng nhập"; label.textContent = loading ? t("login.submitting") : t("login.submit");
}); });
} }
@@ -138,11 +138,18 @@
return perms[resource] || "none"; return perms[resource] || "none";
} }
function isDistributor() {
return currentUser?.group_id === "group_distributors";
}
function canAccessPage(page) { function canAccessPage(page) {
if (page === "config") return isDistributor();
const map = { const map = {
dashboard: "dashboard", dashboard: "dashboard",
config: "config", maps: "maps",
missions: "missions", missions: "missions",
sounds: "integrations",
integrations: "integrations", integrations: "integrations",
}; };
const resource = map[page]; const resource = map[page];
@@ -155,30 +162,32 @@
} }
function applyNavPermissions() { function applyNavPermissions() {
document.querySelectorAll(".navItem[data-page]").forEach((a) => { if (window.NavApp?.applyPermissions) {
const page = a.dataset.page || ""; window.NavApp.applyPermissions();
const allowed = canAccessPage(page); }
a.hidden = !allowed;
a.style.display = allowed ? "" : "none";
});
document.body.classList.toggle("auth-readonly-config", !canWrite("config")); document.body.classList.toggle("auth-readonly-config", !canWrite("config"));
document.body.classList.toggle("auth-readonly-maps", !canWrite("maps"));
document.body.classList.toggle("auth-readonly-missions", !canWrite("missions")); document.body.classList.toggle("auth-readonly-missions", !canWrite("missions"));
document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations")); document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations"));
} }
function updateUserMenu() { function updateUserMenu() {
if (!currentUser) return; if (!currentUser) return;
if (userMenuNameEl) userMenuNameEl.textContent = currentUser.display_name || currentUser.username || "—"; if (window.TopbarApp?.updateUserMenu) {
if (userMenuGroupEl) userMenuGroupEl.textContent = currentUser.group_name || "—"; window.TopbarApp.updateUserMenu(currentUser);
return;
}
if (userMenuBtnEl) { if (userMenuBtnEl) {
const label = currentUser.display_name || currentUser.username || "User"; const label = (currentUser.group_name || "USER").toUpperCase();
userMenuBtnEl.textContent = label; userMenuBtnEl.title = `${currentUser.display_name || currentUser.username} (${currentUser.group_name || ""})`;
userMenuBtnEl.title = `${label} (${currentUser.group_name || ""})`; const labelEl = el("mirUserLabel");
if (labelEl) labelEl.textContent = label;
} }
} }
function unlockApp() { function unlockApp() {
setLoginLoading(false); setLoginLoading(false);
document.body.classList.remove("auth-logged-out");
if (loginScreenEl) { if (loginScreenEl) {
loginScreenEl.setAttribute("hidden", ""); loginScreenEl.setAttribute("hidden", "");
loginScreenEl.style.display = "none"; loginScreenEl.style.display = "none";
@@ -188,14 +197,16 @@
shellEl.style.display = ""; shellEl.style.display = "";
} }
applyNavPermissions(); applyNavPermissions();
updateUserMenu();
ready = true; ready = true;
window.dispatchEvent(new CustomEvent("lm:auth-ready", { detail: { user: currentUser } })); window.dispatchEvent(new CustomEvent("lm:auth-ready", { detail: { user: currentUser } }));
updateUserMenu();
} }
function lockApp() { function lockApp() {
ready = false; ready = false;
currentUser = null; currentUser = null;
document.body.classList.add("auth-logged-out");
window.TopbarApp?.hideJoystickOverlay?.();
if (shellEl) shellEl.classList.add("auth-locked"); if (shellEl) shellEl.classList.add("auth-locked");
if (loginScreenEl) { if (loginScreenEl) {
loginScreenEl.removeAttribute("hidden"); loginScreenEl.removeAttribute("hidden");
@@ -209,6 +220,10 @@
async function tryRestoreSession() { async function tryRestoreSession() {
try { try {
const data = await apiJson("/api/auth/me"); const data = await apiJson("/api/auth/me");
if (!data?.user) {
lockApp();
return false;
}
currentUser = data.user; currentUser = data.user;
unlockApp(); unlockApp();
return true; return true;
@@ -239,6 +254,12 @@
} }
async function logout() { async function logout() {
try {
await window.TopbarApp?.disengageJoystick?.();
} catch {
/* ignore */
}
window.TopbarApp?.hideJoystickOverlay?.();
try { try {
await apiJson("/api/auth/logout", { method: "POST", body: "{}" }); await apiJson("/api/auth/logout", { method: "POST", body: "{}" });
} catch { } catch {
@@ -251,6 +272,17 @@
window.dispatchEvent(new Event("lm:auth-logout")); window.dispatchEvent(new Event("lm:auth-logout"));
} }
async function saveProfile() {
const display_name = el("mirProfileDisplayName")?.value?.trim() || "";
if (!display_name) throw new Error(t("auth.profile.displayNameRequired"));
const data = await apiJson("/api/auth/profile", {
method: "PUT",
body: JSON.stringify({ display_name }),
});
currentUser = data.user;
updateUserMenu();
}
function bindEvents() { function bindEvents() {
loginTabPasswordEl?.addEventListener("click", (evt) => { loginTabPasswordEl?.addEventListener("click", (evt) => {
evt.preventDefault(); evt.preventDefault();
@@ -266,7 +298,7 @@
const username = el("loginUsername")?.value?.trim() || ""; const username = el("loginUsername")?.value?.trim() || "";
const password = el("loginPasswordInput")?.value || ""; const password = el("loginPasswordInput")?.value || "";
if (!username || !password) { if (!username || !password) {
showError("Nhập tên đăng nhập và mật khẩu", "password"); showError(t("login.error.missingCredentials"), "password");
return; return;
} }
setLoginLoading(true); setLoginLoading(true);
@@ -276,11 +308,11 @@
} catch (e) { } catch (e) {
const msg = String(e.message || ""); const msg = String(e.message || "");
if (msg.includes("credentials") || msg.includes("401")) { if (msg.includes("credentials") || msg.includes("401")) {
showError("Sai tên đăng nhập hoặc mật khẩu. Thử Admin / admin", "password"); showError(t("login.error.badCredentials"), "password");
} else if (msg.includes("fetch") || msg.includes("Failed")) { } else if (msg.includes("fetch") || msg.includes("Failed")) {
showError("Không kết nối được server. Kiểm tra http://localhost:8080", "password"); showError(t("login.error.serverUnreachable"), "password");
} else { } else {
showError(msg || "Đăng nhập thất bại", "password"); showError(msg || t("login.error.failed"), "password");
} }
setLoginLoading(false); setLoginLoading(false);
} }
@@ -308,36 +340,35 @@
} }
}); });
userMenuBtnEl?.addEventListener("click", (evt) => { el("mirUserSignOutBtn")?.addEventListener("click", (evt) => {
evt.stopPropagation();
const open = userMenuPanelEl?.hasAttribute("hidden");
if (open) userMenuPanelEl?.removeAttribute("hidden");
else userMenuPanelEl?.setAttribute("hidden", "");
});
document.addEventListener("click", () => {
userMenuPanelEl?.setAttribute("hidden", "");
});
el("userMenuSignOutBtn")?.addEventListener("click", (evt) => {
evt.preventDefault(); evt.preventDefault();
logout(); logout();
}); });
el("userMenuChangePasswordBtn")?.addEventListener("click", (evt) => { el("mirUserChangePasswordBtn")?.addEventListener("click", (evt) => {
evt.preventDefault(); evt.preventDefault();
userMenuPanelEl?.setAttribute("hidden", ""); userMenuPanelEl?.setAttribute("hidden", "");
changePasswordErrorEl && (changePasswordErrorEl.textContent = ""); changePasswordErrorEl && (changePasswordErrorEl.textContent = "");
changePasswordDialogEl?.showModal(); changePasswordDialogEl?.showModal();
}); });
el("mirProfileSaveBtn")?.addEventListener("click", async (evt) => {
evt.preventDefault();
try {
await saveProfile();
userMenuPanelEl?.setAttribute("hidden", "");
} catch (e) {
alert(e.message || t("auth.profile.saveFailed"));
}
});
changePasswordFormEl?.addEventListener("submit", async (evt) => { changePasswordFormEl?.addEventListener("submit", async (evt) => {
evt.preventDefault(); evt.preventDefault();
const current = el("changePasswordCurrent")?.value || ""; const current = el("changePasswordCurrent")?.value || "";
const next = el("changePasswordNew")?.value || ""; const next = el("changePasswordNew")?.value || "";
const confirm = el("changePasswordConfirm")?.value || ""; const confirm = el("changePasswordConfirm")?.value || "";
if (next !== confirm) { if (next !== confirm) {
if (changePasswordErrorEl) changePasswordErrorEl.textContent = "Mật khẩu mới không khớp"; if (changePasswordErrorEl) changePasswordErrorEl.textContent = t("auth.changePassword.mismatch");
return; return;
} }
try { try {
@@ -348,7 +379,7 @@
changePasswordDialogEl?.close(); changePasswordDialogEl?.close();
changePasswordFormEl.reset(); changePasswordFormEl.reset();
} catch (e) { } catch (e) {
if (changePasswordErrorEl) changePasswordErrorEl.textContent = e.message || "Đổi mật khẩu thất bại"; if (changePasswordErrorEl) changePasswordErrorEl.textContent = e.message || t("auth.changePassword.failed");
} }
}); });
} }
@@ -366,7 +397,18 @@
}; };
bindEvents(); bindEvents();
window.addEventListener("lm:locale-change", () => {
const loading = loginScreenEl?.classList.contains("is-loading");
setLoginLoading(loading);
});
setLoginMode("password"); setLoginMode("password");
shellEl?.classList.add("auth-locked"); shellEl?.classList.add("auth-locked");
if (window.location.search) {
try {
history.replaceState({}, "", window.location.pathname);
} catch {
/* ignore */
}
}
tryRestoreSession(); tryRestoreSession();
})(); })();

File diff suppressed because it is too large Load Diff

1337
www/i18n.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
const COIL_MAX = 2000; const COIL_MAX = 2000;
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const triggerListEl = el("integrationTriggerList"); const triggerListEl = el("integrationTriggerList");
const triggerEmptyEl = el("integrationTriggerEmpty"); const triggerEmptyEl = el("integrationTriggerEmpty");
const coilGridEl = el("integrationCoilGrid"); const coilGridEl = el("integrationCoilGrid");
@@ -72,7 +73,7 @@
const data = await apiJson("/api/fleet/robots"); const data = await apiJson("/api/fleet/robots");
store.robots = Array.isArray(data) ? data : []; store.robots = Array.isArray(data) ? data : [];
} catch { } catch {
store.robots = [{ id: "default", name: "Robot chính" }]; store.robots = [{ id: "default", name: t("integrations.defaultRobot") }];
} }
} }
@@ -96,7 +97,7 @@
if (!store.missions.length) { if (!store.missions.length) {
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = ""; opt.value = "";
opt.textContent = "— Chưa có mission —"; opt.textContent = t("integrations.noMissions");
selectEl.appendChild(opt); selectEl.appendChild(opt);
return; return;
} }
@@ -122,7 +123,7 @@
if (!store.robots.length) { if (!store.robots.length) {
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = "default"; opt.value = "default";
opt.textContent = "Robot chính"; opt.textContent = t("integrations.defaultRobot");
opt.selected = selected === "default"; opt.selected = selected === "default";
selectEl.appendChild(opt); selectEl.appendChild(opt);
} }
@@ -133,24 +134,24 @@
triggerListEl.innerHTML = ""; triggerListEl.innerHTML = "";
if (triggerEmptyEl) triggerEmptyEl.hidden = store.triggers.length > 0; if (triggerEmptyEl) triggerEmptyEl.hidden = store.triggers.length > 0;
store.triggers.forEach((t) => { store.triggers.forEach((trigger) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "missionListItem integrationRow"; row.className = "missionListItem integrationRow";
const coil = t.coil_id; const coil = trigger.coil_id;
const on = store.coils[String(coil)] === true; const on = store.coils[String(coil)] === true;
row.innerHTML = ` row.innerHTML = `
<div> <div>
<div class="missionListItemTitle">${escapeHtml(t.name)}</div> <div class="missionListItemTitle">${escapeHtml(trigger.name)}</div>
<div class="missionListItemMeta"> <div class="missionListItemMeta">
Coil <span class="mono">${coil}</span> Coil <span class="mono">${coil}</span>
${escapeHtml(missionName(t.mission_id))} ${escapeHtml(missionName(trigger.mission_id))}
· ${t.enabled === false ? "Tắt" : "Bật"} · ${trigger.enabled === false ? t("common.disabled") : t("common.enabled")}
· coil hiện tại: <span class="mono">${on ? "ON" : "OFF"}</span> · ${t("integrations.coilState", { state: on ? "ON" : "OFF" })}
</div> </div>
</div> </div>
<div class="missionListItemActions"> <div class="missionListItemActions">
<button type="button" class="btn subtle" data-fire-coil="${coil}">Kích hoạt</button> <button type="button" class="btn subtle" data-fire-coil="${coil}">${t("integrations.fireTrigger")}</button>
<button type="button" class="btn subtle danger" data-delete-trigger="${escapeHtml(t.id)}">Xóa</button> <button type="button" class="btn subtle danger" data-delete-trigger="${escapeHtml(trigger.id)}">${t("common.delete")}</button>
</div>`; </div>`;
triggerListEl.appendChild(row); triggerListEl.appendChild(row);
}); });
@@ -160,10 +161,10 @@
if (!coilGridEl) return; if (!coilGridEl) return;
const assigned = new Map(store.triggers.map((t) => [t.coil_id, t])); const assigned = new Map(store.triggers.map((t) => [t.coil_id, t]));
const chips = []; const chips = [];
assigned.forEach((t, coilId) => { assigned.forEach((trigger, coilId) => {
const on = store.coils[String(coilId)] === true; const on = store.coils[String(coilId)] === true;
chips.push( chips.push(
`<button type="button" class="integrationCoilChip${on ? " on" : ""}" data-fire-coil="${coilId}" title="${escapeHtml(t.name)}"> `<button type="button" class="integrationCoilChip${on ? " on" : ""}" data-fire-coil="${coilId}" title="${escapeHtml(trigger.name)}">
${coilId} ${coilId}
</button>` </button>`
); );
@@ -171,11 +172,11 @@
coilGridEl.innerHTML = coilGridEl.innerHTML =
chips.length > 0 chips.length > 0
? chips.join("") ? chips.join("")
: `<span class="mutedNote">Chưa gán coil. Thêm trigger bên trên (10012000).</span>`; : `<span class="mutedNote">${t("integrations.coilsEmpty")}</span>`;
} }
function formatScheduleTime(s) { function formatScheduleTime(s) {
if (!s.start_at) return s.start_mode === "scheduled" ? "—" : "Ngay (asap)"; if (!s.start_at) return s.start_mode === "scheduled" ? "—" : t("integrations.dialog.schedule.asap");
try { try {
return new Date(s.start_at).toLocaleString("vi-VN"); return new Date(s.start_at).toLocaleString("vi-VN");
} catch { } catch {
@@ -204,7 +205,7 @@
</div> </div>
</div> </div>
<div class="missionListItemActions"> <div class="missionListItemActions">
<button type="button" class="btn subtle" data-run-schedule="${escapeHtml(s.id)}">Chạy ngay</button> <button type="button" class="btn subtle" data-run-schedule="${escapeHtml(s.id)}">${t("integrations.schedule.runNow")}</button>
<button type="button" class="btn subtle danger" data-delete-schedule="${escapeHtml(s.id)}">Xóa</button> <button type="button" class="btn subtle danger" data-delete-schedule="${escapeHtml(s.id)}">Xóa</button>
</div>`; </div>`;
scheduleListEl.appendChild(row); scheduleListEl.appendChild(row);
@@ -316,7 +317,7 @@
} }
async function deleteTrigger(id) { async function deleteTrigger(id) {
if (!confirm("Xóa trigger Modbus này?")) return; if (!confirm(t("integrations.confirm.deleteTrigger"))) return;
try { try {
await apiJson(`/api/triggers/${id}`, { method: "DELETE" }); await apiJson(`/api/triggers/${id}`, { method: "DELETE" });
await refreshAll(); await refreshAll();
@@ -326,7 +327,7 @@
} }
async function deleteSchedule(id) { async function deleteSchedule(id) {
if (!confirm("Xóa lịch fleet này?")) return; if (!confirm(t("integrations.confirm.deleteSchedule"))) return;
try { try {
await apiJson(`/api/fleet/schedules/${id}`, { method: "DELETE" }); await apiJson(`/api/fleet/schedules/${id}`, { method: "DELETE" });
await refreshAll(); await refreshAll();
@@ -446,6 +447,12 @@
function boot() { function boot() {
init(); init();
} }
window.addEventListener("lm:locale-change", () => {
renderTriggers();
renderCoilGrid();
renderSchedules();
});
if (window.AuthApp?.isReady()) boot(); if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true }); else window.addEventListener("lm:auth-ready", boot, { once: true });
})(); })();

233
www/map-advanced-zones.js Normal file
View File

@@ -0,0 +1,233 @@
(() => {
/**
* Runtime hooks for Directional / Planner settings / I/O map zones (MiR §4.2.6.5, .10, .11).
*/
const TYPES = {
directional: "directional",
directional_line: "directional_line",
planner: "planner",
io: "io",
};
const ADVANCED_TYPES = new Set([
TYPES.directional,
TYPES.directional_line,
TYPES.planner,
TYPES.io,
]);
const DIRECTION_DEGREES = [0, 45, 90, 135, 180, 225, 270, 315];
const DEFAULT_PLANNER = {
no_localization: false,
look_ahead: false,
path_deviation: 0.5,
path_timeout: 30,
ignore_obstacles: false,
};
const DEFAULT_IO = {
io_module: "",
plc_register: null,
plc_value: null,
plc_mode: "set",
};
function pointInPolygon(px, py, points) {
return window.MapPlannerZones?.pointInPolygon(px, py, points) || false;
}
function normalizeDirectionDeg(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 0;
let deg = ((Math.round(n / 45) * 45) % 360 + 360) % 360;
if (!DIRECTION_DEGREES.includes(deg)) {
deg = DIRECTION_DEGREES.reduce((best, d) =>
Math.abs(d - deg) < Math.abs(best - deg) ? d : best,
);
}
return deg;
}
function clampPathDeviation(value) {
const n = Number(value);
if (!Number.isFinite(n)) return DEFAULT_PLANNER.path_deviation;
return Math.min(3, Math.max(0, n));
}
function clampPathTimeout(value) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return DEFAULT_PLANNER.path_timeout;
return n;
}
function normalizePlannerSettings(raw = {}) {
return {
no_localization: !!raw.no_localization,
look_ahead: !!raw.look_ahead,
path_deviation: clampPathDeviation(raw.path_deviation),
path_timeout: clampPathTimeout(raw.path_timeout),
ignore_obstacles: !!raw.ignore_obstacles,
};
}
function normalizeIoSettings(raw = {}) {
const mode = raw.plc_mode;
return {
io_module: typeof raw.io_module === "string" ? raw.io_module : "",
plc_register:
raw.plc_register == null || raw.plc_register === ""
? null
: Number(raw.plc_register),
plc_value:
raw.plc_value == null || raw.plc_value === "" ? null : Number(raw.plc_value),
plc_mode: mode === "add" || mode === "subtract" ? mode : "set",
};
}
function isAdvancedZoneType(type) {
return ADVANCED_TYPES.has(type);
}
function isDirectionalType(type) {
return type === TYPES.directional || type === TYPES.directional_line;
}
function filterAdvancedZones(zones) {
return (Array.isArray(zones) ? zones : []).filter((z) => isAdvancedZoneType(z?.type));
}
function pointNearPolyline(px, py, points, halfWidth = 8) {
if (!points?.length || points.length < 2) return false;
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const lenSq = dx * dx + dy * dy;
let dist;
if (lenSq === 0) dist = Math.hypot(px - p1.x, py - p1.y);
else {
let t = ((px - p1.x) * dx + (py - p1.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
dist = Math.hypot(px - (p1.x + t * dx), py - (p1.y + t * dy));
}
if (dist <= halfWidth) return true;
}
return false;
}
function zonesAtPoint(zones, px, py, mapMeta = null) {
const list = Array.isArray(zones) ? zones : [];
const hits = [];
for (let i = list.length - 1; i >= 0; i--) {
const z = list[i];
if (!isAdvancedZoneType(z?.type) || !z.points?.length) continue;
if (z.type === TYPES.directional_line) {
const width = window.MapObjects?.zoneLineWidthPx(z, mapMeta) ?? 8;
if (pointNearPolyline(px, py, z.points, width / 2 + 4)) hits.push(z);
continue;
}
if (pointInPolygon(px, py, z.points)) hits.push(z);
}
return hits;
}
function lineDirectionDeg(zone) {
if (!zone?.points?.length || zone.points.length < 2) return 0;
const p0 = zone.points[0];
const p1 = zone.points[zone.points.length - 1];
let deg = (Math.atan2(-(p1.y - p0.y), p1.x - p0.x) * 180) / Math.PI;
if (zone.reversed) deg = (deg + 180) % 360;
return normalizeDirectionDeg(deg);
}
function zoneDirectionDeg(zone) {
if (!zone) return 0;
if (zone.type === TYPES.directional_line) return lineDirectionDeg(zone);
if (zone.type === TYPES.directional) return normalizeDirectionDeg(zone.direction_deg);
return 0;
}
/** Heading radians (map image coords) allowed if dot >= 0 with zone arrow. */
function isHeadingAllowed(zone, headingRad) {
const arrowRad = (-zoneDirectionDeg(zone) * Math.PI) / 180;
const hx = Math.cos(headingRad);
const hy = Math.sin(headingRad);
const ax = Math.cos(arrowRad);
const ay = Math.sin(arrowRad);
return hx * ax + hy * ay >= 0;
}
function getDirectionalConstraint(zones, px, py, headingRad) {
const hits = zonesAtPoint(zones, px, py).filter(isDirectionalType);
const zone = hits[0];
if (!zone) return null;
return {
zone,
direction_deg: zoneDirectionDeg(zone),
allowed: isHeadingAllowed(zone, headingRad),
};
}
function getPlannerSettings(zones, px, py) {
const zone = zonesAtPoint(zones, px, py).find((z) => z.type === TYPES.planner);
if (!zone) return null;
return {
zone,
settings: normalizePlannerSettings(zone),
};
}
function getIoActivation(zones, px, py) {
const zone = zonesAtPoint(zones, px, py).find((z) => z.type === TYPES.io);
if (!zone) return null;
return {
zone,
settings: normalizeIoSettings(zone),
};
}
function classifyPoint(zones, px, py, headingRad = null) {
const hits = zonesAtPoint(zones, px, py);
const directional = hits.find(isDirectionalType);
const planner = hits.find((z) => z.type === TYPES.planner);
const io = hits.find((z) => z.type === TYPES.io);
const out = {
directional: directional || null,
direction_deg: directional ? zoneDirectionDeg(directional) : null,
planner: planner || null,
planner_settings: planner ? normalizePlannerSettings(planner) : null,
io: io || null,
io_settings: io ? normalizeIoSettings(io) : null,
zones: hits,
};
if (directional && headingRad != null) {
out.heading_allowed = isHeadingAllowed(directional, headingRad);
}
return out;
}
window.MapAdvancedZones = {
TYPES,
ADVANCED_TYPES,
DIRECTION_DEGREES,
DEFAULT_PLANNER,
DEFAULT_IO,
normalizeDirectionDeg,
normalizePlannerSettings,
normalizeIoSettings,
clampPathDeviation,
clampPathTimeout,
isAdvancedZoneType,
isDirectionalType,
filterAdvancedZones,
zonesAtPoint,
zoneDirectionDeg,
isHeadingAllowed,
getDirectionalConstraint,
getPlannerSettings,
getIoActivation,
classifyPoint,
};
})();

89
www/map-behavior-zones.js Normal file
View File

@@ -0,0 +1,89 @@
(() => {
/**
* Runtime hooks for Speed / Sound map zones (MiR §4.2.6.89).
* Vector overlay only — consumed by motion controller / mission runner.
*/
const TYPES = {
speed: "speed",
sound: "sound",
};
const BEHAVIOR_TYPES = new Set([TYPES.speed, TYPES.sound]);
const SPEED_MIN = 0.1;
const SPEED_MAX = 1.5;
const DEFAULT_SPEED_MPS = 0.8;
function pointInPolygon(px, py, points) {
return window.MapPlannerZones?.pointInPolygon(px, py, points) || false;
}
function clampSpeed(value) {
const n = Number(value);
if (!Number.isFinite(n)) return DEFAULT_SPEED_MPS;
return Math.min(SPEED_MAX, Math.max(SPEED_MIN, n));
}
function isBehaviorZoneType(type) {
return BEHAVIOR_TYPES.has(type);
}
function filterBehaviorZones(zones) {
return (Array.isArray(zones) ? zones : []).filter((z) => isBehaviorZoneType(z?.type));
}
/** Topmost behavior zones containing image pixel (newest wins for overlaps). */
function zonesAtPoint(zones, px, py) {
const list = Array.isArray(zones) ? zones : [];
const hits = [];
for (let i = list.length - 1; i >= 0; i--) {
const z = list[i];
if (!isBehaviorZoneType(z?.type) || !z.points?.length) continue;
if (pointInPolygon(px, py, z.points)) hits.push(z);
}
return hits;
}
function getSpeedLimit(zones, px, py) {
const hit = zonesAtPoint(zones, px, py).find((z) => z.type === TYPES.speed);
if (!hit) return null;
return clampSpeed(hit.speed_mps);
}
function getSoundAtPoint(zones, px, py) {
const hit = zonesAtPoint(zones, px, py).find((z) => z.type === TYPES.sound);
if (!hit?.sound_id) return null;
return {
sound_id: hit.sound_id,
zone_id: hit.id,
};
}
function classifyPoint(zones, px, py) {
const hits = zonesAtPoint(zones, px, py);
const speedZone = hits.find((z) => z.type === TYPES.speed);
const soundZone = hits.find((z) => z.type === TYPES.sound);
return {
speed_mps: speedZone ? clampSpeed(speedZone.speed_mps) : null,
sound_id: soundZone?.sound_id || null,
speed_zone: speedZone || null,
sound_zone: soundZone || null,
zones: hits,
};
}
window.MapBehaviorZones = {
TYPES,
BEHAVIOR_TYPES,
SPEED_MIN,
SPEED_MAX,
DEFAULT_SPEED_MPS,
clampSpeed,
isBehaviorZoneType,
filterBehaviorZones,
zonesAtPoint,
getSpeedLimit,
getSoundAtPoint,
classifyPoint,
};
})();

2476
www/map-editor.js Normal file

File diff suppressed because it is too large Load Diff

176
www/map-geo.js Normal file
View File

@@ -0,0 +1,176 @@
(() => {
/**
* MiR-style map coordinate model (3 layers):
*
* 1. View space — screen pixels in the viewport; pan + zoom (UI only).
* 2. Image space — floor plan pixels (1 px image = 1 px on sheet; 20 px/m at res 0.05).
* 3. World space — map coordinates in metres + yaw (robot, positions).
*
* view --scale+translate--> image --resolution+origin--> world
*/
function meta(map) {
return {
resolution: Number(map?.resolution) || 0.05,
originX: Number(map?.origin_x) || 0,
originY: Number(map?.origin_y) || 0,
originYaw: Number(map?.origin_yaw) || 0,
width: Number(map?.width) || 0,
height: Number(map?.height) || 0,
};
}
function pixelsPerMeter(map) {
const res = meta(map).resolution;
return res > 0 ? 1 / res : 20;
}
/** Centimetres → image pixels (uses map resolution m/px). */
function cmToPixels(cm, map) {
const res = meta(map).resolution;
if (!Number.isFinite(Number(cm)) || res <= 0) return 0;
return Number(cm) / 100 / res;
}
/** Image pixels → centimetres. */
function pixelsToCm(px, map) {
const res = meta(map).resolution;
if (!Number.isFinite(Number(px)) || res <= 0) return 0;
return Number(px) * res * 100;
}
function imageSize(map, imageEl) {
const w = imageEl?.naturalWidth || meta(map).width || 0;
const h = imageEl?.naturalHeight || meta(map).height || 0;
return { width: w, height: h };
}
/** --- View space (layer 1) --- */
function createView(scale = 1, panX = 0, panY = 0) {
return { scale, panX, panY };
}
function applyViewTransform(el, view) {
if (!el || !view) return;
el.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${view.scale})`;
}
function fitViewToImage(viewportW, viewportH, imageW, imageH, pad = 48) {
if (!imageW || !imageH) {
return createView(1, Math.max(40, pad), Math.max(40, pad));
}
const scale = Math.min((viewportW - pad) / imageW, (viewportH - pad) / imageH, 4);
const s = Math.max(0.1, scale);
return {
scale: s,
panX: (viewportW - imageW * s) / 2,
panY: (viewportH - imageH * s) / 2,
};
}
function centerViewOnImage(viewportW, viewportH, imageW, imageH, view) {
const s = view?.scale || 1;
return {
scale: s,
panX: Math.max(40, (viewportW - imageW * s) / 2),
panY: Math.max(40, (viewportH - imageH * s) / 2),
};
}
/** Viewport-local px → image px (inverse of translate+scale). */
function viewportToImage(view, vx, vy) {
const s = view.scale || 1;
return {
x: (vx - view.panX) / s,
y: (vy - view.panY) / s,
};
}
/** Image px → viewport-local px. */
function imageToViewport(view, ix, iy) {
return {
x: view.panX + ix * view.scale,
y: view.panY + iy * view.scale,
};
}
/** Zoom toward a viewport anchor; keeps image point under cursor fixed. */
function zoomViewAt(view, anchorVx, anchorVy, factor, minScale = 0.1, maxScale = 8) {
const img = viewportToImage(view, anchorVx, anchorVy);
const nextScale = Math.min(maxScale, Math.max(minScale, view.scale * factor));
return {
scale: nextScale,
panX: anchorVx - img.x * nextScale,
panY: anchorVy - img.y * nextScale,
};
}
/** --- Image space (layer 2) --- */
function gridSteps(map) {
const ppm = pixelsPerMeter(map);
return {
minor: Math.max(1, Math.round(ppm)),
major: Math.max(1, Math.round(ppm * 5)),
};
}
function clampImagePoint(px, py, imageW, imageH) {
if (px < 0 || py < 0 || px > imageW || py > imageH) return null;
return { x: px, y: py };
}
/** Screen/client coords → image px using the transformed sheet rect. */
function clientToImage(clientX, clientY, sheetRect, imageW, imageH) {
if (!sheetRect?.width || !sheetRect?.height || !imageW || !imageH) return null;
const px = ((clientX - sheetRect.left) / sheetRect.width) * imageW;
const py = ((clientY - sheetRect.top) / sheetRect.height) * imageH;
return clampImagePoint(px, py, imageW, imageH);
}
/** --- World space (layer 3) — ROS map_server convention --- */
function worldToPixel(map, imgW, imgH, wx, wy) {
const { resolution, originX, originY } = meta(map);
return {
x: (wx - originX) / resolution,
y: imgH - (wy - originY) / resolution,
};
}
function pixelToWorld(map, imgW, imgH, px, py) {
const { resolution, originX, originY } = meta(map);
return {
x: originX + px * resolution,
y: originY + (imgH - py) * resolution,
};
}
/** Viewport pointer → world (chains view→image→world). */
function clientToWorld(map, clientX, clientY, sheetRect, imageW, imageH) {
const img = clientToImage(clientX, clientY, sheetRect, imageW, imageH);
if (!img) return null;
return pixelToWorld(map, imageW, imageH, img.x, img.y);
}
window.MapGeo = {
meta,
pixelsPerMeter,
cmToPixels,
pixelsToCm,
imageSize,
createView,
applyViewTransform,
fitViewToImage,
centerViewOnImage,
viewportToImage,
imageToViewport,
zoomViewAt,
gridSteps,
clientToImage,
clientToWorld,
worldToPixel,
pixelToWorld,
};
})();

97
www/map-import.js Normal file
View File

@@ -0,0 +1,97 @@
(() => {
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
function fileStem(name) {
const base = String(name).replace(/^.*[/\\]/, "");
const dot = base.lastIndexOf(".");
return dot >= 0 ? base.slice(0, dot) : base;
}
function isYamlFile(file) {
return /\.ya?ml$/i.test(file.name);
}
function isMapImageFile(file) {
return /\.(png|pgm)$/i.test(file.name);
}
async function matchRosMapFiles(fileList) {
const files = Array.from(fileList || []);
const yamlFile = files.find(isYamlFile);
if (!yamlFile) {
throw new Error(t("maps.importNeedYaml"));
}
let imageFile = files.find((f) => f !== yamlFile && isMapImageFile(f));
try {
const yamlText = await yamlFile.text();
const imageRef = yamlText.match(/^image:\s*(\S+)/m)?.[1];
if (imageRef) {
const refStem = fileStem(imageRef);
const matched = files.find(
(f) => f !== yamlFile && isMapImageFile(f) && fileStem(f.name) === refStem,
);
if (matched) imageFile = matched;
}
} catch {
/* best-effort stem match */
}
if (!imageFile) {
throw new Error(t("maps.importNeedImage"));
}
return { yamlFile, imageFile };
}
function buildFormData(yamlFile, imageFile, extras = {}) {
const form = new FormData();
form.append("yaml", yamlFile, yamlFile.name);
form.append("image", imageFile, imageFile.name);
Object.entries(extras).forEach(([key, value]) => {
if (value != null && value !== "") form.append(key, value);
});
return form;
}
async function apiImport(path, form) {
const res = await fetch(path, { method: "POST", credentials: "include", body: form });
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return res.json();
}
async function importNewMap(fileList, extras = {}) {
const { yamlFile, imageFile } = await matchRosMapFiles(fileList);
const form = buildFormData(yamlFile, imageFile, {
name: extras.name || fileStem(yamlFile.name),
site_id: extras.site_id || "",
created_by: extras.created_by || "",
description: extras.description || "",
});
return apiImport("/api/maps/import", form);
}
async function importOverwriteMap(mapId, fileList) {
const { yamlFile, imageFile } = await matchRosMapFiles(fileList);
const form = buildFormData(yamlFile, imageFile);
return apiImport(`/api/maps/${encodeURIComponent(mapId)}/import`, form);
}
window.MapImport = {
fileStem,
isYamlFile,
isMapImageFile,
matchRosMapFiles,
importNewMap,
importOverwriteMap,
};
})();

868
www/map-objects.js Normal file
View File

@@ -0,0 +1,868 @@
(() => {
const TYPES = {
wall: "wall",
floor: "floor",
position: "position",
forbidden: "forbidden",
preferred: "preferred",
unpreferred: "unpreferred",
speed: "speed",
sound: "sound",
directional: "directional",
directional_line: "directional_line",
planner: "planner",
io: "io",
};
const FLOOR_PLAN_TYPES = new Set([TYPES.wall, TYPES.floor]);
const POLYGON_TYPES = new Set([
TYPES.floor,
TYPES.forbidden,
TYPES.preferred,
TYPES.unpreferred,
TYPES.speed,
TYPES.sound,
TYPES.directional,
TYPES.planner,
TYPES.io,
]);
const POINT_SHAPE_TYPES = new Set([TYPES.wall, TYPES.directional_line, ...POLYGON_TYPES]);
const DUAL_DRAW_TYPES = new Set([
TYPES.forbidden,
TYPES.preferred,
TYPES.unpreferred,
TYPES.directional,
]);
const LINE_ONLY_OBJECT_TYPES = new Set([TYPES.wall]);
const SHAPE_ONLY_OBJECT_TYPES = new Set([
TYPES.floor,
TYPES.speed,
TYPES.sound,
TYPES.planner,
TYPES.io,
]);
const GEOMETRY_LINE = "line";
function newId() {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
}
function normalizePoint(p) {
const x = Number(p?.x);
const y = Number(p?.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
}
function minPoints(type) {
if (type === TYPES.wall || type === TYPES.directional_line) return 2;
if (POLYGON_TYPES.has(type)) return 3;
return 0;
}
function supportsDrawLine(type) {
if (!type) return false;
return LINE_ONLY_OBJECT_TYPES.has(type) || DUAL_DRAW_TYPES.has(type) || type === TYPES.directional_line;
}
function supportsDrawShape(type) {
if (!type) return false;
return SHAPE_ONLY_OBJECT_TYPES.has(type) || DUAL_DRAW_TYPES.has(type);
}
function defaultDrawTool(type) {
if (type === TYPES.wall) return "drawLine";
if (supportsDrawShape(type) && !supportsDrawLine(type)) return "drawShape";
if (supportsDrawLine(type) && !supportsDrawShape(type)) return "drawLine";
return "drawShape";
}
function defaultLineWidthCm(type) {
if (type === TYPES.wall) return 15;
return 40;
}
function minLineWidthCm(type) {
if (type === TYPES.wall) return 5;
return 10;
}
function typeUsesLineWidthCm(type, geometry = null) {
if (type === TYPES.wall || type === TYPES.directional_line) return true;
return isPlannerZoneType(type) && geometry === GEOMETRY_LINE;
}
function zoneLineWidthCm(zone, mapMeta) {
if (Number.isFinite(Number(zone?.line_width_cm))) return Number(zone.line_width_cm);
if (Number.isFinite(Number(zone?.line_width))) {
return window.MapGeo?.pixelsToCm(Number(zone.line_width), mapMeta) ?? defaultLineWidthCm(zone?.type);
}
return defaultLineWidthCm(zone?.type);
}
function zoneLineWidthPx(zone, mapMeta) {
const geo = window.MapGeo;
const cm = zoneLineWidthCm(zone, mapMeta);
const px = geo?.cmToPixels(cm, mapMeta);
if (Number.isFinite(px) && px > 0) return px;
return zone?.type === TYPES.wall ? 3 : 8;
}
function readLineWidthCmFromRaw(z, mapMeta) {
if (Number.isFinite(Number(z?.line_width_cm))) return Number(z.line_width_cm);
if (Number.isFinite(Number(z?.line_width))) {
return window.MapGeo?.pixelsToCm(Number(z.line_width), mapMeta) ?? defaultLineWidthCm(z?.type);
}
return defaultLineWidthCm(z?.type);
}
function assignLineWidthCm(zone, cm, type) {
const min = minLineWidthCm(type);
zone.line_width_cm = Math.max(min, Number(cm) || defaultLineWidthCm(type));
}
function resolveZoneTypeForDraw(objectType, drawMode) {
if (objectType === TYPES.directional && drawMode === "line") return TYPES.directional_line;
return objectType;
}
function minPointsForDraw(type, drawMode) {
const zoneType = resolveZoneTypeForDraw(type, drawMode || "shape");
if (drawMode === "line" || isPolylineType(zoneType)) return 2;
return minPoints(zoneType);
}
function isLineGeometry(z) {
if (!z) return false;
if (z.type === TYPES.wall || z.type === TYPES.directional_line) return true;
return isPlannerZoneType(z.type) && z.geometry === GEOMETRY_LINE;
}
function isDraftPolyline(draft) {
if (!draft || draft.kind !== "shape") return false;
if (draft.drawMode === "line") return true;
if (draft.drawMode === "shape") return false;
return isPolylineType(draft.type);
}
function isFloorPlanType(type) {
return FLOOR_PLAN_TYPES.has(type);
}
function isPlannerZoneType(type) {
return window.MapPlannerZones?.isPlannerZoneType(type) || false;
}
function isBehaviorZoneType(type) {
return window.MapBehaviorZones?.isBehaviorZoneType(type) || false;
}
function isAdvancedZoneType(type) {
return window.MapAdvancedZones?.isAdvancedZoneType(type) || false;
}
function isDirectionalLineType(type) {
return type === TYPES.directional_line;
}
function clampSpeedMps(value) {
return window.MapBehaviorZones?.clampSpeed(value) ?? 0.8;
}
function isPolygonType(type) {
return POLYGON_TYPES.has(type);
}
function isPolylineType(type) {
return type === TYPES.wall || type === TYPES.directional_line;
}
function isPointShapeType(type) {
return POINT_SHAPE_TYPES.has(type);
}
function isOverlayObjectType(type) {
return (
type === TYPES.position ||
isPlannerZoneType(type) ||
isBehaviorZoneType(type) ||
isAdvancedZoneType(type)
);
}
function isValidPolygon(z) {
if (!z || !isPolygonType(z.type)) return false;
if (isLineGeometry(z)) return false;
const points = Array.isArray(z.points) ? z.points.map(normalizePoint).filter(Boolean) : [];
return points.length >= minPoints(z.type);
}
function isValidPlannerLineZone(z) {
if (!z || !isPlannerZoneType(z.type) || z.geometry !== GEOMETRY_LINE) return false;
const points = Array.isArray(z.points) ? z.points.map(normalizePoint).filter(Boolean) : [];
return points.length >= 2;
}
function isValidWall(z) {
if (!z || z.type !== TYPES.wall) return false;
const points = Array.isArray(z.points) ? z.points.map(normalizePoint).filter(Boolean) : [];
return points.length >= 2;
}
function isValidPosition(z) {
return (
z &&
z.type === TYPES.position &&
Number.isFinite(Number(z.x)) &&
Number.isFinite(Number(z.y)) &&
Number.isFinite(Number(z.yaw))
);
}
function isValidSpeedZone(z) {
if (!isValidPolygon(z) || z.type !== TYPES.speed) return false;
return Number.isFinite(Number(z.speed_mps));
}
function isValidSoundZone(z) {
return isValidPolygon(z) && z.type === TYPES.sound && typeof z.sound_id === "string";
}
function isValidDirectionalShape(z) {
return isValidPolygon(z) && z.type === TYPES.directional && Number.isFinite(Number(z.direction_deg));
}
function isValidDirectionalLine(z) {
if (!z || z.type !== TYPES.directional_line) return false;
const points = Array.isArray(z.points) ? z.points.map(normalizePoint).filter(Boolean) : [];
return points.length >= 2;
}
function isValidPlannerZone(z) {
return isValidPolygon(z) && z.type === TYPES.planner;
}
function isValidIoZone(z) {
return isValidPolygon(z) && z.type === TYPES.io && typeof z.io_module === "string";
}
function isValidZone(z) {
if (z?.type === TYPES.position) return isValidPosition(z);
if (z?.type === TYPES.wall) return isValidWall(z);
if (z?.type === TYPES.speed) return isValidSpeedZone(z);
if (z?.type === TYPES.sound) return isValidSoundZone(z);
if (z?.type === TYPES.directional) return isValidDirectionalShape(z);
if (z?.type === TYPES.directional_line) return isValidDirectionalLine(z);
if (z?.type === TYPES.planner) return isValidPlannerZone(z);
if (z?.type === TYPES.io) return isValidIoZone(z);
if (isPlannerZoneType(z?.type) && z.geometry === GEOMETRY_LINE) return isValidPlannerLineZone(z);
if (isPolygonType(z?.type)) return isValidPolygon(z);
return false;
}
/** Parse all map objects from API / database payload. */
function parseZones(raw, mapMeta = null) {
if (!Array.isArray(raw)) return [];
return raw
.map((z) => {
if (!z) return null;
if (z.type === TYPES.position) {
return {
id: typeof z.id === "string" && z.id ? z.id : newId(),
type: TYPES.position,
name: typeof z.name === "string" ? z.name : "",
x: Number(z.x),
y: Number(z.y),
yaw: Number(z.yaw),
};
}
if (isPointShapeType(z.type)) {
const base = {
id: typeof z.id === "string" && z.id ? z.id : newId(),
type: z.type,
points: (Array.isArray(z.points) ? z.points : []).map(normalizePoint).filter(Boolean),
};
if (z.type === TYPES.speed) {
base.speed_mps = clampSpeedMps(z.speed_mps);
}
if (z.type === TYPES.sound) {
base.sound_id = typeof z.sound_id === "string" ? z.sound_id : "";
}
if (z.type === TYPES.directional) {
base.direction_deg = window.MapAdvancedZones?.normalizeDirectionDeg(z.direction_deg) ?? 0;
}
if (z.type === TYPES.directional_line) {
base.reversed = !!z.reversed;
assignLineWidthCm(base, readLineWidthCmFromRaw(z, mapMeta), TYPES.directional_line);
}
if (z.type === TYPES.wall) {
assignLineWidthCm(base, readLineWidthCmFromRaw(z, mapMeta), TYPES.wall);
}
if (z.type === TYPES.planner) {
Object.assign(
base,
window.MapAdvancedZones?.normalizePlannerSettings(z) || {},
);
}
if (z.type === TYPES.io) {
const io = window.MapAdvancedZones?.normalizeIoSettings(z) || {};
base.io_module = io.io_module || "";
base.plc_register = io.plc_register;
base.plc_value = io.plc_value;
base.plc_mode = io.plc_mode;
}
if (isPlannerZoneType(z.type) && z.geometry === GEOMETRY_LINE) {
base.geometry = GEOMETRY_LINE;
assignLineWidthCm(base, readLineWidthCmFromRaw(z, mapMeta), z.type);
}
return base;
}
return null;
})
.filter(isValidZone);
}
function createZone(type, points, extra = {}) {
const pts = points.map(normalizePoint).filter(Boolean);
const lineGeom = isPlannerZoneType(type) && extra.geometry === GEOMETRY_LINE;
const min = lineGeom ? 2 : minPoints(type);
if (pts.length < min) return null;
const zone = { id: newId(), type, points: pts };
if (lineGeom) {
zone.geometry = GEOMETRY_LINE;
assignLineWidthCm(zone, extra.line_width_cm, type);
}
if (type === TYPES.speed) zone.speed_mps = clampSpeedMps(extra.speed_mps);
if (type === TYPES.sound) zone.sound_id = typeof extra.sound_id === "string" ? extra.sound_id : "";
if (type === TYPES.directional) {
zone.direction_deg = window.MapAdvancedZones?.normalizeDirectionDeg(extra.direction_deg) ?? 0;
}
if (type === TYPES.directional_line) {
zone.reversed = !!extra.reversed;
assignLineWidthCm(zone, extra.line_width_cm, type);
}
if (type === TYPES.wall) {
assignLineWidthCm(zone, extra.line_width_cm, type);
}
if (type === TYPES.planner) {
Object.assign(zone, window.MapAdvancedZones?.normalizePlannerSettings(extra) || {});
}
if (type === TYPES.io) {
const io = window.MapAdvancedZones?.normalizeIoSettings(extra) || {};
zone.io_module = io.io_module || "";
zone.plc_register = io.plc_register;
zone.plc_value = io.plc_value;
zone.plc_mode = io.plc_mode;
}
return zone;
}
function createPosition(worldX, worldY, yaw, name = "") {
if (!Number.isFinite(worldX) || !Number.isFinite(worldY) || !Number.isFinite(yaw)) return null;
return {
id: newId(),
type: TYPES.position,
name: name || "",
x: worldX,
y: worldY,
yaw,
};
}
function distPointToSegment(px, py, x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(px - x1, py - y1);
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
function pointInPolygon(px, py, points) {
return window.MapPlannerZones?.pointInPolygon(px, py, points) || false;
}
function dist(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function positionPixel(z, mapMeta, imgW, imgH) {
const geo = window.MapGeo;
if (!geo || !z) return null;
return geo.worldToPixel(mapMeta, imgW, imgH, z.x, z.y);
}
function hitTestPlannerZone(zones, px, py, tolerance = 8, mapMeta = null) {
for (let i = zones.length - 1; i >= 0; i--) {
const z = zones[i];
if (!isPlannerZoneType(z.type) || !z.points?.length) continue;
if (isLineGeometry(z)) {
const half = zoneLineWidthPx(z, mapMeta) / 2;
for (let j = 0; j < z.points.length - 1; j++) {
const p1 = z.points[j];
const p2 = z.points[j + 1];
if (distPointToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= half + tolerance) return z;
}
continue;
}
if (pointInPolygon(px, py, z.points)) return z;
}
return null;
}
function hitTestBehaviorZone(zones, px, py) {
for (let i = zones.length - 1; i >= 0; i--) {
const z = zones[i];
if (isBehaviorZoneType(z.type) && pointInPolygon(px, py, z.points)) return z;
}
return null;
}
function hitTestAdvancedZone(zones, px, py, tolerance = 8, mapMeta = null) {
for (let i = zones.length - 1; i >= 0; i--) {
const z = zones[i];
if (!isAdvancedZoneType(z.type)) continue;
if (z.type === TYPES.directional_line) {
for (let j = 0; j < z.points.length - 1; j++) {
const p1 = z.points[j];
const p2 = z.points[j + 1];
const width = zoneLineWidthPx(z, mapMeta);
if (distPointToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= width / 2 + tolerance) return z;
}
continue;
}
if (z.points?.length && pointInPolygon(px, py, z.points)) return z;
}
return null;
}
/** Find topmost floor-plan shape at image pixel. */
function hitTest(zones, px, py, tolerance = 8, mapMeta = null) {
for (let i = zones.length - 1; i >= 0; i--) {
const z = zones[i];
if (z.type === TYPES.floor && pointInPolygon(px, py, z.points)) return z;
if (z.type === TYPES.wall) {
const half = zoneLineWidthPx(z, mapMeta) / 2;
for (let j = 0; j < z.points.length - 1; j++) {
const p1 = z.points[j];
const p2 = z.points[j + 1];
if (distPointToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= half + tolerance) return z;
}
}
if (z.type === TYPES.directional_line) {
for (let j = 0; j < z.points.length - 1; j++) {
const p1 = z.points[j];
const p2 = z.points[j + 1];
const width = zoneLineWidthPx(z, mapMeta);
if (distPointToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= width / 2 + tolerance) return z;
}
}
}
return null;
}
function hitTestPosition(zones, px, py, mapMeta, imgW, imgH, tolerance = 14) {
for (let i = zones.length - 1; i >= 0; i--) {
const z = zones[i];
if (z.type !== TYPES.position) continue;
const pt = positionPixel(z, mapMeta, imgW, imgH);
if (pt && dist({ x: px, y: py }, pt) <= tolerance) return z;
}
return null;
}
function hitTestAny(zones, px, py, mapMeta, imgW, imgH, tolerance = 8) {
const pos = hitTestPosition(zones, px, py, mapMeta, imgW, imgH, tolerance + 4);
if (pos) return pos;
const behavior = hitTestBehaviorZone(zones, px, py);
if (behavior) return behavior;
const advanced = hitTestAdvancedZone(zones, px, py, tolerance, mapMeta);
if (advanced) return advanced;
const planner = hitTestPlannerZone(zones, px, py, tolerance, mapMeta);
if (planner) return planner;
return hitTest(zones, px, py, tolerance, mapMeta);
}
/** Hit vertex handle on selected or any point-based shape. */
function hitTestVertex(zones, px, py, selectedId, tolerance = 10) {
const tryZone = (z) => {
if (!isPointShapeType(z.type)) return null;
for (let i = 0; i < z.points.length; i++) {
if (dist({ x: px, y: py }, z.points[i]) <= tolerance) {
return { zoneId: z.id, pointIndex: i };
}
}
return null;
};
if (selectedId) {
const sel = zones.find((z) => z.id === selectedId);
const hit = sel ? tryZone(sel) : null;
if (hit) return hit;
}
for (let i = zones.length - 1; i >= 0; i--) {
const hit = tryZone(zones[i]);
if (hit) return hit;
}
return null;
}
function pointsAttr(points) {
return points.map((p) => `${p.x},${p.y}`).join(" ");
}
function clearSvg(svgEl) {
while (svgEl.firstChild) svgEl.removeChild(svgEl.firstChild);
}
function appendPolyline(parent, points, className) {
if (points.length < 2) return null;
const el = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
el.setAttribute("class", className);
el.setAttribute("points", pointsAttr(points));
el.setAttribute("fill", "none");
parent.appendChild(el);
return el;
}
function appendPolygon(parent, points, className) {
if (points.length < 3) return null;
const el = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
el.setAttribute("class", className);
el.setAttribute("points", pointsAttr(points));
parent.appendChild(el);
return el;
}
function appendLine(parent, p1, p2, className) {
const el = document.createElementNS("http://www.w3.org/2000/svg", "line");
el.setAttribute("class", className);
el.setAttribute("x1", String(p1.x));
el.setAttribute("y1", String(p1.y));
el.setAttribute("x2", String(p2.x));
el.setAttribute("y2", String(p2.y));
parent.appendChild(el);
return el;
}
function appendVertex(parent, p, className, r = 5, dataAttrs = {}) {
const el = document.createElementNS("http://www.w3.org/2000/svg", "circle");
el.setAttribute("class", className);
el.setAttribute("cx", String(p.x));
el.setAttribute("cy", String(p.y));
el.setAttribute("r", String(r));
Object.entries(dataAttrs).forEach(([k, v]) => el.setAttribute(k, v));
parent.appendChild(el);
return el;
}
function appendPositionMarker(parent, px, py, yawDeg, selected) {
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
g.setAttribute("class", selected ? "mapObjPosition mapObjPosition--selected" : "mapObjPosition");
g.setAttribute("transform", `translate(${px},${py}) rotate(${yawDeg})`);
const shaft = document.createElementNS("http://www.w3.org/2000/svg", "line");
shaft.setAttribute("class", "mapObjPositionShaft");
shaft.setAttribute("x1", "0");
shaft.setAttribute("y1", "0");
shaft.setAttribute("x2", "22");
shaft.setAttribute("y2", "0");
g.appendChild(shaft);
const head = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
head.setAttribute("class", "mapObjPositionHead");
head.setAttribute("points", "22,0 14,-5 14,5");
g.appendChild(head);
const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
dot.setAttribute("class", "mapObjPositionDot");
dot.setAttribute("cx", "0");
dot.setAttribute("cy", "0");
dot.setAttribute("r", "4");
g.appendChild(dot);
parent.appendChild(g);
return g;
}
function appendSelectionRect(parent, rect) {
if (!rect) return null;
const x = Math.min(rect.x0, rect.x1);
const y = Math.min(rect.y0, rect.y1);
const w = Math.abs(rect.x1 - rect.x0);
const h = Math.abs(rect.y1 - rect.y0);
if (w < 1 && h < 1) return null;
const el = document.createElementNS("http://www.w3.org/2000/svg", "rect");
el.setAttribute("class", "mapObjSelectionRect");
el.setAttribute("x", String(x));
el.setAttribute("y", String(y));
el.setAttribute("width", String(w));
el.setAttribute("height", String(h));
parent.appendChild(el);
return el;
}
function polygonCentroid(points) {
if (!points?.length) return null;
let x = 0;
let y = 0;
for (const p of points) {
x += p.x;
y += p.y;
}
return { x: x / points.length, y: y / points.length };
}
function lineMidpoint(points) {
if (!points?.length) return null;
if (points.length === 1) return { ...points[0] };
const p0 = points[0];
const p1 = points[points.length - 1];
return { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
}
function appendDirectionArrow(parent, cx, cy, directionDeg, selected = false) {
const adv = window.MapAdvancedZones;
const deg = adv?.normalizeDirectionDeg(directionDeg) ?? 0;
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
g.setAttribute(
"class",
selected ? "mapObjDirectionArrow mapObjDirectionArrow--selected" : "mapObjDirectionArrow",
);
g.setAttribute("transform", `translate(${cx},${cy}) rotate(${-deg})`);
const shaft = document.createElementNS("http://www.w3.org/2000/svg", "line");
shaft.setAttribute("class", "mapObjDirectionShaft");
shaft.setAttribute("x1", "-14");
shaft.setAttribute("y1", "0");
shaft.setAttribute("x2", "14");
shaft.setAttribute("y2", "0");
g.appendChild(shaft);
const head = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
head.setAttribute("class", "mapObjDirectionHead");
head.setAttribute("points", "14,0 6,-5 6,5");
g.appendChild(head);
parent.appendChild(g);
return g;
}
function appendPolylineStyled(parent, points, className, strokeWidth = null) {
const el = appendPolyline(parent, points, className);
if (el && strokeWidth != null) el.setAttribute("stroke-width", String(strokeWidth));
return el;
}
function polygonClass(type, selected, draft = false) {
const base = {
[TYPES.floor]: "mapObjFloor",
[TYPES.forbidden]: "mapObjForbidden",
[TYPES.preferred]: "mapObjPreferred",
[TYPES.unpreferred]: "mapObjUnpreferred",
[TYPES.speed]: "mapObjSpeed",
[TYPES.sound]: "mapObjSound",
[TYPES.directional]: "mapObjDirectional",
[TYPES.directional_line]: "mapObjDirectional",
[TYPES.planner]: "mapObjPlannerSettings",
[TYPES.io]: "mapObjIo",
}[type];
if (!base) return "";
if (draft) return `${base} ${base}--draft`;
if (selected) return `${base} ${base}--selected`;
return base;
}
function zonePolygonClass(type, selected, draft = false) {
return polygonClass(type === TYPES.directional_line ? TYPES.directional : type, selected, draft);
}
function filterVisible(zones, visibility) {
const vis = visibility || {};
return zones.filter((z) => {
if (z.type === TYPES.wall) return vis.walls !== false;
if (z.type === TYPES.floor) return vis.floors !== false;
if (z.type === TYPES.position) return vis.positions !== false;
if (z.type === TYPES.forbidden) return vis.forbidden !== false;
if (z.type === TYPES.preferred) return vis.preferred !== false;
if (z.type === TYPES.unpreferred) return vis.unpreferred !== false;
if (z.type === TYPES.speed) return vis.speed !== false;
if (z.type === TYPES.sound) return vis.sound !== false;
if (z.type === TYPES.directional || z.type === TYPES.directional_line) {
return vis.directional !== false;
}
if (z.type === TYPES.planner) return vis.planner !== false;
if (z.type === TYPES.io) return vis.io !== false;
return true;
});
}
/**
* Render map objects on SVG overlay.
* @param {SVGSVGElement} svgEl
* @param {object[]} zones
* @param {object} opts
*/
function render(svgEl, zones, opts = {}) {
if (!svgEl) return;
clearSvg(svgEl);
const mapMeta = opts.mapMeta || {};
const imgW = opts.imageWidth || 0;
const imgH = opts.imageHeight || 0;
const list = filterVisible(zones, opts.visibility);
list.forEach((z) => {
const selected = z.id === opts.selectedId;
if (z.type === TYPES.wall) {
appendPolylineStyled(
svgEl,
z.points,
selected ? "mapObjWall mapObjWall--selected" : "mapObjWall",
zoneLineWidthPx(z, mapMeta),
);
} else if (z.type === TYPES.directional_line) {
const cls = zonePolygonClass(TYPES.directional_line, selected);
appendPolylineStyled(svgEl, z.points, cls, zoneLineWidthPx(z, mapMeta));
const mid = lineMidpoint(z.points);
if (mid) {
appendDirectionArrow(
svgEl,
mid.x,
mid.y,
window.MapAdvancedZones?.zoneDirectionDeg(z) ?? 0,
selected,
);
}
} else if (isPlannerZoneType(z.type) && isLineGeometry(z)) {
appendPolylineStyled(
svgEl,
z.points,
zonePolygonClass(z.type, selected),
zoneLineWidthPx(z, mapMeta),
);
} else if (isPolygonType(z.type)) {
appendPolygon(svgEl, z.points, zonePolygonClass(z.type, selected));
if (z.type === TYPES.directional) {
const c = polygonCentroid(z.points);
if (c) appendDirectionArrow(svgEl, c.x, c.y, z.direction_deg ?? 0, selected);
}
} else if (z.type === TYPES.position) {
const pt = positionPixel(z, mapMeta, imgW, imgH);
if (pt) {
const yawDeg = (-Number(z.yaw) * 180) / Math.PI;
appendPositionMarker(svgEl, pt.x, pt.y, yawDeg, selected);
}
}
});
if (opts.selectedId && opts.showVertices !== false) {
const sel = list.find((z) => z.id === opts.selectedId);
if (sel && isPointShapeType(sel.type)) {
sel.points.forEach((p, i) => {
appendVertex(svgEl, p, "mapObjVertex mapObjVertex--handle", 6, {
"data-vertex-index": String(i),
});
});
}
}
const draft = opts.draft;
if (draft?.kind === "shape" && draft.type && Array.isArray(draft.points) && draft.points.length) {
const pts = draft.points;
const hover = draft.hover;
if (isDraftPolyline(draft)) {
const draftZone = { type: draft.type, line_width_cm: draft.line_width_cm };
const strokeW = zoneLineWidthPx(draftZone, mapMeta);
let draftCls = "mapObjWall mapObjWall--draft";
const zoneType = resolveZoneTypeForDraw(draft.type, draft.drawMode || "line");
if (zoneType === TYPES.directional_line || isPlannerZoneType(draft.type)) {
draftCls = zonePolygonClass(
zoneType === TYPES.directional_line ? TYPES.directional_line : draft.type,
false,
true,
);
}
appendPolylineStyled(svgEl, pts, draftCls, strokeW);
if (hover && pts.length) appendLine(svgEl, pts[pts.length - 1], hover, "mapObjDraftLine");
} else if (isPolygonType(draft.type)) {
if (pts.length >= 3) appendPolygon(svgEl, pts, zonePolygonClass(draft.type, false, true));
else if (pts.length === 2) appendPolyline(svgEl, pts, "mapObjDraftLine");
if (hover && pts.length) appendLine(svgEl, pts[pts.length - 1], hover, "mapObjDraftLine");
appendVertex(svgEl, pts[0], "mapObjCloseHint", 6);
}
pts.forEach((p) => appendVertex(svgEl, p, "mapObjVertex"));
}
if (draft?.kind === "position" && draft.px != null && draft.py != null) {
const yawDeg = (-Number(draft.yaw || 0) * 180) / Math.PI;
appendPositionMarker(svgEl, draft.px, draft.py, yawDeg, true);
}
appendSelectionRect(svgEl, opts.selectionRect);
}
function constrainAxis(from, to) {
const dx = Math.abs(to.x - from.x);
const dy = Math.abs(to.y - from.y);
if (dx >= dy) return { x: to.x, y: from.y };
return { x: from.x, y: to.y };
}
function nearPoint(a, b, tolerance = 12) {
return dist(a, b) <= tolerance;
}
function yawFromPoints(origin, target) {
return Math.atan2(-(target.y - origin.y), target.x - origin.x);
}
window.MapObjects = {
TYPES,
FLOOR_PLAN_TYPES,
POLYGON_TYPES,
POINT_SHAPE_TYPES,
newId,
parseZones,
createZone,
createPosition,
minPoints,
minPointsForDraw,
supportsDrawLine,
supportsDrawShape,
defaultDrawTool,
defaultLineWidthCm,
minLineWidthCm,
zoneLineWidthCm,
zoneLineWidthPx,
typeUsesLineWidthCm,
resolveZoneTypeForDraw,
isLineGeometry,
isDraftPolyline,
GEOMETRY_LINE,
isFloorPlanType,
isPlannerZoneType,
isBehaviorZoneType,
isAdvancedZoneType,
isDirectionalLineType,
clampSpeedMps,
isPolygonType,
isPolylineType,
isPointShapeType,
isOverlayObjectType,
isValidZone,
hitTest,
hitTestPlannerZone,
hitTestBehaviorZone,
hitTestAdvancedZone,
hitTestPosition,
hitTestAny,
hitTestVertex,
positionPixel,
render,
filterVisible,
constrainAxis,
nearPoint,
yawFromPoints,
};
})();

178
www/map-occupancy-canvas.js Normal file
View File

@@ -0,0 +1,178 @@
(() => {
/**
* RViz-style occupancy grid renderer for map view (Canvas 2D).
*
* ROS map_server / nav_msgs/OccupancyGrid:
* 0 = free
* 100 = occupied
* -1 = unknown
*
* Grid data is row-major with index 0 at the bottom-left cell (world +Y up).
* Canvas pixels use top-left origin — Y is flipped when painting.
*/
const DEFAULT_PALETTE = {
free: [254, 254, 254, 255],
occupied: [0, 0, 0, 255],
unknown: [180, 180, 180, 255],
};
const DEFAULT_THRESHOLDS = {
occupied_thresh: 0.65,
free_thresh: 0.196,
negate: 0,
};
function metaThresholds(meta) {
return {
occupied_thresh: Number(meta?.occupied_thresh ?? DEFAULT_THRESHOLDS.occupied_thresh),
free_thresh: Number(meta?.free_thresh ?? DEFAULT_THRESHOLDS.free_thresh),
negate: Number(meta?.negate ?? DEFAULT_THRESHOLDS.negate),
};
}
/** Trinary mode (Nav2/map_server): darkness or lightness vs thresholds in [0, 1]. */
function grayToOccValue(gray, meta) {
const { occupied_thresh, free_thresh, negate } = metaThresholds(meta);
const lightness = gray / 255;
const probability = negate ? lightness : 1 - lightness;
if (probability > occupied_thresh) return 100;
if (probability < free_thresh) return 0;
return -1;
}
function occToRgba(value, palette) {
if (value < 0) return palette.unknown;
if (value >= 100) return palette.occupied;
if (value === 0) return palette.free;
return value > 50 ? palette.occupied : palette.free;
}
function paletteFrom(opts) {
const p = opts?.palette || {};
return {
free: p.free || DEFAULT_PALETTE.free,
occupied: p.occupied || DEFAULT_PALETTE.occupied,
unknown: p.unknown || DEFAULT_PALETTE.unknown,
};
}
function ensureCanvasSize(canvas, width, height) {
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function decodeGridData(data) {
if (data instanceof Int8Array || data instanceof Uint8Array) return data;
if (Array.isArray(data)) return data;
if (typeof data === "string" && data.length) {
try {
const binary = atob(data);
const out = new Int8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
} catch {
return [];
}
}
return [];
}
/**
* Paint ROS occupancy grid onto canvas.
* @param {HTMLCanvasElement} canvas
* @param {{ width: number, height: number, data: number[]|Int8Array|string }} grid
* @param {{ palette?: object }} [opts]
*/
function renderGrid(canvas, grid, opts = {}) {
if (!canvas || !grid) return false;
const width = Number(grid.width) | 0;
const height = Number(grid.height) | 0;
if (!width || !height) return false;
const data = decodeGridData(grid.data);
if (!data.length) return false;
ensureCanvasSize(canvas, width, height);
const ctx = canvas.getContext("2d");
if (!ctx) return false;
const palette = paletteFrom(opts);
const imageData = ctx.createImageData(width, height);
const px = imageData.data;
for (let row = 0; row < height; row++) {
const srcRow = height - 1 - row;
for (let col = 0; col < width; col++) {
const idx = srcRow * width + col;
const value = idx < data.length ? data[idx] : -1;
const rgba = occToRgba(value, palette);
const dst = (row * width + col) * 4;
px[dst] = rgba[0];
px[dst + 1] = rgba[1];
px[dst + 2] = rgba[2];
px[dst + 3] = rgba[3];
}
}
ctx.putImageData(imageData, 0, 0);
return true;
}
/**
* Convert map_server grayscale image to RViz-style occupancy colors.
* @param {HTMLCanvasElement} canvas
* @param {CanvasImageSource} source
* @param {object} [meta] — negate, occupied_thresh, free_thresh
* @param {{ palette?: object }} [opts]
*/
function renderFromImage(canvas, source, meta = {}, opts = {}) {
if (!canvas || !source) return false;
const width = source.naturalWidth || source.videoWidth || source.width;
const height = source.naturalHeight || source.videoHeight || source.height;
if (!width || !height) return false;
ensureCanvasSize(canvas, width, height);
const ctx = canvas.getContext("2d");
if (!ctx) return false;
const scratch = document.createElement("canvas");
scratch.width = width;
scratch.height = height;
const sctx = scratch.getContext("2d");
if (!sctx) return false;
sctx.drawImage(source, 0, 0, width, height);
const sampled = sctx.getImageData(0, 0, width, height);
const src = sampled.data;
const palette = paletteFrom(opts);
const imageData = ctx.createImageData(width, height);
const dst = imageData.data;
const thresholds = metaThresholds(meta);
for (let i = 0, p = 0; p < width * height; p++, i += 4) {
const gray = src[i];
const occ = grayToOccValue(gray, thresholds);
const rgba = occToRgba(occ, palette);
dst[i] = rgba[0];
dst[i + 1] = rgba[1];
dst[i + 2] = rgba[2];
dst[i + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
return true;
}
window.MapOccupancyCanvas = {
DEFAULT_PALETTE,
DEFAULT_THRESHOLDS,
grayToOccValue,
occToRgba,
decodeGridData,
renderGrid,
renderFromImage,
};
})();

241
www/map-occupancy-edit.js Normal file
View File

@@ -0,0 +1,241 @@
(() => {
/**
* map_server grayscale editing — bake walls/floors and brush-erase on floor-plan raster.
* Values match ROS map_server convention (see map-occupancy-canvas.js).
*/
const GRAY = {
free: 254,
occupied: 0,
unknown: 205,
};
const DEFAULT_LINE_WIDTH = 3;
const DEFAULT_BRUSH_RADIUS = 10;
function ensureCanvas(canvas, width, height) {
if (!canvas) return null;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
return canvas.getContext("2d");
}
function copyCanvas(src, dst) {
if (!src?.width || !dst) return false;
const ctx = ensureCanvas(dst, src.width, src.height);
if (!ctx) return false;
ctx.drawImage(src, 0, 0);
return true;
}
/**
* Composite = base scan + floor polygons (free) + wall polylines (occupied).
* @param {HTMLCanvasElement} baseCanvas
* @param {HTMLCanvasElement} outCanvas
* @param {object[]} zones
*/
function rebakeComposite(baseCanvas, outCanvas, zones, mapMeta = null) {
if (!baseCanvas?.width || !outCanvas) return false;
copyCanvas(baseCanvas, outCanvas);
const list = Array.isArray(zones) ? zones : [];
const linePx = (z) => window.MapObjects?.zoneLineWidthPx(z, mapMeta) ?? DEFAULT_LINE_WIDTH;
list.filter((z) => z?.type === "floor").forEach((z) => bakeFloor(outCanvas, z.points));
list.filter((z) => z?.type === "wall").forEach((z) => bakeWall(outCanvas, z.points, linePx(z)));
return true;
}
/** Load PNG pixels into editable grayscale source canvas. */
function initSourceFromImage(sourceCanvas, imageEl) {
if (!sourceCanvas || !imageEl?.naturalWidth) return false;
const w = imageEl.naturalWidth;
const h = imageEl.naturalHeight;
const ctx = ensureCanvas(sourceCanvas, w, h);
if (!ctx) return false;
ctx.drawImage(imageEl, 0, 0, w, h);
return true;
}
function cloneRaster(sourceCanvas) {
if (!sourceCanvas?.width) return null;
const ctx = sourceCanvas.getContext("2d");
if (!ctx) return null;
return ctx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height);
}
function restoreRaster(sourceCanvas, imageData) {
if (!sourceCanvas || !imageData) return false;
const ctx = ensureCanvas(sourceCanvas, imageData.width, imageData.height);
if (!ctx) return false;
ctx.putImageData(imageData, 0, 0);
return true;
}
function grayRgb(gray) {
const g = Math.max(0, Math.min(255, gray | 0));
return `rgb(${g},${g},${g})`;
}
/** Paint display canvas from grayscale source using map_server thresholds. */
function renderDisplayFromSource(displayCanvas, sourceCanvas, meta, occModule) {
const occ = occModule || window.MapOccupancyCanvas;
if (!occ?.renderFromImage || !displayCanvas || !sourceCanvas?.width) return false;
return occ.renderFromImage(displayCanvas, sourceCanvas, meta || {});
}
function strokePolyline(ctx, points, gray, lineWidth = DEFAULT_LINE_WIDTH) {
if (!ctx || !points || points.length < 2) return;
ctx.save();
ctx.strokeStyle = grayRgb(gray);
ctx.lineWidth = lineWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
points.forEach((p, i) => {
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
});
ctx.stroke();
ctx.restore();
}
function fillPolygon(ctx, points, gray) {
if (!ctx || !points || points.length < 3) return;
ctx.save();
ctx.fillStyle = grayRgb(gray);
ctx.beginPath();
points.forEach((p, i) => {
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
});
ctx.closePath();
ctx.fill();
ctx.restore();
}
/** Bake wall polyline as occupied pixels. */
function bakeWall(sourceCanvas, points, lineWidth = DEFAULT_LINE_WIDTH) {
const ctx = sourceCanvas?.getContext("2d");
if (!ctx) return false;
strokePolyline(ctx, points, GRAY.occupied, lineWidth);
return true;
}
/** Bake floor polygon as free walkable pixels. */
function bakeFloor(sourceCanvas, points) {
const ctx = sourceCanvas?.getContext("2d");
if (!ctx) return false;
fillPolygon(ctx, points, GRAY.free);
return true;
}
/** Remove baked wall by painting free along its path. */
function unbakeWall(sourceCanvas, points, lineWidth = DEFAULT_LINE_WIDTH + 2) {
const ctx = sourceCanvas?.getContext("2d");
if (!ctx) return false;
strokePolyline(ctx, points, GRAY.free, lineWidth);
return true;
}
function bakeZone(sourceCanvas, zone, mapMeta = null) {
if (!zone?.points?.length) return false;
if (zone.type === "wall") {
const px = window.MapObjects?.zoneLineWidthPx(zone, mapMeta) ?? DEFAULT_LINE_WIDTH;
return bakeWall(sourceCanvas, zone.points, px);
}
if (zone.type === "floor") return bakeFloor(sourceCanvas, zone.points);
return false;
}
function unbakeZone(sourceCanvas, zone, mapMeta = null) {
if (!zone?.points?.length) return false;
if (zone.type === "wall") {
const px = window.MapObjects?.zoneLineWidthPx(zone, mapMeta) ?? DEFAULT_LINE_WIDTH;
return unbakeWall(sourceCanvas, zone.points, px + 2);
}
if (zone.type === "floor") {
const ctx = sourceCanvas?.getContext("2d");
if (!ctx) return false;
fillPolygon(ctx, zone.points, GRAY.unknown);
return true;
}
return false;
}
function paintBrush(sourceCanvas, x, y, layer, radius = DEFAULT_BRUSH_RADIUS) {
const ctx = sourceCanvas?.getContext("2d");
if (!ctx) return false;
const gray = layer === "floor" ? GRAY.unknown : GRAY.free;
ctx.save();
ctx.fillStyle = grayRgb(gray);
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
return true;
}
/** Erase pixels inside axis-aligned rectangle on base layer. */
function eraseRect(sourceCanvas, x0, y0, x1, y1, layer) {
const ctx = sourceCanvas?.getContext("2d");
if (!ctx) return false;
const gray = layer === "floor" ? GRAY.unknown : GRAY.free;
const x = Math.min(x0, x1);
const y = Math.min(y0, y1);
const w = Math.abs(x1 - x0);
const h = Math.abs(y1 - y0);
if (w < 1 || h < 1) return false;
ctx.save();
ctx.fillStyle = grayRgb(gray);
ctx.fillRect(x, y, w, h);
ctx.restore();
return true;
}
/** Interpolated brush stroke between two image-space points. */
function paintBrushStroke(sourceCanvas, x0, y0, x1, y1, layer, radius = DEFAULT_BRUSH_RADIUS) {
const dist = Math.hypot(x1 - x0, y1 - y0);
const step = Math.max(1, radius * 0.5);
const n = Math.max(1, Math.ceil(dist / step));
for (let i = 0; i <= n; i++) {
const t = i / n;
paintBrush(sourceCanvas, x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, layer, radius);
}
}
function exportPngBlob(sourceCanvas) {
return new Promise((resolve, reject) => {
if (!sourceCanvas?.width) {
reject(new Error("no source canvas"));
return;
}
sourceCanvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error("export failed"));
},
"image/png",
);
});
}
window.MapOccupancyEdit = {
GRAY,
DEFAULT_LINE_WIDTH,
DEFAULT_BRUSH_RADIUS,
initSourceFromImage,
cloneRaster,
restoreRaster,
copyCanvas,
rebakeComposite,
renderDisplayFromSource,
bakeWall,
bakeFloor,
bakeZone,
unbakeZone,
paintBrush,
paintBrushStroke,
eraseRect,
exportPngBlob,
};
})();

133
www/map-planner-zones.js Normal file
View File

@@ -0,0 +1,133 @@
(() => {
/**
* Planner hooks for Forbidden / Preferred / Unpreferred drive zones (MiR §4.2.6.6).
* Vector overlay only — consumed by future global planner / path preview.
*/
const TYPES = {
forbidden: "forbidden",
preferred: "preferred",
unpreferred: "unpreferred",
};
const PLANNER_TYPES = new Set([TYPES.forbidden, TYPES.preferred, TYPES.unpreferred]);
/** Relative traversal cost (forbidden = impassable). */
const COST = {
forbidden: Infinity,
unpreferred: 4,
preferred: 0.35,
neutral: 1,
};
function pointInPolygon(px, py, points) {
if (!points?.length) return false;
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i].x;
const yi = points[i].y;
const xj = points[j].x;
const yj = points[j].y;
const intersect = yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
function distPointToSegment(px, py, x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(px - x1, py - y1);
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
function pointNearPolyline(px, py, points, halfWidth, tolerance = 4) {
if (!points?.length || points.length < 2) return false;
const limit = halfWidth + tolerance;
for (let j = 0; j < points.length - 1; j++) {
const p1 = points[j];
const p2 = points[j + 1];
if (distPointToSegment(px, py, p1.x, p1.y, p2.x, p2.y) <= limit) return true;
}
return false;
}
function isLineGeometry(z) {
return z?.geometry === "line";
}
function isPlannerZoneType(type) {
return PLANNER_TYPES.has(type);
}
function filterPlannerZones(zones) {
return (Array.isArray(zones) ? zones : []).filter((z) => isPlannerZoneType(z?.type));
}
/** Topmost planner zones containing image pixel (newest wins for overlaps). */
function zonesAtPoint(zones, px, py, mapMeta = null) {
const list = Array.isArray(zones) ? zones : [];
const hits = [];
const linePx = (z) => window.MapObjects?.zoneLineWidthPx(z, mapMeta) ?? 8;
for (let i = list.length - 1; i >= 0; i--) {
const z = list[i];
if (!isPlannerZoneType(z?.type) || !z.points?.length) continue;
if (isLineGeometry(z)) {
const half = linePx(z) / 2;
if (pointNearPolyline(px, py, z.points, half)) hits.push(z);
} else if (pointInPolygon(px, py, z.points)) hits.push(z);
}
return hits;
}
function pixelCost(zones, px, py, mapMeta = null) {
const hits = zonesAtPoint(zones, px, py, mapMeta);
if (hits.some((z) => z.type === TYPES.forbidden)) return COST.forbidden;
if (hits.some((z) => z.type === TYPES.unpreferred)) return COST.unpreferred;
if (hits.some((z) => z.type === TYPES.preferred)) return COST.preferred;
return COST.neutral;
}
function classifyPoint(zones, px, py, mapMeta = null) {
const hits = zonesAtPoint(zones, px, py, mapMeta);
return {
forbidden: hits.some((z) => z.type === TYPES.forbidden),
preferred: hits.some((z) => z.type === TYPES.preferred),
unpreferred: hits.some((z) => z.type === TYPES.unpreferred),
zones: hits,
cost: pixelCost(zones, px, py, mapMeta),
};
}
function isPathBlocked(zones, pathPoints, mapMeta = null) {
const pts = Array.isArray(pathPoints) ? pathPoints : [];
return pts.some((p) => pixelCost(zones, p.x, p.y, mapMeta) === COST.forbidden);
}
function pathCost(zones, pathPoints, mapMeta = null) {
const pts = Array.isArray(pathPoints) ? pathPoints : [];
let sum = 0;
for (const p of pts) {
const c = pixelCost(zones, p.x, p.y, mapMeta);
if (!Number.isFinite(c)) return Infinity;
sum += c;
}
return sum;
}
window.MapPlannerZones = {
TYPES,
PLANNER_TYPES,
COST,
isPlannerZoneType,
filterPlannerZones,
zonesAtPoint,
pixelCost,
classifyPoint,
isPathBlocked,
pathCost,
pointInPolygon,
};
})();

104
www/map-yaml.js Normal file
View File

@@ -0,0 +1,104 @@
(() => {
function stripComment(line) {
const pos = line.indexOf("#");
return pos === -1 ? line : line.slice(0, pos);
}
function trim(s) {
return String(s || "").trim();
}
function parseOriginArray(value) {
const start = value.indexOf("[");
const end = value.indexOf("]");
if (start === -1 || end === -1 || end <= start) return null;
const parts = value
.slice(start + 1, end)
.split(",")
.map((p) => Number(trim(p)))
.filter((n) => !Number.isNaN(n));
if (parts.length < 2) return null;
return { origin_x: parts[0], origin_y: parts[1], origin_yaw: parts[2] || 0 };
}
function parseNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function parseIntField(value) {
const n = parseInt(value, 10);
return Number.isFinite(n) ? n : null;
}
/**
* Parse ROS map_server yaml (resolution, origin, thresholds).
* @param {string} text
* @param {{ requireImage?: boolean }} opts
*/
function parse(text, opts = {}) {
const requireImage = !!opts.requireImage;
const out = {
image: "",
resolution: null,
origin_x: 0,
origin_y: 0,
origin_yaw: 0,
negate: 0,
occupied_thresh: 0.65,
free_thresh: 0.196,
};
for (const rawLine of String(text || "").split(/\r?\n/)) {
const line = trim(stripComment(rawLine));
if (!line) continue;
const colon = line.indexOf(":");
if (colon === -1) continue;
const key = trim(line.slice(0, colon));
const value = trim(line.slice(colon + 1));
if (!value && key !== "image") continue;
if (key === "image") out.image = value;
else if (key === "resolution") out.resolution = parseNumber(value);
else if (key === "origin") {
const origin = parseOriginArray(value);
if (origin) Object.assign(out, origin);
} else if (key === "negate") {
const n = parseIntField(value);
if (n != null) out.negate = n;
} else if (key === "occupied_thresh") {
const n = parseNumber(value);
if (n != null) out.occupied_thresh = n;
} else if (key === "free_thresh") {
const n = parseNumber(value);
if (n != null) out.free_thresh = n;
}
}
if (requireImage && !out.image) return { error: "yaml missing image field" };
if (out.resolution == null || out.resolution <= 0) return { error: "yaml missing resolution field" };
return out;
}
function serialize(meta) {
const image = meta.image || "map.png";
const resolution = meta.resolution != null ? meta.resolution : 0.05;
const ox = meta.origin_x != null ? meta.origin_x : 0;
const oy = meta.origin_y != null ? meta.origin_y : 0;
const oyaw = meta.origin_yaw != null ? meta.origin_yaw : 0;
const negate = meta.negate != null ? meta.negate : 0;
const occ = meta.occupied_thresh != null ? meta.occupied_thresh : 0.65;
const free = meta.free_thresh != null ? meta.free_thresh : 0.196;
return [
`image: ${image}`,
`resolution: ${Number(resolution).toFixed(6)}`,
`origin: [${Number(ox).toFixed(6)}, ${Number(oy).toFixed(6)}, ${Number(oyaw).toFixed(6)}]`,
`negate: ${negate}`,
`occupied_thresh: ${Number(occ).toFixed(3)}`,
`free_thresh: ${Number(free).toFixed(3)}`,
"",
].join("\n");
}
window.MapYaml = { parse, serialize };
})();

641
www/maps.js Normal file
View File

@@ -0,0 +1,641 @@
(() => {
const PAGE_SIZE = 10;
const ICONS = {
map: `<svg class="mapsMirMapIcon" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><rect x="2" y="2" width="14" height="14" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M2 6h14M6 2v14M12 2v14" stroke="currentColor" stroke-width=".8" opacity=".5"/></svg>`,
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
view: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M1 7s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="7" cy="7" r="1.8" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>`,
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 4h8M5 4V2.5h4V4M5.5 6v4M8.5 6v4M4.5 4l.5 7.5h4L9.5 4" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
active: `<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 5l2.2 2.2L8 3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listViewEl = el("mapsListView");
const createViewEl = el("mapsCreateView");
const editorViewEl = el("mapEditorView");
const listEl = el("mapsList");
const listEmptyEl = el("mapsListEmpty");
const tableEl = el("mapsTable");
const activeHintEl = el("mapsActiveHint");
const filterInputEl = el("mapsFilterInput");
const filterCountEl = el("mapsFilterCount");
const pageLabelEl = el("mapsPageLabel");
const sitesDialogEl = el("mapsSitesDialog");
const sitesListEl = el("mapsSitesList");
const siteFormDialogEl = el("mapsSiteFormDialog");
const deleteDialogEl = el("mapsDeleteDialog");
const createSiteSelectEl = el("mapsCreateSite");
let deleteDialogResolve = null;
const SITE_ICONS = {
chevron: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 3l3 4-3 4M8 3l3 4-3 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
};
const store = {
maps: [],
sites: [],
activeMapId: null,
filter: "",
page: 1,
editingSiteId: null,
sitesDialogSelectedId: null,
sitesDialogSnapshotId: null,
};
function canWrite() {
return window.AuthApp?.canWrite?.("maps") ?? true;
}
function currentUser() {
return window.AuthApp?.getUser?.() || null;
}
function canDeleteMap(map) {
if (!canWrite() || !map) return false;
const user = currentUser();
if (!user) return true;
const mapGroup = map.created_by_group;
if (mapGroup) return mapGroup === user.group_id;
return true;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function api(path, opts = {}) {
const res = await fetch(path, { credentials: "include", ...opts });
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function findMap(id) {
return store.maps.find((m) => m.id === id) || null;
}
function findSite(id) {
return store.sites.find((s) => s.id === id) || null;
}
function siteName(siteId) {
return findSite(siteId)?.name || siteId || "—";
}
function mapImageUrl(map) {
if (!map?.id || !map.image_file) return null;
return `/api/maps/${encodeURIComponent(map.id)}/image?t=${encodeURIComponent(map.updated_at || "")}`;
}
function filteredMaps() {
const q = store.filter.trim().toLowerCase();
let items = [...store.maps].sort((a, b) => {
const sa = siteName(a.site_id).localeCompare(siteName(b.site_id));
if (sa !== 0) return sa;
return (a.name || "").localeCompare(b.name || "");
});
if (q) {
items = items.filter((m) => {
const name = (m.name || "").toLowerCase();
const site = siteName(m.site_id).toLowerCase();
return name.includes(q) || site.includes(q);
});
}
return items;
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function pagedMaps(items) {
const totalPages = pageCount(items.length);
if (store.page > totalPages) store.page = totalPages;
if (store.page < 1) store.page = 1;
const start = (store.page - 1) * PAGE_SIZE;
return items.slice(start, start + PAGE_SIZE);
}
function updatePagerUi(totalItems) {
const totalPages = pageCount(totalItems);
if (filterCountEl) {
filterCountEl.textContent = t("maps.itemsFound", { n: totalItems });
}
if (pageLabelEl) {
pageLabelEl.textContent = t("maps.pageOf", { page: store.page, total: totalPages });
}
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("mapsPageFirst")?.toggleAttribute("disabled", atStart);
el("mapsPagePrev")?.toggleAttribute("disabled", atStart);
el("mapsPageNext")?.toggleAttribute("disabled", atEnd);
el("mapsPageLast")?.toggleAttribute("disabled", atEnd);
}
async function loadSites() {
const data = await api("/api/sites");
store.sites = Array.isArray(data.sites) ? data.sites : [];
}
async function loadMaps() {
const data = await api("/api/maps");
store.maps = Array.isArray(data.maps) ? data.maps : [];
}
async function loadActiveMap() {
try {
const status = await api("/api/robot/status");
store.activeMapId = status.active_map_id || null;
} catch {
store.activeMapId = null;
}
}
function renderActiveHint() {
if (!activeHintEl) return;
const active = findMap(store.activeMapId);
if (active) {
activeHintEl.hidden = false;
activeHintEl.textContent = t("maps.activeHint", { name: active.name });
} else {
activeHintEl.hidden = true;
activeHintEl.textContent = "";
}
}
function renderSiteSelect(selectedId) {
if (!createSiteSelectEl) return;
createSiteSelectEl.innerHTML = "";
store.sites.forEach((site) => {
const opt = document.createElement("option");
opt.value = site.id;
opt.textContent = site.name || site.id;
createSiteSelectEl.appendChild(opt);
});
if (selectedId) createSiteSelectEl.value = selectedId;
else if (store.sites[0]) createSiteSelectEl.value = store.sites[0].id;
}
function renderList() {
if (!listEl) return;
const items = filteredMaps();
const pageItems = pagedMaps(items);
updatePagerUi(items.length);
listEl.innerHTML = "";
const showEmpty = items.length === 0;
if (tableEl) tableEl.hidden = showEmpty;
if (listEmptyEl) {
listEmptyEl.hidden = !showEmpty;
listEmptyEl.textContent = store.filter.trim() ? t("maps.emptyFilter") : t("maps.empty");
}
let lastSiteId = null;
pageItems.forEach((map) => {
const siteId = map.site_id || "";
if (siteId !== lastSiteId) {
lastSiteId = siteId;
const siteTr = document.createElement("tr");
siteTr.className = "mapsMirSiteRow";
siteTr.innerHTML = `<td colspan="3">${escapeHtml(siteName(siteId))}</td>`;
listEl.appendChild(siteTr);
}
const isActive = map.id === store.activeMapId;
const tr = document.createElement("tr");
tr.className = "mapsMirRow";
tr.dataset.mapId = map.id;
const activeBadge = isActive
? `<span class="mapsActiveBadge">${ICONS.active}<span>${escapeHtml(t("maps.activeBadge"))}</span></span>`
: "";
const actions = canWrite()
? `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn" data-edit="${map.id}" data-i18n-title="common.edit" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
<button type="button" class="mapsMirIconBtn" data-view="${map.id}" data-i18n-title="maps.view" title="${escapeHtml(t("maps.view"))}">${ICONS.view}</button>
${canDeleteMap(map) ? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${map.id}" data-i18n-title="common.delete" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>` : ""}
</div>`
: `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn" data-view="${map.id}" data-i18n-title="maps.view" title="${escapeHtml(t("maps.view"))}">${ICONS.view}</button>
</div>`;
tr.innerHTML = `
<td class="mapsMirCellName">
<div class="mapsMirNameCell">
${ICONS.map}
<button type="button" class="mapsMirNameLink" data-open="${map.id}">${escapeHtml(map.name || map.id)}</button>
${activeBadge}
</div>
</td>
<td class="mapsMirCellCreatedBy">${escapeHtml(map.created_by || "—")}</td>
<td class="mapsMirCellActions">${actions}</td>`;
tr.querySelector("[data-open]")?.addEventListener("click", () => openEditor(map.id));
tr.querySelector("[data-edit]")?.addEventListener("click", () => openEditor(map.id));
tr.querySelector("[data-view]")?.addEventListener("click", () => openEditor(map.id, { readOnly: !canWrite() }));
tr.querySelector("[data-delete]")?.addEventListener("click", () => {
void deleteMapFromList(map.id);
});
tr.addEventListener("dblclick", () => openEditor(map.id));
listEl.appendChild(tr);
});
renderActiveHint();
}
function hideAllViews() {
[listViewEl, createViewEl, editorViewEl].forEach((view) => {
if (!view) return;
view.hidden = true;
view.setAttribute("aria-hidden", "true");
});
}
function showList() {
hideAllViews();
if (listViewEl) {
listViewEl.hidden = false;
listViewEl.removeAttribute("aria-hidden");
}
window.MapEditorApp?.close?.();
}
function showCreate() {
if (!canWrite()) return;
hideAllViews();
renderSiteSelect();
const nameEl = el("mapsCreateName");
if (nameEl) nameEl.value = "";
if (createViewEl) {
createViewEl.hidden = false;
createViewEl.removeAttribute("aria-hidden");
}
nameEl?.focus();
}
function openEditor(mapId, opts = {}) {
const map = findMap(mapId);
if (!map) return;
hideAllViews();
if (editorViewEl) {
editorViewEl.hidden = false;
editorViewEl.removeAttribute("aria-hidden");
}
window.MapEditorApp?.open?.(mapId, {
readOnly: opts.readOnly,
onMapUpdated: (updated) => {
const idx = store.maps.findIndex((m) => m.id === updated.id);
if (idx >= 0) store.maps[idx] = updated;
else store.maps.push(updated);
},
onMapDeleted: (id) => {
store.maps = store.maps.filter((m) => m.id !== id);
if (store.activeMapId === id) store.activeMapId = null;
showList();
renderList();
},
onActivated: (id) => {
store.activeMapId = id;
renderList();
},
onClose: () => {
showList();
renderList();
},
getSiteName: siteName,
getActiveMapId: () => store.activeMapId,
canWrite: canWrite(),
});
}
function confirmDeleteMap(map) {
return new Promise((resolve) => {
deleteDialogResolve = resolve;
const textEl = el("mapsDeleteDialogText");
const activeWarnEl = el("mapsDeleteDialogActiveWarn");
if (textEl) textEl.textContent = t("maps.deleteDialog.text", { name: map.name || map.id });
if (activeWarnEl) {
const isActive = map.id === store.activeMapId;
activeWarnEl.hidden = !isActive;
if (isActive) activeWarnEl.textContent = t("maps.deleteDialog.activeWarning");
}
deleteDialogEl?.showModal();
});
}
async function deleteMapFromList(mapId) {
const map = findMap(mapId);
if (!map || !canDeleteMap(map)) return;
if (!(await confirmDeleteMap(map))) return;
try {
await api(`/api/maps/${encodeURIComponent(map.id)}`, { method: "DELETE" });
} catch (e) {
alert(e.message || t("maps.deleteForbidden"));
return;
}
store.maps = store.maps.filter((m) => m.id !== map.id);
if (store.activeMapId === map.id) store.activeMapId = null;
renderList();
}
async function activateMap(mapId) {
const map = findMap(mapId);
if (!map) return;
if (!map.image_file) {
alert(t("maps.error.noImage"));
return;
}
await api("/api/robot/active_map", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ map_id: mapId }),
});
store.activeMapId = mapId;
renderList();
}
function openCreatePage() {
showCreate();
}
function renderSitesDialogList() {
if (!sitesListEl) return;
sitesListEl.innerHTML = "";
if (store.sites.length === 0) {
const empty = document.createElement("li");
empty.className = "mapsMirSitesEmpty";
empty.textContent = t("maps.sitesDialog.empty");
sitesListEl.appendChild(empty);
return;
}
store.sites.forEach((site) => {
const li = document.createElement("li");
li.className = "mapsMirSitesItem";
if (site.id === store.sitesDialogSelectedId) li.classList.add("is-selected");
li.dataset.siteId = site.id;
li.innerHTML = `
<button type="button" class="mapsMirSitesItemMain" data-select-site="${site.id}">
<span class="mapsMirSitesChevron">${SITE_ICONS.chevron}</span>
<span class="mapsMirSitesItemName">${escapeHtml(site.name || site.id)}</span>
</button>
<div class="mapsMirSitesItemActions">
<button type="button" class="mapsMirSitesIconBtn" data-edit-site="${site.id}" data-i18n-title="common.edit" title="${escapeHtml(t("common.edit"))}">${SITE_ICONS.edit}</button>
<button type="button" class="mapsMirSitesIconBtn mapsMirSitesIconBtn--danger" data-delete-site="${site.id}" data-i18n-title="common.delete" title="${escapeHtml(t("common.delete"))}">${SITE_ICONS.delete}</button>
</div>`;
li.querySelector("[data-select-site]")?.addEventListener("click", () => {
store.sitesDialogSelectedId = site.id;
renderSitesDialogList();
});
li.querySelector("[data-edit-site]")?.addEventListener("click", (evt) => {
evt.stopPropagation();
openSiteFormDialog(site.id);
});
li.querySelector("[data-delete-site]")?.addEventListener("click", (evt) => {
evt.stopPropagation();
void deleteSite(site.id);
});
sitesListEl.appendChild(li);
});
}
async function openSitesDialog() {
await loadSites();
store.sitesDialogSnapshotId = createSiteSelectEl?.value || store.sites[0]?.id || null;
store.sitesDialogSelectedId = store.sitesDialogSnapshotId;
renderSitesDialogList();
sitesDialogEl?.showModal();
}
function closeSitesDialog(apply) {
if (apply && store.sitesDialogSelectedId) {
renderSiteSelect(store.sitesDialogSelectedId);
}
sitesDialogEl?.close();
}
function openSiteFormDialog(siteId) {
store.editingSiteId = siteId || null;
const site = siteId ? findSite(siteId) : null;
const titleEl = el("mapsSiteFormTitle");
const nameEl = el("mapsSiteName");
if (titleEl) {
titleEl.textContent = site ? t("maps.siteForm.edit") : t("maps.siteForm.create");
}
if (nameEl) nameEl.value = site?.name || "";
siteFormDialogEl?.showModal();
nameEl?.focus();
}
async function saveSite(evt) {
evt.preventDefault();
const name = el("mapsSiteName")?.value.trim();
if (!name) return;
if (store.editingSiteId) {
const updated = await api(`/api/sites/${encodeURIComponent(store.editingSiteId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const idx = store.sites.findIndex((s) => s.id === store.editingSiteId);
if (idx >= 0) store.sites[idx] = updated;
} else {
const created = await api("/api/sites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
store.sites.push(created);
store.sitesDialogSelectedId = created.id;
}
siteFormDialogEl?.close();
renderSitesDialogList();
renderList();
}
async function deleteSite(siteId) {
const site = findSite(siteId);
if (!site) return;
if (!confirm(t("maps.sitesDialog.deleteConfirm", { name: site.name }))) return;
try {
await api(`/api/sites/${encodeURIComponent(siteId)}`, { method: "DELETE" });
} catch (e) {
alert(e.message);
return;
}
store.sites = store.sites.filter((s) => s.id !== siteId);
if (store.sitesDialogSelectedId === siteId) {
store.sitesDialogSelectedId = store.sites[0]?.id || null;
}
renderSitesDialogList();
renderList();
}
async function createMap(evt) {
evt.preventDefault();
const name = el("mapsCreateName")?.value.trim();
const site_id = createSiteSelectEl?.value;
if (!name || !site_id) return;
const user = window.AuthApp?.getUser?.();
const created = await api("/api/maps", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
site_id,
created_by: user?.display_name || user?.username || "",
created_by_user: user?.id || "",
created_by_group: user?.group_id || "",
resolution: 0.05,
origin_x: 0,
origin_y: 0,
origin_yaw: 0,
}),
});
store.maps.push(created);
store.filter = "";
if (filterInputEl) filterInputEl.value = "";
store.page = 1;
renderList();
openEditor(created.id);
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function goToPage(page) {
const total = pageCount(filteredMaps().length);
store.page = Math.min(Math.max(1, page), total);
renderList();
}
function bindEvents() {
el("mapsCreateOpenBtn")?.addEventListener("click", openCreatePage);
el("mapsCreateGoBackBtn")?.addEventListener("click", showList);
el("mapsCreateCancelBtn")?.addEventListener("click", showList);
el("mapsCreateHelpBtn")?.addEventListener("click", () => alert(t("maps.createPage.helpText")));
el("mapsImportSiteBtn")?.addEventListener("click", () => alert(t("maps.importComingSoon")));
el("mapsClearFiltersBtn")?.addEventListener("click", clearFilters);
el("mapsHelpBtn")?.addEventListener("click", () => alert(t("maps.helpText")));
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("mapsPageFirst")?.addEventListener("click", () => goToPage(1));
el("mapsPagePrev")?.addEventListener("click", () => goToPage(store.page - 1));
el("mapsPageNext")?.addEventListener("click", () => goToPage(store.page + 1));
el("mapsPageLast")?.addEventListener("click", () => goToPage(pageCount(filteredMaps().length)));
el("mapsCreateForm")?.addEventListener("submit", (evt) => {
createMap(evt).catch((e) => alert(e.message));
});
el("mapsDeleteYesBtn")?.addEventListener("click", () => {
deleteDialogEl?.close();
deleteDialogResolve?.(true);
deleteDialogResolve = null;
});
el("mapsDeleteNoBtn")?.addEventListener("click", () => {
deleteDialogEl?.close();
deleteDialogResolve?.(false);
deleteDialogResolve = null;
});
deleteDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
deleteDialogEl.close();
deleteDialogResolve?.(false);
deleteDialogResolve = null;
});
el("mapsSiteForm")?.addEventListener("submit", (evt) => {
saveSite(evt).catch((e) => alert(e.message));
});
el("mapsCreateSiteBtn")?.addEventListener("click", () => {
openSitesDialog().catch((e) => alert(e.message));
});
el("mapsSitesCreateBtn")?.addEventListener("click", () => openSiteFormDialog(null));
el("mapsSitesOkBtn")?.addEventListener("click", () => closeSitesDialog(true));
el("mapsSitesCancelBtn")?.addEventListener("click", () => closeSitesDialog(false));
sitesDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
closeSitesDialog(false);
});
document.querySelectorAll("[data-close-dialog]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-close-dialog");
el(id)?.close();
});
});
}
function applyReadOnly() {
document.body.classList.toggle("auth-readonly-maps-page", !canWrite());
}
async function refresh() {
await Promise.all([loadSites(), loadMaps(), loadActiveMap()]);
renderList();
}
async function init() {
applyReadOnly();
showList();
bindEvents();
try {
await refresh();
} catch (e) {
if (listEmptyEl) {
listEmptyEl.hidden = false;
listEmptyEl.textContent = t("common.error", { msg: e.message });
}
if (tableEl) tableEl.hidden = true;
}
}
window.MapsApp = {
init,
refresh,
onPageShow() {
applyReadOnly();
showList();
refresh().catch(() => {});
},
getMaps: () => [...store.maps],
getMapById: findMap,
activateMap,
};
function boot() {
init();
}
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:locale-change", () => {
renderList();
renderActiveHint();
});
})();

View File

@@ -39,6 +39,7 @@
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"]; const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const missionListEl = el("missionList"); const missionListEl = el("missionList");
const missionListEmptyEl = el("missionListEmpty"); const missionListEmptyEl = el("missionListEmpty");
@@ -87,10 +88,12 @@
configListPath: "root", configListPath: "root",
queue: [], queue: [],
runner: { state: "idle", message: "" }, runner: { state: "idle", message: "" },
queuePollTimer: null,
pendingQueueMissionId: null, pendingQueueMissionId: null,
}; };
let queuePollRefs = 0;
let queuePollTimer = null;
function newId() { function newId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID(); if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
return `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; return `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -134,9 +137,9 @@
function actionMeta(type) { function actionMeta(type) {
for (const items of Object.values(ACTION_GROUPS)) { for (const items of Object.values(ACTION_GROUPS)) {
const hit = items.find((a) => a.type === type); const hit = items.find((a) => a.type === type);
if (hit) return hit; if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
} }
return { type, label: type }; return { type, label: t(`missions.action.${type}`) || type };
} }
function createAction(type, overrides = {}) { function createAction(type, overrides = {}) {
@@ -334,7 +337,7 @@
if (action.id === actionId) return { action, list, index: i, path, parent }; if (action.id === actionId) return { action, list, index: i, path, parent };
if (Array.isArray(action.children)) { if (Array.isArray(action.children)) {
const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action); const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action);
if (hit) return hit; if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
} }
} }
return null; return null;
@@ -474,13 +477,14 @@
function queueStatusLabel(status) { function queueStatusLabel(status) {
const map = { const map = {
pending: "Chờ", pending: "missions.queue.status.pending",
executing: "Đang chạy", executing: "missions.queue.status.executing",
completed: "Xong", completed: "missions.queue.status.done",
failed: "Lỗi", failed: "missions.queue.status.error",
cancelled: "Đã hủy", cancelled: "missions.queue.status.cancelled",
}; };
return map[status] || status; const key = map[status];
return key ? t(key) : status;
} }
async function refreshQueue() { async function refreshQueue() {
@@ -493,7 +497,7 @@
notifyQueueUpdate(); notifyQueueUpdate();
} catch (e) { } catch (e) {
if (String(e.message || "").includes("not authenticated")) return; if (String(e.message || "").includes("not authenticated")) return;
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `Không tải được queue: ${e.message}`; if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `${t("common.error", { msg: e.message })}`;
} }
} }
@@ -534,8 +538,8 @@
? `${store.runner.message}${action}` ? `${store.runner.message}${action}`
: st === "idle" : st === "idle"
? compact ? compact
? "Sẵn sàng" ? t("missions.queue.ready")
: "Robot sẵn sàng — queue trống hoặc chờ mission mới." : t("missions.queue.idleMessage")
: "—"; : "—";
} }
@@ -552,12 +556,12 @@
${paramHtml ? `<div class="missionQueueItemParams">${paramHtml}</div>` : ""} ${paramHtml ? `<div class="missionQueueItemParams">${paramHtml}</div>` : ""}
</div> </div>
<div class="missionQueueWidgetActions"> <div class="missionQueueWidgetActions">
${entry.status === "pending" ? `<button type="button" class="iconBtn danger" data-queue-remove="${entry.id}" title="Xóa">×</button>` : `<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>`} ${entry.status === "pending" ? `<button type="button" class="iconBtn danger" data-queue-remove="${entry.id}" title="" data-i18n-title="common.delete">×</button>` : `<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>`}
</div>` </div>`
: ` : `
<div class="missionQueueOrder"> <div class="missionQueueOrder">
<button type="button" class="iconBtn" data-queue-up="${entry.id}" title="Lên" ${canReorder && index > 0 ? "" : "disabled"}>↑</button> <button type="button" class="iconBtn" data-queue-up="${entry.id}" title="${t("missions.queue.moveUp")}" ${canReorder && index > 0 ? "" : "disabled"}>↑</button>
<button type="button" class="iconBtn" data-queue-down="${entry.id}" title="Xuống" ${canReorder && index < store.queue.length - 1 ? "" : "disabled"}>↓</button> <button type="button" class="iconBtn" data-queue-down="${entry.id}" title="${t("missions.queue.moveDown")}" ${canReorder && index < store.queue.length - 1 ? "" : "disabled"}>↓</button>
</div> </div>
<div> <div>
<div class="missionQueueItemTitle">${escapeHtml(entry.mission_name || "Mission")}</div> <div class="missionQueueItemTitle">${escapeHtml(entry.mission_name || "Mission")}</div>
@@ -566,7 +570,7 @@
</div> </div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;"> <div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;">
<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span> <span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>
${entry.status === "pending" ? `<button type="button" class="btn subtle danger" data-queue-remove="${entry.id}">Xóa</button>` : ""} ${entry.status === "pending" ? `<button type="button" class="btn subtle danger" data-queue-remove="${entry.id}">${t("common.delete")}</button>` : ""}
</div>`; </div>`;
row.querySelector("[data-queue-up]")?.addEventListener("click", () => moveQueueItem(entry.id, -1)); row.querySelector("[data-queue-up]")?.addEventListener("click", () => moveQueueItem(entry.id, -1));
@@ -617,7 +621,7 @@
} }
async function clearQueue() { async function clearQueue() {
if (!confirm("Xóa các mission đang chờ trong queue?")) return; if (!confirm(t("missions.queue.clearConfirm"))) return;
try { try {
await missionApi("/api/mission_queue", { method: "DELETE" }); await missionApi("/api/mission_queue", { method: "DELETE" });
await refreshQueue(); await refreshQueue();
@@ -648,7 +652,7 @@
} }
async function cancelRunner() { async function cancelRunner() {
if (!confirm("Hủy mission đang chạy? (thoát loop và dừng ngay)")) return; if (!confirm(t("missions.queue.cancelConfirm"))) return;
await missionApi("/api/mission_queue/cancel", { method: "POST", body: "{}" }); await missionApi("/api/mission_queue/cancel", { method: "POST", body: "{}" });
await refreshQueue(); await refreshQueue();
} }
@@ -714,15 +718,27 @@
function startQueuePoll() { function startQueuePoll() {
if (window.AuthApp && !window.AuthApp.isReady()) return; if (window.AuthApp && !window.AuthApp.isReady()) return;
stopQueuePoll(); queuePollRefs += 1;
refreshQueue(); if (queuePollRefs === 1) {
store.queuePollTimer = setInterval(refreshQueue, 1500); refreshQueue();
queuePollTimer = setInterval(refreshQueue, 1500);
}
} }
function stopQueuePoll() { function stopQueuePoll() {
if (store.queuePollTimer) { if (queuePollRefs <= 0) return;
clearInterval(store.queuePollTimer); queuePollRefs -= 1;
store.queuePollTimer = null; if (queuePollRefs === 0 && queuePollTimer) {
clearInterval(queuePollTimer);
queuePollTimer = null;
}
}
function stopQueuePollForce() {
queuePollRefs = 0;
if (queuePollTimer) {
clearInterval(queuePollTimer);
queuePollTimer = null;
} }
} }
@@ -742,8 +758,8 @@
</div> </div>
<div class="missionListItemActions"> <div class="missionListItemActions">
<button type="button" class="iconBtn missionQueueBtn" data-queue="${mission.id}" title="Thêm vào mission queue" aria-label="Thêm vào queue">▤</button> <button type="button" class="iconBtn missionQueueBtn" data-queue="${mission.id}" title="Thêm vào mission queue" aria-label="Thêm vào queue">▤</button>
<button type="button" class="btn subtle" data-edit="${mission.id}">Sửa</button> <button type="button" class="btn subtle" data-edit="${mission.id}">${t("common.edit")}</button>
<button type="button" class="btn subtle danger" data-delete="${mission.id}">Xóa</button> <button type="button" class="btn subtle danger" data-delete="${mission.id}">${t("common.delete")}</button>
</div>`; </div>`;
row.addEventListener("click", (evt) => { row.addEventListener("click", (evt) => {
if (evt.target.closest("button")) return; if (evt.target.closest("button")) return;
@@ -759,7 +775,7 @@
}); });
row.querySelector("[data-delete]").addEventListener("click", (evt) => { row.querySelector("[data-delete]").addEventListener("click", (evt) => {
evt.stopPropagation(); evt.stopPropagation();
if (!confirm(`Xóa mission «${mission.name}»?`)) return; if (!confirm(t("missions.deleteConfirm", { name: mission.name }))) return;
store.missions = store.missions.filter((m) => m.id !== mission.id); store.missions = store.missions.filter((m) => m.id !== mission.id);
persistStore(); persistStore();
renderMissionList(); renderMissionList();
@@ -768,6 +784,12 @@
}); });
} }
function groupLabel(name) {
const key = `missions.group.${name}`;
const v = t(key);
return v !== key ? v : name;
}
function renderActionPalette() { function renderActionPalette() {
if (!missionGroupTabsEl) return; if (!missionGroupTabsEl) return;
missionGroupTabsEl.innerHTML = ""; missionGroupTabsEl.innerHTML = "";
@@ -875,7 +897,7 @@
</div> </div>
<div class="missionActionBtns"> <div class="missionActionBtns">
<button type="button" class="iconBtn" data-config="${action.id}" title="Cấu hình">⚙</button> <button type="button" class="iconBtn" data-config="${action.id}" title="Cấu hình">⚙</button>
<button type="button" class="iconBtn danger" data-remove="${action.id}" title="Xóa">×</button> <button type="button" class="iconBtn danger" data-remove="${action.id}" title="" data-i18n-title="common.delete">×</button>
</div> </div>
</div>`; </div>`;
@@ -1030,7 +1052,7 @@
} }
function closeEditor() { function closeEditor() {
if (store.dirty && !confirm("Bỏ thay đổi chưa lưu?")) return; if (store.dirty && !confirm(t("missions.editor.discardConfirm"))) return;
store.editingId = null; store.editingId = null;
store.draft = null; store.draft = null;
setDirty(false); setDirty(false);
@@ -1043,7 +1065,7 @@
const draft = getDraft(); const draft = getDraft();
if (!draft) return false; if (!draft) return false;
if (!draft.name.trim()) { if (!draft.name.trim()) {
alert("Tên mission không được trống."); alert(t("missions.error.nameRequired"));
return false; return false;
} }
draft.updated_at = new Date().toISOString(); draft.updated_at = new Date().toISOString();
@@ -1062,7 +1084,7 @@
const name = newName.trim(); const name = newName.trim();
if (!name) return false; if (!name) return false;
if (store.missions.some((m) => m.name === name && m.id !== draft.id)) { if (store.missions.some((m) => m.name === name && m.id !== draft.id)) {
alert("Tên mission đã tồn tại."); alert(t("missions.error.nameDuplicate"));
return false; return false;
} }
const copy = JSON.parse(JSON.stringify(draft)); const copy = JSON.parse(JSON.stringify(draft));
@@ -1199,7 +1221,7 @@
addField("Timeout (s)", textInput("timeout_s", p.timeout_s, "number")); addField("Timeout (s)", textInput("timeout_s", p.timeout_s, "number"));
{ {
const chk = document.createElement("label"); const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> Chờ mức ON`; chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> ${t("missions.action.waitOnLevel")}`;
addField("Kỳ vọng", chk); addField("Kỳ vọng", chk);
} }
break; break;
@@ -1272,7 +1294,7 @@
if (!store.groups.includes(group)) store.groups.push(group); if (!store.groups.includes(group)) store.groups.push(group);
} }
if (store.missions.some((m) => m.name === name)) { if (store.missions.some((m) => m.name === name)) {
alert("Tên mission đã tồn tại."); alert(t("missions.error.nameDuplicate"));
return; return;
} }
const mission = createMission(name, group, el("missionCreateDesc").value); const mission = createMission(name, group, el("missionCreateDesc").value);
@@ -1285,7 +1307,7 @@
el("missionEditorBackBtn")?.addEventListener("click", closeEditor); el("missionEditorBackBtn")?.addEventListener("click", closeEditor);
el("missionSaveBtn")?.addEventListener("click", () => { el("missionSaveBtn")?.addEventListener("click", () => {
if (saveDraft()) alert("Đã lưu mission."); if (saveDraft()) alert(t("missions.saveSuccess"));
}); });
el("missionSaveAsBtn")?.addEventListener("click", openSaveAsDialog); el("missionSaveAsBtn")?.addEventListener("click", openSaveAsDialog);
el("missionSettingsBtn")?.addEventListener("click", openSettingsDialog); el("missionSettingsBtn")?.addEventListener("click", openSettingsDialog);
@@ -1298,7 +1320,7 @@
draft.group = el("missionSettingsGroup").value; draft.group = el("missionSettingsGroup").value;
draft.description = el("missionSettingsDesc").value.trim(); draft.description = el("missionSettingsDesc").value.trim();
if (!draft.name) { if (!draft.name) {
alert("Tên không được trống."); alert(t("missions.error.nameEmpty"));
return; return;
} }
setDirty(true); setDirty(true);
@@ -1379,7 +1401,17 @@
function boot() { function boot() {
init(); init();
} }
function onLocaleChange() {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
else {
renderMissionList();
renderQueuePanel();
}
renderActionPalette();
}
window.addEventListener("lm:locale-change", onLocaleChange);
if (window.AuthApp?.isReady()) boot(); if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true }); else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:auth-logout", stopQueuePoll); window.addEventListener("lm:auth-logout", stopQueuePollForce);
})(); })();

302
www/nav.js Normal file
View File

@@ -0,0 +1,302 @@
/**
* MiR-style 3-column navigation: primary rail + flyout submenu + content.
*/
(function () {
const STORAGE_MODULE = "mirNavModule";
const STORAGE_SECTION = "mirNavSection";
const STORAGE_FLYOUT = "mirNavFlyoutOpen";
const MODULES = {
dashboards: {
items: [{ section: "dashboard-list", page: "dashboard" }],
dynamic: true,
},
setup: {
items: [
{ section: "missions", page: "missions" },
{ section: "maps", page: "maps" },
{ section: "sounds", page: "sounds" },
{ section: "build-robot", page: "config" },
],
},
monitoring: {
items: [{ section: "monitoring-log", page: "monitoring" }],
},
system: {
items: [{ section: "integrations", page: "integrations" }],
},
help: {
items: [{ section: "help-api", page: "help" }],
},
};
const PAGE_NAV = {
dashboard: { module: "dashboards", section: "dashboard-list" },
config: { module: "setup", section: "build-robot" },
maps: { module: "setup", section: "maps" },
missions: { module: "setup", section: "missions" },
sounds: { module: "setup", section: "sounds" },
integrations: { module: "system", section: "integrations" },
monitoring: { module: "monitoring", section: "monitoring-log" },
help: { module: "help", section: "help-api" },
};
let activeModule = "setup";
let activeSection = "missions";
let flyoutOpen = true;
const shellEl = () => document.getElementById("mirNavShell");
const flyoutListEl = () => document.getElementById("mirNavFlyoutList");
const flyoutTitleEl = () => document.getElementById("mirNavFlyoutTitle");
const backBtnEl = () => document.getElementById("mirNavBackBtn");
function t(key) {
return window.I18n?.t(`nav.${key}`) ?? key;
}
function canAccessPage(page) {
if (window.AuthApp?.canAccessPage) return window.AuthApp.canAccessPage(page);
return true;
}
function moduleItems(moduleId) {
const mod = MODULES[moduleId];
if (!mod) return [];
if (mod.dynamic && moduleId === "dashboards" && window.DashboardApp?.getNavItems) {
return window.DashboardApp.getNavItems();
}
return mod.items;
}
function visibleItems(moduleId) {
const mod = MODULES[moduleId];
if (!mod) return [];
return moduleItems(moduleId).filter((item) => canAccessPage(item.page));
}
function itemLabel(item) {
if (item.label) return item.label;
return t(item.section);
}
function moduleHasAccess(moduleId) {
return visibleItems(moduleId).length > 0;
}
function saveState() {
try {
localStorage.setItem(STORAGE_MODULE, activeModule);
localStorage.setItem(STORAGE_SECTION, activeSection);
localStorage.setItem(STORAGE_FLYOUT, flyoutOpen ? "1" : "0");
} catch {
/* ignore */
}
}
function renderFlyout() {
const list = flyoutListEl();
const title = flyoutTitleEl();
if (!list || !title) return;
const items = visibleItems(activeModule);
title.textContent = t(activeModule);
list.replaceChildren();
items.forEach((item) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "mirNavFlyoutItem";
btn.dataset.section = item.section;
btn.dataset.page = item.page;
btn.textContent = itemLabel(item);
if (item.section === activeSection) {
btn.classList.add("is-active");
btn.setAttribute("aria-current", "page");
}
btn.addEventListener("click", () => selectSection(item.section, item.page));
list.appendChild(btn);
});
}
function updateRailUI() {
document.querySelectorAll(".mirNavRailItem[data-module]").forEach((btn) => {
const mod = btn.dataset.module || "";
const allowed = moduleHasAccess(mod);
btn.hidden = !allowed;
btn.style.display = allowed ? "" : "none";
const on = mod === activeModule && flyoutOpen;
btn.classList.toggle("is-active", on);
if (on) btn.setAttribute("aria-current", "true");
else btn.removeAttribute("aria-current");
});
const shell = shellEl();
if (shell) shell.classList.toggle("mirNavShell--flyout-collapsed", !flyoutOpen);
const back = backBtnEl();
if (back) {
const label = flyoutOpen ? t("collapse") : t("expand");
back.title = label;
back.setAttribute("aria-label", label);
}
renderFlyout();
}
function selectModule(moduleId, opts = {}) {
if (!MODULES[moduleId] || !moduleHasAccess(moduleId)) return;
if (moduleId === activeModule && flyoutOpen && !opts.forceSection) {
flyoutOpen = false;
saveState();
updateRailUI();
return;
}
activeModule = moduleId;
flyoutOpen = true;
const items = visibleItems(moduleId);
const keepSection = items.some((i) => i.section === activeSection);
if (!keepSection || opts.forceSection) {
const preferred = items.find((i) => i.section === opts.section) || items[0];
if (preferred) {
activeSection = preferred.section;
if (!opts.skipPage) navigateToPage(preferred.page);
}
} else if (!opts.skipPage) {
const current = items.find((i) => i.section === activeSection);
if (current) navigateToPage(current.page);
}
saveState();
updateRailUI();
}
function selectSection(section, page) {
activeSection = section;
saveState();
updateRailUI();
navigateToPage(page);
if (page === "dashboard") window.DashboardApp?.handleNav?.(section);
}
function syncDashboardSection(section) {
activeModule = "dashboards";
activeSection = section.startsWith("dashboard-") || section === "dashboard-list" ? section : `dashboard-${section}`;
saveState();
updateRailUI();
}
function navigateToPage(page) {
if (window.LmApp?.setActivePage) window.LmApp.setActivePage(page);
}
function syncFromPage(page) {
const nav = PAGE_NAV[page];
if (!nav) return;
activeModule = nav.module;
activeSection = nav.section;
saveState();
updateRailUI();
if (page === "dashboard") window.DashboardApp?.handleNav?.(activeSection);
}
function toggleFlyout() {
flyoutOpen = !flyoutOpen;
saveState();
updateRailUI();
}
function applyPermissions() {
const modules = Object.keys(MODULES);
if (!moduleHasAccess(activeModule)) {
const fallback = modules.find((m) => moduleHasAccess(m));
if (fallback) selectModule(fallback, { forceSection: true, skipPage: false });
} else {
const items = visibleItems(activeModule);
if (!items.some((i) => i.section === activeSection)) {
activeSection = items[0]?.section || activeSection;
}
}
updateRailUI();
}
function restoreInitialPage() {
let page = "missions";
try {
const saved = localStorage.getItem("activePage");
if (saved && PAGE_NAV[saved]) page = saved;
} catch {
/* ignore */
}
try {
const savedMod = localStorage.getItem(STORAGE_MODULE);
const savedSec = localStorage.getItem(STORAGE_SECTION);
const savedFlyout = localStorage.getItem(STORAGE_FLYOUT);
if (savedMod && MODULES[savedMod]) activeModule = savedMod;
if (savedSec) activeSection = savedSec;
if (savedFlyout === "0") flyoutOpen = false;
} catch {
/* ignore */
}
const nav = PAGE_NAV[page];
if (nav && moduleHasAccess(nav.module)) {
activeModule = nav.module;
activeSection = nav.section;
} else {
const modItems = visibleItems(activeModule);
const match = modItems.find((i) => i.page === page) || modItems[0];
if (match) {
activeSection = match.section;
page = match.page;
}
}
updateRailUI();
navigateToPage(page);
}
function refreshFlyout() {
updateRailUI();
}
function refreshLabels() {
window.I18n?.applyDOM?.();
updateRailUI();
}
function bindEvents() {
document.querySelectorAll(".mirNavRailItem[data-module]").forEach((btn) => {
btn.addEventListener("click", () => selectModule(btn.dataset.module || "setup"));
});
backBtnEl()?.addEventListener("click", toggleFlyout);
document.getElementById("mirNavLogout")?.addEventListener("click", () => {
window.AuthApp?.logout?.();
});
window.addEventListener("lm:locale-change", () => refreshLabels());
}
function init() {
refreshLabels();
bindEvents();
applyPermissions();
restoreInitialPage();
}
window.NavApp = {
init,
syncFromPage,
syncDashboardSection,
applyPermissions,
selectModule,
selectSection,
toggleFlyout,
refreshFlyout,
};
})();

280
www/sounds.js Normal file
View File

@@ -0,0 +1,280 @@
(() => {
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listEl = el("soundList");
const emptyEl = el("soundListEmpty");
const createBtnEl = el("soundCreateBtn");
const dialogEl = el("soundEditDialog");
const formEl = el("soundEditForm");
const titleEl = el("soundEditTitle");
const nameEl = el("soundEditName");
const descEl = el("soundEditDescription");
const enabledEl = el("soundEditEnabled");
const fileMetaEl = el("soundEditFileMeta");
const uploadInputEl = el("soundEditUploadInput");
const uploadBtnEl = el("soundEditUploadBtn");
const playBtnEl = el("soundEditPlayBtn");
const deleteBtnEl = el("soundEditDeleteBtn");
const store = {
sounds: [],
editingId: null,
previewAudio: null,
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("integrations");
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function apiJson(url, opts = {}) {
if (window.AuthApp && !window.AuthApp.isReady()) {
throw new Error("not authenticated");
}
const res = await fetch(url, { credentials: "include", ...opts });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) {
const msg = (data && data.error) || text || res.statusText;
throw new Error(msg);
}
return data;
}
async function refreshSounds() {
const data = await apiJson("/api/sounds");
store.sounds = Array.isArray(data.sounds) ? data.sounds : [];
}
function formatDuration(ms) {
if (!Number.isFinite(Number(ms))) return "—";
const sec = Math.round(Number(ms) / 100) / 10;
return `${sec}s`;
}
function renderList() {
if (!listEl) return;
listEl.innerHTML = "";
if (emptyEl) emptyEl.hidden = store.sounds.length > 0;
store.sounds.forEach((sound) => {
const row = document.createElement("div");
row.className = "missionListItem soundListItem";
const hasFile = !!sound.file_name;
row.innerHTML = `
<div>
<div class="missionListItemTitle">${escapeHtml(sound.name || sound.id)}</div>
<div class="missionListItemMeta">
${sound.enabled === false ? t("common.disabled") : t("common.enabled")}
· ${hasFile ? escapeHtml(sound.file_name) : t("sounds.noFile")}
${sound.duration_ms != null ? ` · ${formatDuration(sound.duration_ms)}` : ""}
</div>
</div>
<div class="missionListItemActions">
<button type="button" class="btn subtle soundPlayBtn" data-id="${escapeHtml(sound.id)}" ${hasFile ? "" : "disabled"}>${t("sounds.play")}</button>
<button type="button" class="btn subtle soundEditBtn" data-id="${escapeHtml(sound.id)}">${t("common.edit")}</button>
</div>
`;
listEl.appendChild(row);
});
listEl.querySelectorAll(".soundEditBtn").forEach((btn) => {
btn.addEventListener("click", () => openDialog(btn.dataset.id));
});
listEl.querySelectorAll(".soundPlayBtn").forEach((btn) => {
btn.addEventListener("click", () => playSound(btn.dataset.id));
});
}
function stopPreview() {
if (store.previewAudio) {
store.previewAudio.pause();
store.previewAudio = null;
}
}
function playSound(id) {
stopPreview();
const audio = new Audio(`/api/sounds/${encodeURIComponent(id)}/file`);
store.previewAudio = audio;
audio.play().catch(() => alert(t("sounds.playFailed")));
}
function updateFileMeta(sound) {
if (!fileMetaEl) return;
if (sound?.file_name) {
fileMetaEl.textContent = t("sounds.fileMeta", {
name: sound.file_name,
duration: formatDuration(sound.duration_ms),
});
} else {
fileMetaEl.textContent = t("sounds.noFile");
}
if (playBtnEl) playBtnEl.disabled = !sound?.file_name;
}
function openDialog(id = null) {
store.editingId = id;
const existing = id ? store.sounds.find((s) => s.id === id) : null;
if (titleEl) {
titleEl.textContent = existing ? t("sounds.editTitle") : t("sounds.createTitle");
}
if (nameEl) nameEl.value = existing?.name || "";
if (descEl) descEl.value = existing?.description || "";
if (enabledEl) enabledEl.checked = existing?.enabled !== false;
updateFileMeta(existing);
if (deleteBtnEl) deleteBtnEl.hidden = !existing || !canWrite();
if (uploadBtnEl) uploadBtnEl.disabled = !canWrite();
if (nameEl) nameEl.readOnly = !canWrite();
if (descEl) descEl.readOnly = !canWrite();
if (enabledEl) enabledEl.disabled = !canWrite();
dialogEl?.showModal();
}
async function saveDialog() {
if (!canWrite()) return;
const name = nameEl?.value.trim() || "";
if (!name) {
alert(t("sounds.nameRequired"));
return;
}
const payload = {
name,
description: descEl?.value.trim() || "",
enabled: enabledEl?.checked !== false,
};
try {
if (store.editingId) {
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
const created = await apiJson("/api/sounds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
store.editingId = created.id;
}
await refreshSounds();
renderList();
const updated = store.sounds.find((s) => s.id === store.editingId);
updateFileMeta(updated);
if (!uploadInputEl?.files?.length) {
dialogEl?.close();
}
} catch (e) {
alert(e.message);
}
}
async function uploadFile() {
if (!canWrite() || !store.editingId) return;
const file = uploadInputEl?.files?.[0];
if (!file) return;
const form = new FormData();
form.append("file", file);
try {
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}/file`, {
method: "POST",
body: form,
});
uploadInputEl.value = "";
await refreshSounds();
renderList();
updateFileMeta(store.sounds.find((s) => s.id === store.editingId));
} catch (e) {
alert(e.message);
}
}
async function deleteSound() {
if (!canWrite() || !store.editingId) return;
if (!confirm(t("sounds.deleteConfirm"))) return;
try {
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}`, { method: "DELETE" });
dialogEl?.close();
store.editingId = null;
await refreshSounds();
renderList();
} catch (e) {
alert(e.message);
}
}
function bindEvents() {
createBtnEl?.addEventListener("click", () => {
if (!canWrite()) return;
openDialog(null);
});
formEl?.addEventListener("submit", (evt) => {
evt.preventDefault();
saveDialog();
});
el("soundEditCancelBtn")?.addEventListener("click", () => {
stopPreview();
dialogEl?.close();
});
dialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
stopPreview();
dialogEl?.close();
});
uploadBtnEl?.addEventListener("click", () => uploadInputEl?.click());
uploadInputEl?.addEventListener("change", () => {
saveDialog().then(() => uploadFile());
});
playBtnEl?.addEventListener("click", () => {
if (store.editingId) playSound(store.editingId);
});
deleteBtnEl?.addEventListener("click", () => deleteSound());
}
async function onPageShow() {
stopPreview();
if (createBtnEl) createBtnEl.hidden = !canWrite();
try {
await refreshSounds();
renderList();
} catch (e) {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = e.message;
}
}
}
function onPageHide() {
stopPreview();
dialogEl?.close();
}
function getSounds() {
return JSON.parse(JSON.stringify(store.sounds));
}
bindEvents();
window.SoundsApp = {
onPageShow,
onPageHide,
getSounds,
refreshSounds,
};
})();

File diff suppressed because it is too large Load Diff

457
www/topbar.js Normal file
View File

@@ -0,0 +1,457 @@
(() => {
const el = (id) => document.getElementById(id);
const LOCALE_META = {
vi: { flag: "🇻🇳", labelKey: "topbar.localeVi" },
en: { flag: "🇺🇸", labelKey: "topbar.localeEn" },
};
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const getLocale = () => window.I18n?.getLocale?.() ?? "vi";
let robotStatus = null;
let pollTimer = null;
let eventsBound = false;
let openPanel = null;
let joystickActive = false;
let joystickPointerId = null;
let joystickRaf = null;
let lastCmd = { linear: 0, angular: 0 };
function applyLocale(next) {
if (window.I18n) window.I18n.setLocale(next);
if (robotStatus) renderAll(robotStatus);
}
function loadLocale() {
/* locale owned by I18n */
}
function canSeeMissions() {
return window.AuthApp?.canAccessPage?.("missions");
}
function canControl() {
return window.AuthApp?.canWrite?.("missions");
}
async function apiJson(path, opts = {}) {
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
function closePanels() {
document.querySelectorAll(".mirPanel").forEach((p) => {
p.hidden = true;
});
document.querySelectorAll(".mirSegment[aria-haspopup='true']").forEach((btn) => {
btn.setAttribute("aria-expanded", "false");
});
openPanel = null;
}
function togglePanel(btn, panel) {
if (!btn || !panel) return;
const isOpen = btn.getAttribute("aria-expanded") === "true";
closePanels();
if (!isOpen) {
panel.hidden = false;
btn.setAttribute("aria-expanded", "true");
openPanel = panel;
}
}
function missionStripMessage(status) {
const pending = Number(status.queue_pending) || 0;
const runnerState = status.runner?.state || "idle";
const msg = status.message || "";
if (runnerState === "running" || runnerState === "paused") {
if (msg && msg !== t("topbar.waiting")) return msg;
const name = status.runner?.current_action;
if (name) return String(name);
}
if (pending === 0 && runnerState === "idle") return t("topbar.noMissionsQueue");
if (msg && msg !== t("topbar.waiting")) return msg;
return t("topbar.waiting");
}
function renderControl(status) {
const motion = status.motion || "paused";
const running = motion === "running";
const runnerState = status.runner?.state || "idle";
const isError = status.health === "error" || runnerState === "error";
const pauseIcon = el("mirControlIconPause");
const playIcon = el("mirControlIconPlay");
const pillEl = el("mirControlPill");
const msgEl = el("mirMissionMsg");
const btnEl = el("mirSegControl");
const stripEl = el("mirMissionStrip");
if (pauseIcon && playIcon) {
pauseIcon.hidden = !running;
playIcon.hidden = running;
}
if (pillEl) {
pillEl.textContent = isError ? t("topbar.error") : running ? t("topbar.running") : t("topbar.paused");
pillEl.classList.toggle("is-running", running && !isError);
pillEl.classList.toggle("is-paused", !running && !isError);
pillEl.classList.toggle("is-error", isError);
}
if (msgEl) msgEl.textContent = missionStripMessage(status);
if (stripEl) stripEl.classList.toggle("is-error", isError);
if (btnEl) {
btnEl.disabled = !canControl() || status.health === "error";
btnEl.title = running ? t("topbar.pauseHint") : t("topbar.startHint");
btnEl.classList.toggle("is-readonly", !canControl());
}
}
function renderStatus(status) {
const health = status.health || "ok";
const runnerState = status.runner?.state || "idle";
const isError = health === "error" || runnerState === "error";
const labelEl = el("mirStatusLabel");
const iconEl = el("mirStatusIcon");
const bodyEl = el("mirStatusPanelBody");
const footerEl = el("mirStatusPanelFooter");
const segEl = el("mirSegStatus");
if (labelEl) labelEl.textContent = isError ? t("topbar.error") : t("topbar.allOk");
if (iconEl) {
iconEl.classList.toggle("is-ok", !isError);
iconEl.classList.toggle("is-error", isError);
}
if (segEl) segEl.classList.toggle("is-error", isError);
if (!bodyEl) return;
const err = status.error && typeof status.error === "object" ? status.error : null;
const runnerErr = runnerState === "error" ? status.runner?.message : "";
const message = status.message || t("topbar.waiting");
if (isError && (err || runnerErr)) {
bodyEl.innerHTML = `
<div class="mirStatusErrorTitle">${t("topbar.error")}</div>
${err?.code != null ? `<div class="mirStatusRow"><span>${t("topbar.code")}:</span> <strong>${err.code}</strong></div>` : ""}
${err?.module ? `<div class="mirStatusRow"><span>${t("topbar.module")}:</span> ${err.module}</div>` : ""}
<div class="mirStatusDesc">${err?.description || runnerErr || message}</div>`;
if (footerEl) footerEl.hidden = !canControl();
} else {
bodyEl.innerHTML = `
<div class="mirStatusOkTitle">${t("topbar.allOk")}</div>
<div class="mirStatusDesc">${message}</div>
${status.queue_pending > 0 ? `<div class="mirStatusMeta">${t("topbar.queueCount", { n: status.queue_pending })}</div>` : ""}`;
if (footerEl) footerEl.hidden = true;
}
}
function renderBattery(status) {
const pct = Math.max(0, Math.min(100, Number(status.battery_percent) || 0));
const labelEl = el("mirBatteryLabel");
const levelEl = el("mirBatteryLevel");
const segEl = el("mirSegBattery");
if (labelEl) labelEl.textContent = `${pct}%`;
if (levelEl) levelEl.style.width = `${pct}%`;
if (segEl) {
segEl.classList.toggle("is-low", pct < 20);
segEl.classList.toggle("is-mid", pct >= 20 && pct < 50);
segEl.classList.toggle("is-charging", !!status.battery_charging);
}
}
function hideJoystickOverlay() {
const overlay = el("joystickOverlay");
if (overlay) overlay.hidden = true;
joystickActive = false;
const stick = el("joystickStick");
if (stick) stick.style.transform = "translate(0, 0)";
lastCmd = { linear: 0, angular: 0 };
}
function renderJoystick(status) {
if (!window.AuthApp?.isReady?.()) {
hideJoystickOverlay();
return;
}
const seg = el("mirSegJoystick");
const engaged = !!status.joystick_engaged;
if (seg) seg.classList.toggle("is-active", engaged);
const overlay = el("joystickOverlay");
if (overlay) overlay.hidden = !engaged;
joystickActive = engaged;
const speedSel = el("joystickSpeedSelect");
if (speedSel && status.joystick_speed) speedSel.value = status.joystick_speed;
if (el("joystickSpeedLabel")) {
const speed = status.joystick_speed || "fast";
el("joystickSpeedLabel").textContent = t(`topbar.joystickSpeed.${speed}`);
}
}
function renderAll(status) {
if (!window.AuthApp?.isReady?.()) {
hideJoystickOverlay();
return;
}
robotStatus = status;
if (!canSeeMissions()) {
el("mirTopbar")?.classList.add("mirTopbar--no-missions");
return;
}
el("mirTopbar")?.classList.remove("mirTopbar--no-missions");
renderControl(status);
renderStatus(status);
renderBattery(status);
renderJoystick(status);
}
async function fetchStatus() {
if (!window.AuthApp?.isReady() || !canSeeMissions()) return;
try {
const data = await apiJson("/api/robot/status");
renderAll(data);
window.dispatchEvent(new CustomEvent("lm:robot-status", { detail: data }));
} catch (e) {
if (String(e.message || "").includes("not authenticated")) return;
}
}
function startPoll() {
stopPoll();
fetchStatus();
pollTimer = setInterval(fetchStatus, 1500);
window.MissionsApp?.startQueuePoll?.();
}
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
window.MissionsApp?.stopQueuePoll?.();
}
async function toggleRobotMotion() {
if (!robotStatus || !canControl()) return;
const running = robotStatus.motion === "running";
const path = running ? "/api/robot/pause" : "/api/robot/start";
const data = await apiJson(path, { method: "POST", body: "{}" });
renderAll(data);
}
async function resetError() {
const data = await apiJson("/api/robot/errors/reset", { method: "POST", body: "{}" });
renderAll(data);
closePanels();
}
async function engageJoystick(engaged, speed) {
const payload = { engaged };
if (speed) payload.speed = speed;
const data = await apiJson("/api/robot/joystick", {
method: "POST",
body: JSON.stringify(payload),
});
renderAll(data);
}
function sendCmdVel(linear, angular) {
if (!joystickActive) return;
if (Math.abs(linear - lastCmd.linear) < 0.02 && Math.abs(angular - lastCmd.angular) < 0.02) return;
lastCmd = { linear, angular };
apiJson("/api/robot/cmd_vel", {
method: "POST",
body: JSON.stringify({ linear, angular }),
}).catch(() => {});
}
function bindJoystickPad() {
const pad = el("joystickPad");
const stick = el("joystickStick");
if (!pad || !stick) return;
const center = () => {
const r = pad.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2, radius: r.width / 2 - 24 };
};
const moveStick = (clientX, clientY) => {
const c = center();
let dx = clientX - c.x;
let dy = clientY - c.y;
const dist = Math.hypot(dx, dy);
if (dist > c.radius) {
dx = (dx / dist) * c.radius;
dy = (dy / dist) * c.radius;
}
stick.style.transform = `translate(${dx}px, ${dy}px)`;
const linear = -dy / c.radius;
const angular = dx / c.radius;
if (joystickRaf) cancelAnimationFrame(joystickRaf);
joystickRaf = requestAnimationFrame(() => sendCmdVel(linear, angular));
};
const resetStick = () => {
stick.style.transform = "translate(0, 0)";
sendCmdVel(0, 0);
lastCmd = { linear: 0, angular: 0 };
};
const onDown = (evt) => {
if (!joystickActive) return;
joystickPointerId = evt.pointerId;
pad.setPointerCapture(evt.pointerId);
moveStick(evt.clientX, evt.clientY);
};
const onMove = (evt) => {
if (evt.pointerId !== joystickPointerId) return;
moveStick(evt.clientX, evt.clientY);
};
const onUp = (evt) => {
if (evt.pointerId !== joystickPointerId) return;
joystickPointerId = null;
resetStick();
};
pad.addEventListener("pointerdown", onDown);
pad.addEventListener("pointermove", onMove);
pad.addEventListener("pointerup", onUp);
pad.addEventListener("pointercancel", onUp);
}
function bindEvents() {
if (eventsBound) return;
eventsBound = true;
el("mirSegControl")?.addEventListener("click", () => {
toggleRobotMotion().catch((e) => alert(e.message));
});
el("mirSegStatus")?.addEventListener("click", (evt) => {
evt.stopPropagation();
togglePanel(el("mirSegStatus"), el("mirStatusPanel"));
});
el("mirSegLocale")?.addEventListener("click", (evt) => {
evt.stopPropagation();
togglePanel(el("mirSegLocale"), el("mirLocalePanel"));
});
el("mirUserBtn")?.addEventListener("click", (evt) => {
evt.stopPropagation();
togglePanel(el("mirUserBtn"), el("mirUserPanel"));
});
el("mirErrorResetBtn")?.addEventListener("click", () => {
resetError().catch((e) => alert(e.message));
});
document.querySelectorAll(".mirLocaleOption").forEach((btn) => {
btn.addEventListener("click", (evt) => {
evt.stopPropagation();
applyLocale(btn.dataset.locale || "vi");
closePanels();
});
});
el("mirSegJoystick")?.addEventListener("click", async () => {
if (!canControl()) {
alert(t("topbar.noControlPermission"));
return;
}
try {
if (robotStatus?.joystick_engaged) await engageJoystick(false);
else await engageJoystick(true, el("joystickSpeedSelect")?.value || "fast");
} catch (e) {
alert(e.message);
}
});
el("joystickDisengageBtn")?.addEventListener("click", () => {
engageJoystick(false).catch((e) => alert(e.message));
});
el("joystickSpeedSelect")?.addEventListener("change", (evt) => {
if (robotStatus?.joystick_engaged) {
engageJoystick(true, evt.target.value).catch(() => {});
}
});
document.addEventListener("click", (evt) => {
if (evt.target.closest(".mirSegment") || evt.target.closest(".mirPanel")) return;
closePanels();
});
document.addEventListener("keydown", (evt) => {
if (evt.key === "Escape") {
closePanels();
if (robotStatus?.joystick_engaged) engageJoystick(false).catch(() => {});
}
});
bindJoystickPad();
window.addEventListener("lm:locale-change", () => {
if (robotStatus) renderAll(robotStatus);
});
}
function start() {
loadLocale();
bindEvents();
if (!window.AuthApp?.isReady()) return;
startPoll();
}
function stop() {
stopPoll();
closePanels();
hideJoystickOverlay();
if (window.AuthApp?.isReady?.()) {
engageJoystick(false).catch(() => {});
}
}
async function disengageJoystick() {
hideJoystickOverlay();
if (!window.AuthApp?.isReady?.()) return;
try {
await engageJoystick(false);
} catch {
/* session may already be gone */
}
}
window.TopbarApp = {
t,
getLocale,
applyLocale,
refresh: fetchStatus,
getRobotStatus: () => robotStatus,
hideJoystickOverlay,
disengageJoystick,
updateUserMenu(user) {
const role = (user?.group_name || "USER").toUpperCase();
if (el("mirUserLabel")) el("mirUserLabel").textContent = role;
if (el("mirUserPanelRole")) el("mirUserPanelRole").textContent = role;
if (el("mirUserPanelName")) el("mirUserPanelName").textContent = user?.display_name || user?.username || "—";
if (el("mirProfileDisplayName")) el("mirProfileDisplayName").value = user?.display_name || user?.username || "";
},
};
if (window.AuthApp?.isReady()) start();
else window.addEventListener("lm:auth-ready", () => start(), { once: true });
window.addEventListener("lm:auth-logout", () => stop());
hideJoystickOverlay();
})();