Compare commits
9 Commits
a2e87aeb29
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 199f8c0537 | |||
| 7a850937b0 | |||
| 064c9b5758 | |||
| 365a15c32a | |||
| 90e8e9d252 | |||
| 819323f8c8 | |||
| a6cf06d7eb | |||
| 098e1b2b69 | |||
| 4054d81aaf |
17
.gitignore
vendored
17
.gitignore
vendored
@@ -12,4 +12,19 @@
|
|||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.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
|
||||||
@@ -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
|
||||||
@@ -50,9 +57,11 @@ add_executable(lidar_manager_web
|
|||||||
src/robot/robot_runtime.cpp
|
src/robot/robot_runtime.cpp
|
||||||
src/server/api_mission_routes.cpp
|
src/server/api_mission_routes.cpp
|
||||||
src/server/api_robot_routes.cpp
|
src/server/api_robot_routes.cpp
|
||||||
|
src/server/api_media_routes.cpp
|
||||||
|
src/server/api_dashboard_routes.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads)
|
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads SQLite::SQLite3)
|
||||||
|
|
||||||
target_include_directories(lidar_manager_web PRIVATE
|
target_include_directories(lidar_manager_web PRIVATE
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||||
@@ -83,6 +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
|
||||||
@@ -104,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)
|
||||||
|
|||||||
@@ -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-shm
Normal file
BIN
RBS.db-shm
Normal file
Binary file not shown.
BIN
RBS.db-wal
Normal file
BIN
RBS.db-wal
Normal file
Binary file not shown.
33
README.md
33
README.md
@@ -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
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
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
BIN
data/Denso_1/Denso_1.xloc
Normal file
Binary file not shown.
6
data/Denso_1/Denso_1.yaml
Normal file
6
data/Denso_1/Denso_1.yaml
Normal 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
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
{
|
|
||||||
"groups": [
|
|
||||||
{
|
|
||||||
"allow_pin": false,
|
|
||||||
"id": "group_distributors",
|
|
||||||
"name": "Distributors",
|
|
||||||
"permissions": {
|
|
||||||
"config": "write",
|
|
||||||
"dashboard": "write",
|
|
||||||
"integrations": "write",
|
|
||||||
"missions": "write",
|
|
||||||
"users": "write"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_pin": false,
|
|
||||||
"id": "group_administrators",
|
|
||||||
"name": "Administrators",
|
|
||||||
"permissions": {
|
|
||||||
"config": "write",
|
|
||||||
"dashboard": "write",
|
|
||||||
"integrations": "write",
|
|
||||||
"missions": "write",
|
|
||||||
"users": "write"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_pin": true,
|
|
||||||
"id": "group_users",
|
|
||||||
"name": "Users",
|
|
||||||
"permissions": {
|
|
||||||
"config": "read",
|
|
||||||
"dashboard": "write",
|
|
||||||
"integrations": "read",
|
|
||||||
"missions": "read",
|
|
||||||
"users": "none"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"display_name": "Distributor",
|
|
||||||
"enabled": true,
|
|
||||||
"group_id": "group_distributors",
|
|
||||||
"id": "user_distributor",
|
|
||||||
"password_hash": "e245409d2efb801adfb55abc4f8298deff27e86d9c3ca11a05e1403de3d4cc44",
|
|
||||||
"password_salt": "9c23467cf7b338b6cd27dab6f411135a",
|
|
||||||
"pin_hash": null,
|
|
||||||
"pin_salt": null,
|
|
||||||
"username": "Distributor"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"display_name": "Administrator",
|
|
||||||
"enabled": true,
|
|
||||||
"group_id": "group_administrators",
|
|
||||||
"id": "user_admin",
|
|
||||||
"password_hash": "d07eb95a7364e6fb9fe2ce152e3617dc0f23bb943263c5ca2f77a4cbbf5d5396",
|
|
||||||
"password_salt": "804fec3b7b4910d6bdde1fb3782371e5",
|
|
||||||
"pin_hash": null,
|
|
||||||
"pin_salt": null,
|
|
||||||
"username": "Admin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"display_name": "Operator",
|
|
||||||
"enabled": true,
|
|
||||||
"group_id": "group_users",
|
|
||||||
"id": "user_operator",
|
|
||||||
"password_hash": "b9091e9f6bcbd060231cc2f2e0ae028af88db0bca2af068548cb7604329fbdc9",
|
|
||||||
"password_salt": "d2eedd0b0d2446af5ba875ebcff658f1",
|
|
||||||
"pin_hash": "8dd8a6d52c7b7b76fde819aae2d5d3e3e06b321f71f61ef2918be879ace49d71",
|
|
||||||
"pin_salt": "8d0ec0ed4339dafcb0f099a4c77895a2",
|
|
||||||
"username": "User"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
0
data/maps/.gitkeep
Normal file
0
data/maps/.gitkeep
Normal file
@@ -1,891 +0,0 @@
|
|||||||
{
|
|
||||||
"queue": [
|
|
||||||
{
|
|
||||||
"created_at": "2026-06-15T03:25:12Z",
|
|
||||||
"finished_at": "2026-06-15T03:26:42Z",
|
|
||||||
"id": "6732b109c5f13b8f",
|
|
||||||
"log": [
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Loop endless (simulated, max 10000)",
|
|
||||||
"ts": "2026-06-15T03:25:12Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:12Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:13Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:14Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:14Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:15Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:16Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:17Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:17Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:18Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:18Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:19Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:20Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:22Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:23Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:24Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:24Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:25Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:25Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:26Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:27Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:28Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:28Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:29Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:30Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:31Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:31Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:32Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:32Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:33Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:34Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:35Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:35Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:36Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:37Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:38Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:38Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:39Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:39Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:40Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:41Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:42Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:42Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:43Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:44Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:45Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:45Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:46Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:46Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:47Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:48Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:49Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:49Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:50Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:51Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:52Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:52Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:53Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:53Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:54Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:55Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:56Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:56Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:57Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:58Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:25:59Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:25:59Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:01Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:02Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:03Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:03Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:04Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:05Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:06Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:06Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:07Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:07Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:08Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:09Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:10Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:10Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:11Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:12Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:13Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:13Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:14Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:14Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:15Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:16Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:17Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:17Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:18Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:19Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:20Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:20Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:21Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:22Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:23Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:24Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:24Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:25Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:26Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:27Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:27Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:28Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:28Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:29Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:30Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:31Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:31Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:32Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:33Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:34Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:34Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:35Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:35Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:36Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:37Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:38Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:38Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:39Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:40Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:41Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-15T03:26:41Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-15T03:26:42Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warn",
|
|
||||||
"message": "Mission hủy bởi operator",
|
|
||||||
"ts": "2026-06-15T03:26:42Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"mission": {
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
|
|
||||||
"kind": "action",
|
|
||||||
"label": "Set PLC register",
|
|
||||||
"params": {
|
|
||||||
"action": "set",
|
|
||||||
"register": 1,
|
|
||||||
"value": 0
|
|
||||||
},
|
|
||||||
"type": "set_plc_register"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "a1",
|
|
||||||
"kind": "action",
|
|
||||||
"label": "Wait",
|
|
||||||
"params": {
|
|
||||||
"seconds": 1
|
|
||||||
},
|
|
||||||
"type": "wait"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
|
|
||||||
"kind": "action",
|
|
||||||
"label": "Loop",
|
|
||||||
"params": {
|
|
||||||
"count": 0,
|
|
||||||
"mode": "endless"
|
|
||||||
},
|
|
||||||
"type": "loop"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "",
|
|
||||||
"group": "Missions",
|
|
||||||
"id": "5ae9dbcb0722dffb",
|
|
||||||
"name": "Test run",
|
|
||||||
"updated_at": "2026-06-15T03:08:55.138Z"
|
|
||||||
},
|
|
||||||
"mission_group": "Missions",
|
|
||||||
"mission_id": "5ae9dbcb0722dffb",
|
|
||||||
"mission_name": "Test run",
|
|
||||||
"parameters": {},
|
|
||||||
"priority": 0,
|
|
||||||
"robot_id": "default",
|
|
||||||
"source": "ui",
|
|
||||||
"started_at": "2026-06-15T03:25:12Z",
|
|
||||||
"status": "cancelled"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"created_at": "2026-06-16T09:41:27Z",
|
|
||||||
"finished_at": "2026-06-16T09:41:41Z",
|
|
||||||
"id": "29d42c51d3a96bec",
|
|
||||||
"log": [
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Loop endless (simulated, max 10000)",
|
|
||||||
"ts": "2026-06-16T09:41:28Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-16T09:41:28Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-16T09:41:28Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-16T09:41:29Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-16T09:41:29Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-16T09:41:30Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-16T09:41:31Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-16T09:41:32Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-16T09:41:32Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-16T09:41:33Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-16T09:41:34Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-16T09:41:35Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-16T09:41:35Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-16T09:41:36Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-16T09:41:36Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-16T09:41:37Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-16T09:41:38Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-16T09:41:39Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-16T09:41:39Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Set PLC register (set_plc_register) simulated",
|
|
||||||
"ts": "2026-06-16T09:41:40Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-16T09:41:41Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warn",
|
|
||||||
"message": "Mission hủy bởi operator",
|
|
||||||
"ts": "2026-06-16T09:41:41Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"mission": {
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
|
|
||||||
"kind": "action",
|
|
||||||
"label": "Set PLC register",
|
|
||||||
"params": {
|
|
||||||
"action": "set",
|
|
||||||
"register": 1,
|
|
||||||
"value": 0
|
|
||||||
},
|
|
||||||
"type": "set_plc_register"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "a1",
|
|
||||||
"kind": "action",
|
|
||||||
"label": "Wait",
|
|
||||||
"params": {
|
|
||||||
"seconds": 1
|
|
||||||
},
|
|
||||||
"type": "wait"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
|
|
||||||
"kind": "action",
|
|
||||||
"label": "Loop",
|
|
||||||
"params": {
|
|
||||||
"count": 0,
|
|
||||||
"mode": "endless"
|
|
||||||
},
|
|
||||||
"type": "loop"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "",
|
|
||||||
"group": "Missions",
|
|
||||||
"id": "5ae9dbcb0722dffb",
|
|
||||||
"name": "Test run",
|
|
||||||
"updated_at": "2026-06-15T03:08:55.138Z"
|
|
||||||
},
|
|
||||||
"mission_group": "Missions",
|
|
||||||
"mission_id": "5ae9dbcb0722dffb",
|
|
||||||
"mission_name": "Test run",
|
|
||||||
"parameters": {},
|
|
||||||
"priority": 0,
|
|
||||||
"robot_id": "default",
|
|
||||||
"source": "ui",
|
|
||||||
"started_at": "2026-06-16T09:41:28Z",
|
|
||||||
"status": "cancelled"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"runner": {
|
|
||||||
"current_action": null,
|
|
||||||
"current_queue_id": null,
|
|
||||||
"message": "Đã hủy: Test run",
|
|
||||||
"paused": false,
|
|
||||||
"state": "idle",
|
|
||||||
"updated_at": "2026-06-16T09:41:41Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
{
|
|
||||||
"dashboard": {
|
|
||||||
"widgets": []
|
|
||||||
},
|
|
||||||
"groups": [
|
|
||||||
"Missions",
|
|
||||||
"Move",
|
|
||||||
"Logic",
|
|
||||||
"I/O",
|
|
||||||
"Cart",
|
|
||||||
"Misc"
|
|
||||||
],
|
|
||||||
"missions": [
|
|
||||||
{
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
|
|
||||||
"kind": "action",
|
|
||||||
"label": "Set PLC register",
|
|
||||||
"params": {
|
|
||||||
"action": "set",
|
|
||||||
"register": 1,
|
|
||||||
"value": 0
|
|
||||||
},
|
|
||||||
"type": "set_plc_register"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "a1",
|
|
||||||
"kind": "action",
|
|
||||||
"label": "Wait",
|
|
||||||
"params": {
|
|
||||||
"seconds": 1
|
|
||||||
},
|
|
||||||
"type": "wait"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
|
|
||||||
"kind": "action",
|
|
||||||
"label": "Loop",
|
|
||||||
"params": {
|
|
||||||
"count": 0,
|
|
||||||
"mode": "endless"
|
|
||||||
},
|
|
||||||
"type": "loop"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "",
|
|
||||||
"group": "Missions",
|
|
||||||
"id": "5ae9dbcb0722dffb",
|
|
||||||
"name": "Test run",
|
|
||||||
"updated_at": "2026-06-15T03:08:55.138Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"id": "a1",
|
|
||||||
"kind": "action",
|
|
||||||
"label": "Wait",
|
|
||||||
"params": {
|
|
||||||
"seconds": 1
|
|
||||||
},
|
|
||||||
"type": "wait"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "",
|
|
||||||
"group": "Missions",
|
|
||||||
"id": "68950059fc0bd633",
|
|
||||||
"name": "Test run 3",
|
|
||||||
"updated_at": "2026-06-13T04:45:08Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"robots": [
|
|
||||||
{
|
|
||||||
"id": "default",
|
|
||||||
"name": "Robot chính",
|
|
||||||
"online": true,
|
|
||||||
"serial": "PX-001"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"schedules": [],
|
|
||||||
"triggers": [],
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
{
|
|
||||||
"created_at": "2026-05-29T08:27:25Z",
|
|
||||||
"id": "a07ab938d9029ef1",
|
|
||||||
"imus": [
|
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"frame_id": "imu_link",
|
|
||||||
"id": "f7ddb6d2c3c1c5cf",
|
|
||||||
"name": "IMU test",
|
|
||||||
"rate_hz": 100,
|
|
||||||
"source": "onboard",
|
|
||||||
"topic": "imu/data"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"imuPoses": {
|
|
||||||
"f7ddb6d2c3c1c5cf": {
|
|
||||||
"x": 196.14886948882076,
|
|
||||||
"y": 0.1286840744156286,
|
|
||||||
"yaw_deg": 0,
|
|
||||||
"z": 0.1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"imuPosesFrame": "robot",
|
|
||||||
"lidarPoses": {
|
|
||||||
"02c4b7f4de7bd639": {
|
|
||||||
"theta_deg": 45,
|
|
||||||
"x": 215,
|
|
||||||
"y": 40
|
|
||||||
},
|
|
||||||
"1e591c93c581f705": {
|
|
||||||
"theta_deg": -45,
|
|
||||||
"x": 215.39984362180326,
|
|
||||||
"y": -40
|
|
||||||
},
|
|
||||||
"242be6d6e782ecdf": {
|
|
||||||
"theta_deg": 180,
|
|
||||||
"x": 145,
|
|
||||||
"y": -0.3738614899159438
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lidarPosesFrame": "robot",
|
|
||||||
"lidarPositions": {},
|
|
||||||
"map": {
|
|
||||||
"height": 600,
|
|
||||||
"width": 800
|
|
||||||
},
|
|
||||||
"robot": {
|
|
||||||
"bicycle": {
|
|
||||||
"display": {
|
|
||||||
"L_px": 240.0,
|
|
||||||
"r_px": 60.0,
|
|
||||||
"scale_m_per_px": 0.005
|
|
||||||
},
|
|
||||||
"drive": {
|
|
||||||
"joint_name": "rear_wheel_joint"
|
|
||||||
},
|
|
||||||
"limits": {
|
|
||||||
"cmd_vel_timeout_s": 0.25,
|
|
||||||
"linear": {
|
|
||||||
"max_acceleration": 0.8,
|
|
||||||
"max_velocity": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"steer": {
|
|
||||||
"joint_name": "front_steer_joint",
|
|
||||||
"max_angle_deg": 60,
|
|
||||||
"preview_deg": 15
|
|
||||||
},
|
|
||||||
"wheel_radius_m": 0.15,
|
|
||||||
"wheelbase_m": 1.2,
|
|
||||||
"wheels": [
|
|
||||||
{
|
|
||||||
"id": "rear",
|
|
||||||
"joint_name": "rear_wheel_joint",
|
|
||||||
"motor": {
|
|
||||||
"gear_ratio": 20,
|
|
||||||
"invert": false,
|
|
||||||
"model": "m2dc10a",
|
|
||||||
"vendor": "moons"
|
|
||||||
},
|
|
||||||
"role": "drive",
|
|
||||||
"x_m": 0,
|
|
||||||
"y_m": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "front",
|
|
||||||
"joint_name": "front_steer_joint",
|
|
||||||
"motor": {
|
|
||||||
"gear_ratio": 20,
|
|
||||||
"invert": false,
|
|
||||||
"model": "m2dc10a",
|
|
||||||
"vendor": "moons"
|
|
||||||
},
|
|
||||||
"role": "steer",
|
|
||||||
"x_m": 1.2,
|
|
||||||
"y_m": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"diff": {
|
|
||||||
"b": 200.0,
|
|
||||||
"d": 120.0,
|
|
||||||
"display": {
|
|
||||||
"b_px": 200.0,
|
|
||||||
"d_px": 120.0,
|
|
||||||
"scale_m_per_px": 0.005
|
|
||||||
},
|
|
||||||
"limits": {
|
|
||||||
"angular": {
|
|
||||||
"max_acceleration": 1.5,
|
|
||||||
"max_velocity": 1.7
|
|
||||||
},
|
|
||||||
"cmd_vel_timeout_s": 0.25,
|
|
||||||
"linear": {
|
|
||||||
"max_acceleration": 0.8,
|
|
||||||
"max_velocity": 1,
|
|
||||||
"min_acceleration": -0.4,
|
|
||||||
"min_velocity": -0.5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"wheel_radius_m": 0.3,
|
|
||||||
"wheel_radius_multiplier": 1,
|
|
||||||
"wheel_separation_m": 1,
|
|
||||||
"wheel_separation_multiplier": 1,
|
|
||||||
"wheels": [
|
|
||||||
{
|
|
||||||
"id": "left",
|
|
||||||
"joint_name": "wheel_left_joint",
|
|
||||||
"motor": {
|
|
||||||
"gear_ratio": 20,
|
|
||||||
"invert": false,
|
|
||||||
"model": "m2dc10a",
|
|
||||||
"vendor": "moons"
|
|
||||||
},
|
|
||||||
"side": "left",
|
|
||||||
"y_m": 0.5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "right",
|
|
||||||
"joint_name": "wheel_right_joint",
|
|
||||||
"motor": {
|
|
||||||
"gear_ratio": 20,
|
|
||||||
"invert": false,
|
|
||||||
"model": "m2dc10a",
|
|
||||||
"vendor": "moons"
|
|
||||||
},
|
|
||||||
"side": "right",
|
|
||||||
"y_m": -0.5
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"footprint": [
|
|
||||||
{
|
|
||||||
"x": 249.49596246923238,
|
|
||||||
"y": 76.53128468019501
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 252.05138984920825,
|
|
||||||
"y": -73.40426803273583
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 146.0988213814129,
|
|
||||||
"y": -73.14624094113161
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 146.4579317148541,
|
|
||||||
"y": -36.76005121552378
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": -24.190052366845578,
|
|
||||||
"y": -36.232153738354725
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": -23.18092513013994,
|
|
||||||
"y": 31.895774646867324
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 149.1507088069675,
|
|
||||||
"y": 31.363038836025066
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 148.2973527630072,
|
|
||||||
"y": 77.68471811183447
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"footprint_params": {
|
|
||||||
"length_m": 1.69,
|
|
||||||
"radius_m": 0.8432486399759678,
|
|
||||||
"segments": 32,
|
|
||||||
"sides": 6,
|
|
||||||
"width_m": 1.28
|
|
||||||
},
|
|
||||||
"footprint_shape": "custom",
|
|
||||||
"frame_id": "base_footprint",
|
|
||||||
"model": "bicycle",
|
|
||||||
"x": 400,
|
|
||||||
"y": 300,
|
|
||||||
"yaw_deg": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lidars": [
|
|
||||||
{
|
|
||||||
"id": "02c4b7f4de7bd639",
|
|
||||||
"ip": "192.168.1.11",
|
|
||||||
"name": "Front",
|
|
||||||
"port": 2112
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "1e591c93c581f705",
|
|
||||||
"ip": "192.168.1.12",
|
|
||||||
"name": "Back",
|
|
||||||
"port": 2112
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "242be6d6e782ecdf",
|
|
||||||
"ip": "192.168.1.15",
|
|
||||||
"name": "Oile",
|
|
||||||
"port": 2112
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "Mặc định",
|
|
||||||
"updated_at": "2026-05-29T10:09:07Z"
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
{
|
|
||||||
"created_at": "2026-05-29T08:40:51Z",
|
|
||||||
"id": "ea89e39c835c0557",
|
|
||||||
"imus": [
|
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"frame_id": "imu_link",
|
|
||||||
"id": "719a21772e114466",
|
|
||||||
"name": "IMU",
|
|
||||||
"rate_hz": 100,
|
|
||||||
"source": "external",
|
|
||||||
"topic": "imu/data"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"layout": {
|
|
||||||
"imuPoses": {
|
|
||||||
"719a21772e114466": {
|
|
||||||
"x": 0.06910131801805619,
|
|
||||||
"y": 0.8135664703630141,
|
|
||||||
"yaw_deg": 0,
|
|
||||||
"z": 0.1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"imuPosesFrame": "robot",
|
|
||||||
"lidarPoses": {
|
|
||||||
"40235913b52d8101": {
|
|
||||||
"theta_deg": -135,
|
|
||||||
"x": -120,
|
|
||||||
"y": -90
|
|
||||||
},
|
|
||||||
"f4504deeb605e6ed": {
|
|
||||||
"theta_deg": 45,
|
|
||||||
"x": 120,
|
|
||||||
"y": 90
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lidarPosesFrame": "robot",
|
|
||||||
"lidarPositions": {},
|
|
||||||
"map": {
|
|
||||||
"height": 600,
|
|
||||||
"width": 800
|
|
||||||
},
|
|
||||||
"robot": {
|
|
||||||
"bicycle": {
|
|
||||||
"display": {
|
|
||||||
"L_px": 240.0,
|
|
||||||
"r_px": 60.0,
|
|
||||||
"scale_m_per_px": 0.005
|
|
||||||
},
|
|
||||||
"drive": {
|
|
||||||
"joint_name": "rear_wheel_joint"
|
|
||||||
},
|
|
||||||
"limits": {
|
|
||||||
"cmd_vel_timeout_s": 0.25,
|
|
||||||
"linear": {
|
|
||||||
"max_acceleration": 0.8,
|
|
||||||
"max_velocity": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"steer": {
|
|
||||||
"joint_name": "front_steer_joint",
|
|
||||||
"max_angle_deg": 60,
|
|
||||||
"preview_deg": 15
|
|
||||||
},
|
|
||||||
"wheel_radius_m": 0.15,
|
|
||||||
"wheelbase_m": 1.2,
|
|
||||||
"wheels": [
|
|
||||||
{
|
|
||||||
"id": "rear",
|
|
||||||
"joint_name": "rear_wheel_joint",
|
|
||||||
"motor": {
|
|
||||||
"gear_ratio": 20,
|
|
||||||
"invert": false,
|
|
||||||
"model": "m2dc10a",
|
|
||||||
"vendor": "moons"
|
|
||||||
},
|
|
||||||
"role": "drive",
|
|
||||||
"x_m": 0,
|
|
||||||
"y_m": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "front",
|
|
||||||
"joint_name": "front_steer_joint",
|
|
||||||
"motor": {
|
|
||||||
"gear_ratio": 20,
|
|
||||||
"invert": false,
|
|
||||||
"model": "m2dc10a",
|
|
||||||
"vendor": "moons"
|
|
||||||
},
|
|
||||||
"role": "steer",
|
|
||||||
"x_m": 1.2,
|
|
||||||
"y_m": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"diff": {
|
|
||||||
"b": 220.0,
|
|
||||||
"d": 120.0,
|
|
||||||
"display": {
|
|
||||||
"b_px": 220.0,
|
|
||||||
"d_px": 120.0,
|
|
||||||
"scale_m_per_px": 0.005
|
|
||||||
},
|
|
||||||
"limits": {
|
|
||||||
"angular": {
|
|
||||||
"max_acceleration": 1.5,
|
|
||||||
"max_velocity": 1.7
|
|
||||||
},
|
|
||||||
"cmd_vel_timeout_s": 0.25,
|
|
||||||
"linear": {
|
|
||||||
"max_acceleration": 0.8,
|
|
||||||
"max_velocity": 1,
|
|
||||||
"min_acceleration": -0.8,
|
|
||||||
"min_velocity": -0.5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"wheel_radius_m": 0.3,
|
|
||||||
"wheel_radius_multiplier": 1,
|
|
||||||
"wheel_separation_m": 1.1,
|
|
||||||
"wheel_separation_multiplier": 1,
|
|
||||||
"wheels": [
|
|
||||||
{
|
|
||||||
"id": "left",
|
|
||||||
"joint_name": "wheel_left_joint",
|
|
||||||
"motor": {
|
|
||||||
"gear_ratio": 10,
|
|
||||||
"invert": true,
|
|
||||||
"model": "m2dc10a",
|
|
||||||
"vendor": "moons"
|
|
||||||
},
|
|
||||||
"side": "left",
|
|
||||||
"y_m": 0.55
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "right",
|
|
||||||
"joint_name": "wheel_right_joint",
|
|
||||||
"motor": {
|
|
||||||
"gear_ratio": 10,
|
|
||||||
"invert": false,
|
|
||||||
"model": "m2dc10a",
|
|
||||||
"vendor": "moons"
|
|
||||||
},
|
|
||||||
"side": "right",
|
|
||||||
"y_m": -0.55
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"footprint": [
|
|
||||||
{
|
|
||||||
"x": 150,
|
|
||||||
"y": 120
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 150,
|
|
||||||
"y": -120
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": -150,
|
|
||||||
"y": -120
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": -150,
|
|
||||||
"y": 120
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"footprint_params": {
|
|
||||||
"length_m": 1.5,
|
|
||||||
"radius_m": 1,
|
|
||||||
"segments": 32,
|
|
||||||
"sides": 6,
|
|
||||||
"width_m": 1.2
|
|
||||||
},
|
|
||||||
"footprint_shape": "rectangle",
|
|
||||||
"frame_id": "base_footprint",
|
|
||||||
"model": "diff",
|
|
||||||
"x": 400,
|
|
||||||
"y": 300,
|
|
||||||
"yaw_deg": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lidars": [
|
|
||||||
{
|
|
||||||
"id": "f4504deeb605e6ed",
|
|
||||||
"ip": "192.168.1.11",
|
|
||||||
"name": "Front",
|
|
||||||
"port": 2112
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "40235913b52d8101",
|
|
||||||
"ip": "192.168.1.11",
|
|
||||||
"name": "Back",
|
|
||||||
"port": 2112
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "T800",
|
|
||||||
"updated_at": "2026-06-13T07:03:01Z"
|
|
||||||
}
|
|
||||||
0
data/recordings/.gitkeep
Normal file
0
data/recordings/.gitkeep
Normal file
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"battery_charging": false,
|
|
||||||
"battery_percent": 54,
|
|
||||||
"cmd_angular": 0.0,
|
|
||||||
"cmd_linear": 0.0,
|
|
||||||
"error": null,
|
|
||||||
"health": "ok",
|
|
||||||
"joystick_engaged": false,
|
|
||||||
"joystick_speed": "fast",
|
|
||||||
"message": "Robot paused",
|
|
||||||
"motion": "paused",
|
|
||||||
"updated_at": "2026-06-16T03:40:34Z"
|
|
||||||
}
|
|
||||||
0
data/sounds/.gitkeep
Normal file
0
data/sounds/.gitkeep
Normal file
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"active_layout_id": "ea89e39c835c0557",
|
|
||||||
"layouts": [
|
|
||||||
{
|
|
||||||
"created_at": "2026-05-29T08:27:25Z",
|
|
||||||
"id": "a07ab938d9029ef1",
|
|
||||||
"imu_count": 1,
|
|
||||||
"lidar_count": 3,
|
|
||||||
"model": "bicycle",
|
|
||||||
"name": "Mặc định",
|
|
||||||
"updated_at": "2026-05-29T10:09:07Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"created_at": "2026-05-29T08:40:51Z",
|
|
||||||
"id": "ea89e39c835c0557",
|
|
||||||
"imu_count": 1,
|
|
||||||
"lidar_count": 2,
|
|
||||||
"model": "diff",
|
|
||||||
"name": "T800",
|
|
||||||
"updated_at": "2026-06-13T07:03:01Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
name: rbs
|
||||||
|
|
||||||
services:
|
services:
|
||||||
lidar-manager:
|
lidar-manager:
|
||||||
build: .
|
build: .
|
||||||
image: lidar-manager-web:test3
|
image: lidar-manager-web:RBS
|
||||||
container_name: lidar-manager-limited
|
container_name: lidar-manager-limited
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Giao diện web trên robot: **responsive** (PC, tablet, portrait/landscape). Tr
|
|||||||
|
|
||||||
### 2.1 Signing in
|
### 2.1 Signing in
|
||||||
|
|
||||||
> **Test3:** tính năng đã triển khai — xem [Test3 — Signing in](#test3--signing-in-đã-triển-khai).
|
> **RBS:** tính năng đã triển khai — xem [RBS — Signing in](#RBS--signing-in-đã-triển-khai).
|
||||||
|
|
||||||
#### Luồng truy cập (MiR)
|
#### Luồng truy cập (MiR)
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ Admin có thể tạo thêm user group (ví dụ `Operators`) và gán quyền t
|
|||||||
- Không nên nhiều người dùng chung một account.
|
- Không nên nhiều người dùng chung một account.
|
||||||
- SW mới (~2023): **auto sign-out** theo user group; MiR Fleet hỗ trợ **OAuth 2.0 / OpenID Connect**.
|
- SW mới (~2023): **auto sign-out** theo user group; MiR Fleet hỗ trợ **OAuth 2.0 / OpenID Connect**.
|
||||||
|
|
||||||
#### Test3 — Signing in (đã triển khai)
|
#### RBS — Signing in (đã triển khai)
|
||||||
|
|
||||||
Tính năng đăng nhập theo MiR §2.1 đã tích hợp vào `lidar_manager_web`. Toàn bộ API (trừ health/login/logout) yêu cầu session; UI bị chặn cho đến khi đăng nhập thành công.
|
Tính năng đăng nhập theo MiR §2.1 đã tích hợp vào `lidar_manager_web`. Toàn bộ API (trừ health/login/logout) yêu cầu session; UI bị chặn cho đến khi đăng nhập thành công.
|
||||||
|
|
||||||
@@ -214,9 +214,9 @@ Hash: SHA-256 + salt (`sha256(salt:password)` / `sha256(salt:pin:pin)`).
|
|||||||
- Docker: `www/` copy lúc build → `docker compose up --build -d` sau sửa UI.
|
- Docker: `www/` copy lúc build → `docker compose up --build -d` sau sửa UI.
|
||||||
- Hard refresh (`Ctrl+Shift+R`) nếu cache JS/CSS.
|
- Hard refresh (`Ctrl+Shift+R`) nếu cache JS/CSS.
|
||||||
|
|
||||||
##### So sánh MiR ↔ Test3
|
##### So sánh MiR ↔ RBS
|
||||||
|
|
||||||
| MiR §2.1 | Test3 |
|
| MiR §2.1 | RBS |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| Sign in bắt buộc | Có |
|
| Sign in bắt buộc | Có |
|
||||||
| Tab password \| PIN + keypad | Có |
|
| Tab password \| PIN + keypad | Có |
|
||||||
@@ -374,12 +374,12 @@ Thiết lập hệ thống: map → chỉnh map (positions, zones) → missions.
|
|||||||
|
|
||||||
## 7. Dashboard widgets
|
## 7. Dashboard widgets
|
||||||
|
|
||||||
| Widget MiR | Test3 (Cách B) |
|
| Widget MiR | RBS (Cách B) |
|
||||||
|------------|----------------|
|
|------------|----------------|
|
||||||
| Mission button | `dashboard.js` — mission_button |
|
| Mission button | `dashboard.js` — mission_button |
|
||||||
| Mission group | mission_group |
|
| Mission group | mission_group |
|
||||||
| Mission queue | mission_queue |
|
| Mission queue | mission_queue |
|
||||||
| Pause/Continue | pause_continue (+ **Hủy mission** bổ sung trong Test3) |
|
| Pause/Continue | pause_continue (+ **Hủy mission** bổ sung trong RBS) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -423,9 +423,9 @@ Xác thực: HTTP Basic (user/password robot).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Mapping sang dự án Test3
|
## 10. Mapping sang dự án RBS
|
||||||
|
|
||||||
| Khái niệm MiR Reference Guide | Test3 |
|
| Khái niệm MiR Reference Guide | RBS |
|
||||||
|------------------------------|--------|
|
|------------------------------|--------|
|
||||||
| Setup → Missions → queue | **Cách A** — `www/missions.js` |
|
| Setup → Missions → queue | **Cách A** — `www/missions.js` |
|
||||||
| Dashboard widgets | **Cách B** — `www/dashboard.js` |
|
| Dashboard widgets | **Cách B** — `www/dashboard.js` |
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Scripts Test3
|
# Scripts RBS
|
||||||
|
|
||||||
CLI thống nhất: `./scripts/lm.sh <nhóm> <lệnh>`
|
CLI thống nhất: `./scripts/lm.sh <nhóm> <lệnh>`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Shared paths and helpers for Test3 scripts.
|
# Shared paths and helpers for RBS scripts.
|
||||||
# shellcheck shell=bash
|
# shellcheck shell=bash
|
||||||
|
|
||||||
_lm_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
_lm_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# PhenikaaX Test3 — CLI gom script theo nhóm.
|
# PhenikaaX RBS — CLI gom script theo nhóm.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
#include "robot/robot_runtime.hpp"
|
#include "robot/robot_runtime.hpp"
|
||||||
#include "server/api_server.hpp"
|
#include "server/api_server.hpp"
|
||||||
#include "server/static_file_server.hpp"
|
#include "server/static_file_server.hpp"
|
||||||
|
#include "storage/dashboard_store.hpp"
|
||||||
|
#include "storage/database.hpp"
|
||||||
|
#include "storage/map_store.hpp"
|
||||||
|
#include "storage/site_store.hpp"
|
||||||
|
#include "storage/sound_store.hpp"
|
||||||
#include "storage/state_repository.hpp"
|
#include "storage/state_repository.hpp"
|
||||||
|
|
||||||
#include <httplib.h>
|
#include <httplib.h>
|
||||||
@@ -16,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)
|
||||||
@@ -25,14 +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);
|
||||||
RobotRuntime robot_runtime(data_path_.parent_path() / "robot_runtime.json", mission_queue);
|
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;
|
||||||
@@ -44,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, robot_runtime);
|
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");
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -164,7 +208,17 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
|
|||||||
path.rfind("/api/robots", 0) == 0 || path.rfind("/api/fleet", 0) == 0 ||
|
path.rfind("/api/robots", 0) == 0 || path.rfind("/api/fleet", 0) == 0 ||
|
||||||
path.rfind("/api/modbus", 0) == 0 || path.rfind("/api/v2.0.0/", 0) == 0)
|
path.rfind("/api/modbus", 0) == 0 || path.rfind("/api/v2.0.0/", 0) == 0)
|
||||||
return "integrations";
|
return "integrations";
|
||||||
if (path.rfind("/api/", 0) == 0)
|
if (path.rfind("/api/dashboards", 0) == 0)
|
||||||
|
return "dashboard";
|
||||||
|
if (path.rfind("/api/sounds", 0) == 0)
|
||||||
|
return "integrations";
|
||||||
|
if (path == "/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;
|
||||||
}
|
}
|
||||||
@@ -637,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");
|
||||||
@@ -708,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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -55,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_;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ int main(int argc, char** argv)
|
|||||||
const int port = (argc >= 2) ? std::atoi(argv[1]) : 8080;
|
const int port = (argc >= 2) ? std::atoi(argv[1]) : 8080;
|
||||||
const std::filesystem::path www_root = (argc >= 3) ? std::filesystem::path(argv[2]) : std::filesystem::path("www");
|
const std::filesystem::path www_root = (argc >= 3) ? std::filesystem::path(argv[2]) : std::filesystem::path("www");
|
||||||
const std::filesystem::path data_path =
|
const std::filesystem::path data_path =
|
||||||
(argc >= 4) ? std::filesystem::path(argv[3]) : std::filesystem::path("data/state.json");
|
(argc >= 4) ? std::filesystem::path(argv[3]) : std::filesystem::path("data/RBS.db");
|
||||||
|
|
||||||
lm::LidarManagerApp app(port, www_root, data_path);
|
lm::LidarManagerApp app(port, www_root, data_path);
|
||||||
return app.run();
|
return app.run();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#include "mission/mission_queue.hpp"
|
#include "mission/mission_queue.hpp"
|
||||||
|
|
||||||
#include "util/file_util.hpp"
|
#include "storage/database.hpp"
|
||||||
#include "util/id_util.hpp"
|
#include "util/id_util.hpp"
|
||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
@@ -40,7 +40,7 @@ double paramNumber(const nlohmann::json& params, const std::string& key, double
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
MissionQueue::MissionQueue(std::filesystem::path queue_path) : queue_path_(std::move(queue_path))
|
MissionQueue::MissionQueue(Database& db) : db_(db)
|
||||||
{
|
{
|
||||||
load();
|
load();
|
||||||
ensureRunnerDefaults();
|
ensureRunnerDefaults();
|
||||||
@@ -60,22 +60,15 @@ void MissionQueue::load()
|
|||||||
std::lock_guard<std::recursive_mutex> lock(mu_);
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
queue_ = nlohmann::json::array();
|
queue_ = nlohmann::json::array();
|
||||||
runner_ = nlohmann::json::object();
|
runner_ = nlohmann::json::object();
|
||||||
if (!std::filesystem::exists(queue_path_))
|
nlohmann::json parsed;
|
||||||
|
if (!db_.getDocument("mission_queue", parsed))
|
||||||
return;
|
return;
|
||||||
try
|
if (parsed.is_object())
|
||||||
{
|
{
|
||||||
const auto parsed = nlohmann::json::parse(FileUtil::readBinary(queue_path_));
|
if (parsed.contains("queue") && parsed["queue"].is_array())
|
||||||
if (parsed.is_object())
|
queue_ = parsed["queue"];
|
||||||
{
|
if (parsed.contains("runner") && parsed["runner"].is_object())
|
||||||
if (parsed.contains("queue") && parsed["queue"].is_array())
|
runner_ = parsed["runner"];
|
||||||
queue_ = parsed["queue"];
|
|
||||||
if (parsed.contains("runner") && parsed["runner"].is_object())
|
|
||||||
runner_ = parsed["runner"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (...)
|
|
||||||
{
|
|
||||||
queue_ = nlohmann::json::array();
|
|
||||||
}
|
}
|
||||||
ensureRunnerDefaults();
|
ensureRunnerDefaults();
|
||||||
}
|
}
|
||||||
@@ -83,7 +76,7 @@ void MissionQueue::load()
|
|||||||
void MissionQueue::saveUnlocked() const
|
void MissionQueue::saveUnlocked() const
|
||||||
{
|
{
|
||||||
const nlohmann::json payload = {{"queue", queue_}, {"runner", runner_}};
|
const nlohmann::json payload = {{"queue", queue_}, {"runner", runner_}};
|
||||||
FileUtil::writeBinaryAtomic(queue_path_, payload.dump(2));
|
db_.setDocument("mission_queue", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MissionQueue::ensureRunnerDefaults()
|
void MissionQueue::ensureRunnerDefaults()
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <filesystem>
|
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -11,10 +10,12 @@
|
|||||||
|
|
||||||
namespace lm {
|
namespace lm {
|
||||||
|
|
||||||
|
class Database;
|
||||||
|
|
||||||
class MissionQueue
|
class MissionQueue
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit MissionQueue(std::filesystem::path queue_path);
|
explicit MissionQueue(Database& db);
|
||||||
~MissionQueue();
|
~MissionQueue();
|
||||||
|
|
||||||
MissionQueue(const MissionQueue&) = delete;
|
MissionQueue(const MissionQueue&) = delete;
|
||||||
@@ -34,7 +35,7 @@ public:
|
|||||||
private:
|
private:
|
||||||
enum class LoopControl { None, Break, Continue };
|
enum class LoopControl { None, Break, Continue };
|
||||||
|
|
||||||
std::filesystem::path queue_path_;
|
Database& db_;
|
||||||
mutable std::recursive_mutex mu_;
|
mutable std::recursive_mutex mu_;
|
||||||
nlohmann::json queue_;
|
nlohmann::json queue_;
|
||||||
nlohmann::json runner_;
|
nlohmann::json runner_;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#include "mission/mission_store.hpp"
|
#include "mission/mission_store.hpp"
|
||||||
|
|
||||||
#include "util/file_util.hpp"
|
#include "storage/database.hpp"
|
||||||
#include "util/id_util.hpp"
|
#include "util/id_util.hpp"
|
||||||
#include "util/string_util.hpp"
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ bool coilIdValid(int coil_id)
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
MissionStore::MissionStore(std::filesystem::path store_path) : store_path_(std::move(store_path))
|
MissionStore::MissionStore(Database& db) : db_(db)
|
||||||
{
|
{
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
@@ -27,21 +27,10 @@ void MissionStore::load()
|
|||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
data_ = nlohmann::json::object();
|
data_ = nlohmann::json::object();
|
||||||
if (!std::filesystem::exists(store_path_))
|
const bool existed = db_.getDocument("missions", data_);
|
||||||
{
|
|
||||||
ensureSchemaUnlocked();
|
|
||||||
saveUnlocked();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try
|
|
||||||
{
|
|
||||||
data_ = nlohmann::json::parse(FileUtil::readBinary(store_path_));
|
|
||||||
}
|
|
||||||
catch (...)
|
|
||||||
{
|
|
||||||
data_ = nlohmann::json::object();
|
|
||||||
}
|
|
||||||
ensureSchemaUnlocked();
|
ensureSchemaUnlocked();
|
||||||
|
if (!existed)
|
||||||
|
saveUnlocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MissionStore::ensureSchemaUnlocked()
|
void MissionStore::ensureSchemaUnlocked()
|
||||||
@@ -69,7 +58,7 @@ void MissionStore::ensureSchemaUnlocked()
|
|||||||
|
|
||||||
void MissionStore::saveUnlocked() const
|
void MissionStore::saveUnlocked() const
|
||||||
{
|
{
|
||||||
FileUtil::writeBinaryAtomic(store_path_, data_.dump(2));
|
db_.setDocument("missions", data_);
|
||||||
}
|
}
|
||||||
|
|
||||||
nlohmann::json MissionStore::snapshot() const
|
nlohmann::json MissionStore::snapshot() const
|
||||||
|
|||||||
@@ -2,17 +2,18 @@
|
|||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
#include <filesystem>
|
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace lm {
|
namespace lm {
|
||||||
|
|
||||||
|
class Database;
|
||||||
|
|
||||||
class MissionStore
|
class MissionStore
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit MissionStore(std::filesystem::path store_path);
|
explicit MissionStore(Database& db);
|
||||||
|
|
||||||
nlohmann::json snapshot() const;
|
nlohmann::json snapshot() const;
|
||||||
bool replace(const nlohmann::json& payload, std::string& err);
|
bool replace(const nlohmann::json& payload, std::string& err);
|
||||||
@@ -34,7 +35,7 @@ public:
|
|||||||
nlohmann::json listRobots() const;
|
nlohmann::json listRobots() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::filesystem::path store_path_;
|
Database& db_;
|
||||||
mutable std::mutex mu_;
|
mutable std::mutex mu_;
|
||||||
nlohmann::json data_;
|
nlohmann::json data_;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#include "robot/robot_runtime.hpp"
|
#include "robot/robot_runtime.hpp"
|
||||||
|
|
||||||
#include "mission/mission_queue.hpp"
|
#include "mission/mission_queue.hpp"
|
||||||
#include "util/file_util.hpp"
|
#include "storage/database.hpp"
|
||||||
#include "util/id_util.hpp"
|
#include "util/id_util.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -16,8 +16,8 @@ constexpr const char* kDefaultMessage = "Waiting for new missions...";
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
RobotRuntime::RobotRuntime(std::filesystem::path runtime_path, MissionQueue& mission_queue)
|
RobotRuntime::RobotRuntime(Database& db, MissionQueue& mission_queue)
|
||||||
: runtime_path_(std::move(runtime_path)), mission_queue_(mission_queue)
|
: db_(db), mission_queue_(mission_queue)
|
||||||
{
|
{
|
||||||
load();
|
load();
|
||||||
ensureDefaultsUnlocked();
|
ensureDefaultsUnlocked();
|
||||||
@@ -27,23 +27,14 @@ void RobotRuntime::load()
|
|||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
state_ = nlohmann::json::object();
|
state_ = nlohmann::json::object();
|
||||||
if (!std::filesystem::exists(runtime_path_))
|
nlohmann::json parsed;
|
||||||
return;
|
if (db_.getDocument("robot_runtime", parsed) && parsed.is_object())
|
||||||
try
|
state_ = parsed;
|
||||||
{
|
|
||||||
const auto parsed = nlohmann::json::parse(FileUtil::readBinary(runtime_path_));
|
|
||||||
if (parsed.is_object())
|
|
||||||
state_ = parsed;
|
|
||||||
}
|
|
||||||
catch (...)
|
|
||||||
{
|
|
||||||
state_ = nlohmann::json::object();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void RobotRuntime::saveUnlocked() const
|
void RobotRuntime::saveUnlocked() const
|
||||||
{
|
{
|
||||||
FileUtil::writeBinaryAtomic(runtime_path_, state_.dump(2));
|
db_.setDocument("robot_runtime", state_);
|
||||||
}
|
}
|
||||||
|
|
||||||
void RobotRuntime::ensureDefaultsUnlocked()
|
void RobotRuntime::ensureDefaultsUnlocked()
|
||||||
@@ -72,6 +63,10 @@ void RobotRuntime::ensureDefaultsUnlocked()
|
|||||||
state_["cmd_angular"] = 0.0;
|
state_["cmd_angular"] = 0.0;
|
||||||
if (!state_.contains("updated_at"))
|
if (!state_.contains("updated_at"))
|
||||||
state_["updated_at"] = IdUtil::nowIso8601();
|
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();
|
saveUnlocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +108,8 @@ nlohmann::json RobotRuntime::buildStatusUnlocked() const
|
|||||||
{"joystick_speed", state_.value("joystick_speed", "fast")},
|
{"joystick_speed", state_.value("joystick_speed", "fast")},
|
||||||
{"cmd_linear", state_.value("cmd_linear", 0.0)},
|
{"cmd_linear", state_.value("cmd_linear", 0.0)},
|
||||||
{"cmd_angular", state_.value("cmd_angular", 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},
|
{"runner", runner},
|
||||||
{"queue_pending", pending},
|
{"queue_pending", pending},
|
||||||
{"updated_at", state_.value("updated_at", "")}};
|
{"updated_at", state_.value("updated_at", "")}};
|
||||||
@@ -244,6 +241,27 @@ bool RobotRuntime::setJoystick(bool engaged, const std::string& speed, std::stri
|
|||||||
return true;
|
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()
|
void RobotRuntime::tick()
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
|||||||
@@ -2,18 +2,18 @@
|
|||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
#include <filesystem>
|
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace lm {
|
namespace lm {
|
||||||
|
|
||||||
|
class Database;
|
||||||
class MissionQueue;
|
class MissionQueue;
|
||||||
|
|
||||||
class RobotRuntime
|
class RobotRuntime
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit RobotRuntime(std::filesystem::path runtime_path, MissionQueue& mission_queue);
|
explicit RobotRuntime(Database& db, MissionQueue& mission_queue);
|
||||||
|
|
||||||
nlohmann::json status() const;
|
nlohmann::json status() const;
|
||||||
bool start(std::string& err);
|
bool start(std::string& err);
|
||||||
@@ -21,10 +21,12 @@ public:
|
|||||||
bool resetError(std::string& err);
|
bool resetError(std::string& err);
|
||||||
bool setCmdVel(double linear, double angular, std::string& err);
|
bool setCmdVel(double linear, double angular, std::string& err);
|
||||||
bool setJoystick(bool engaged, const std::string& speed, 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();
|
void tick();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::filesystem::path runtime_path_;
|
Database& db_;
|
||||||
MissionQueue& mission_queue_;
|
MissionQueue& mission_queue_;
|
||||||
mutable std::mutex mu_;
|
mutable std::mutex mu_;
|
||||||
nlohmann::json state_;
|
nlohmann::json state_;
|
||||||
|
|||||||
34
src/server/api_dashboard_routes.cpp
Normal file
34
src/server/api_dashboard_routes.cpp
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#include "server/api_server.hpp"
|
||||||
|
|
||||||
|
#include "util/http_util.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
void ApiServer::registerDashboardRoutes(httplib::Server& svr)
|
||||||
|
{
|
||||||
|
svr.Get("/api/dashboards", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = dashboard_store_.snapshot().dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Put("/api/dashboards", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
if (!dashboard_store_.replace(body, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = dashboard_store_.snapshot().dump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
333
src/server/api_media_routes.cpp
Normal file
333
src/server/api_media_routes.cpp
Normal 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
|
||||||
@@ -80,6 +80,29 @@ void ApiServer::registerRobotRoutes(httplib::Server& svr)
|
|||||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
res.body = robot_runtime_.status().dump();
|
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
|
} // namespace lm
|
||||||
|
|||||||
@@ -15,13 +15,21 @@ ApiServer::ApiServer(StateRepository& repo,
|
|||||||
MissionStore& mission_store,
|
MissionStore& mission_store,
|
||||||
ModbusTriggerService& modbus,
|
ModbusTriggerService& modbus,
|
||||||
MissionScheduler& scheduler,
|
MissionScheduler& scheduler,
|
||||||
RobotRuntime& robot_runtime)
|
RobotRuntime& robot_runtime,
|
||||||
|
MapStore& map_store,
|
||||||
|
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)
|
robot_runtime_(robot_runtime),
|
||||||
|
map_store_(map_store),
|
||||||
|
site_store_(site_store),
|
||||||
|
sound_store_(sound_store),
|
||||||
|
dashboard_store_(dashboard_store)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +549,8 @@ void ApiServer::registerRoutes(httplib::Server& svr)
|
|||||||
registerIntegrationRoutes(svr);
|
registerIntegrationRoutes(svr);
|
||||||
registerMirV2Routes(svr);
|
registerMirV2Routes(svr);
|
||||||
registerRobotRoutes(svr);
|
registerRobotRoutes(svr);
|
||||||
|
registerMediaRoutes(svr);
|
||||||
|
registerDashboardRoutes(svr);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace lm
|
} // namespace lm
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
#include "mission/mission_store.hpp"
|
#include "mission/mission_store.hpp"
|
||||||
#include "mission/modbus_trigger_service.hpp"
|
#include "mission/modbus_trigger_service.hpp"
|
||||||
#include "robot/robot_runtime.hpp"
|
#include "robot/robot_runtime.hpp"
|
||||||
|
#include "storage/dashboard_store.hpp"
|
||||||
|
#include "storage/map_store.hpp"
|
||||||
|
#include "storage/site_store.hpp"
|
||||||
|
#include "storage/sound_store.hpp"
|
||||||
#include "storage/state_repository.hpp"
|
#include "storage/state_repository.hpp"
|
||||||
|
|
||||||
namespace lm {
|
namespace lm {
|
||||||
@@ -19,7 +23,11 @@ public:
|
|||||||
MissionStore& mission_store,
|
MissionStore& mission_store,
|
||||||
ModbusTriggerService& modbus,
|
ModbusTriggerService& modbus,
|
||||||
MissionScheduler& scheduler,
|
MissionScheduler& scheduler,
|
||||||
RobotRuntime& robot_runtime);
|
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);
|
||||||
|
|
||||||
@@ -30,6 +38,10 @@ private:
|
|||||||
ModbusTriggerService& modbus_;
|
ModbusTriggerService& modbus_;
|
||||||
MissionScheduler& scheduler_;
|
MissionScheduler& scheduler_;
|
||||||
RobotRuntime& robot_runtime_;
|
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);
|
||||||
@@ -38,6 +50,8 @@ private:
|
|||||||
void registerMirV2Routes(httplib::Server& svr);
|
void registerMirV2Routes(httplib::Server& svr);
|
||||||
void registerIntegrationRoutes(httplib::Server& svr);
|
void registerIntegrationRoutes(httplib::Server& svr);
|
||||||
void registerRobotRoutes(httplib::Server& svr);
|
void registerRobotRoutes(httplib::Server& svr);
|
||||||
|
void registerMediaRoutes(httplib::Server& svr);
|
||||||
|
void registerDashboardRoutes(httplib::Server& svr);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace lm
|
} // namespace lm
|
||||||
|
|||||||
351
src/storage/dashboard_store.cpp
Normal file
351
src/storage/dashboard_store.cpp
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
#include "storage/dashboard_store.hpp"
|
||||||
|
|
||||||
|
#include "storage/database.hpp"
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const char* kDefaultId = "dashboard_default";
|
||||||
|
|
||||||
|
nlohmann::json loadWidgets(sqlite3* db, const std::string& dashboard_id)
|
||||||
|
{
|
||||||
|
nlohmann::json widgets = nlohmann::json::array();
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db,
|
||||||
|
"SELECT id, type, title, mission_id, mission_group, config_json, sort_order "
|
||||||
|
"FROM dashboard_widgets WHERE dashboard_id = ?1 ORDER BY sort_order, id",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
return widgets;
|
||||||
|
sqlite3_bind_text(stmt, 1, dashboard_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
{
|
||||||
|
nlohmann::json w;
|
||||||
|
w["id"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||||
|
w["type"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||||
|
w["title"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
||||||
|
if (sqlite3_column_type(stmt, 3) != SQLITE_NULL)
|
||||||
|
w["mission_id"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||||
|
if (sqlite3_column_type(stmt, 4) != SQLITE_NULL)
|
||||||
|
w["group"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 4));
|
||||||
|
const char* cfg = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 5));
|
||||||
|
if (cfg)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const auto extra = nlohmann::json::parse(cfg);
|
||||||
|
if (extra.is_object())
|
||||||
|
{
|
||||||
|
for (auto it = extra.begin(); it != extra.end(); ++it)
|
||||||
|
{
|
||||||
|
if (!w.contains(it.key()))
|
||||||
|
w[it.key()] = it.value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
widgets.push_back(w);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json loadEditGroups(sqlite3* db, const std::string& dashboard_id)
|
||||||
|
{
|
||||||
|
nlohmann::json groups = nlohmann::json::array();
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db,
|
||||||
|
"SELECT group_id FROM dashboard_edit_groups WHERE dashboard_id = ?1 ORDER BY group_id",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
return groups;
|
||||||
|
sqlite3_bind_text(stmt, 1, dashboard_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
groups.push_back(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0)));
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string> loadActiveDashboardId(sqlite3* db)
|
||||||
|
{
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db, "SELECT active_dashboard_id FROM dashboard_state WHERE id = 1", -1, &stmt, nullptr) !=
|
||||||
|
SQLITE_OK)
|
||||||
|
return std::nullopt;
|
||||||
|
std::optional<std::string> out;
|
||||||
|
if (sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_type(stmt, 0) != SQLITE_NULL)
|
||||||
|
out = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
DashboardStore::DashboardStore(Database& db) : db_(db)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
ensureDefaultsUnlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DashboardStore::ensureDefaultsUnlocked()
|
||||||
|
{
|
||||||
|
sqlite3_stmt* count_stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), "SELECT COUNT(*) FROM dashboards", -1, &count_stmt, nullptr) != SQLITE_OK)
|
||||||
|
return;
|
||||||
|
int count = 0;
|
||||||
|
if (sqlite3_step(count_stmt) == SQLITE_ROW)
|
||||||
|
count = sqlite3_column_int(count_stmt, 0);
|
||||||
|
sqlite3_finalize(count_stmt);
|
||||||
|
if (count > 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
sqlite3_stmt* ins = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"INSERT INTO dashboards(id, name, created_by, created_by_user, is_default, sort_order, "
|
||||||
|
"created_at, updated_at) VALUES(?1,?2,?3,NULL,1,0,?4,?4)",
|
||||||
|
-1,
|
||||||
|
&ins,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
return;
|
||||||
|
sqlite3_bind_text(ins, 1, kDefaultId, -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_text(ins, 2, "Default Dashboard", -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_text(ins, 3, "MiR", -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_text(ins, 4, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_step(ins);
|
||||||
|
sqlite3_finalize(ins);
|
||||||
|
|
||||||
|
for (const char* gid :
|
||||||
|
{"group_administrators", "group_distributors", "group_users"})
|
||||||
|
{
|
||||||
|
sqlite3_stmt* g = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"INSERT OR IGNORE INTO dashboard_edit_groups(dashboard_id, group_id) VALUES(?1,?2)",
|
||||||
|
-1,
|
||||||
|
&g,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
continue;
|
||||||
|
sqlite3_bind_text(g, 1, kDefaultId, -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_text(g, 2, gid, -1, SQLITE_STATIC);
|
||||||
|
sqlite3_step(g);
|
||||||
|
sqlite3_finalize(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_stmt* st = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"INSERT OR REPLACE INTO dashboard_state(id, active_dashboard_id) VALUES(1, ?1)",
|
||||||
|
-1,
|
||||||
|
&st,
|
||||||
|
nullptr) == SQLITE_OK)
|
||||||
|
{
|
||||||
|
sqlite3_bind_text(st, 1, kDefaultId, -1, SQLITE_STATIC);
|
||||||
|
sqlite3_step(st);
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json DashboardStore::snapshot() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
nlohmann::json dashboards = nlohmann::json::array();
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"SELECT id, name, created_by, created_by_user, is_default FROM dashboards ORDER BY sort_order, name",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
return {{"dashboards", dashboards}, {"activeDashboardId", nullptr}};
|
||||||
|
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
{
|
||||||
|
const std::string id = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||||
|
nlohmann::json d;
|
||||||
|
d["id"] = id;
|
||||||
|
d["name"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||||
|
d["createdBy"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
||||||
|
if (sqlite3_column_type(stmt, 3) != SQLITE_NULL)
|
||||||
|
d["createdByUser"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||||
|
else
|
||||||
|
d["createdByUser"] = nullptr;
|
||||||
|
d["isDefault"] = sqlite3_column_int(stmt, 4) != 0;
|
||||||
|
d["editGroups"] = loadEditGroups(db_.handle(), id);
|
||||||
|
d["widgets"] = loadWidgets(db_.handle(), id);
|
||||||
|
dashboards.push_back(d);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
|
std::string active_id = kDefaultId;
|
||||||
|
if (auto active = loadActiveDashboardId(db_.handle()))
|
||||||
|
active_id = *active;
|
||||||
|
if (dashboards.empty())
|
||||||
|
return {{"dashboards", nlohmann::json::array()}, {"activeDashboardId", nullptr}};
|
||||||
|
return {{"dashboards", dashboards}, {"activeDashboardId", active_id}};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DashboardStore::replace(const nlohmann::json& payload, std::string& err)
|
||||||
|
{
|
||||||
|
if (!payload.is_object())
|
||||||
|
{
|
||||||
|
err = "payload must be an object";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!payload.contains("dashboards") || !payload["dashboards"].is_array())
|
||||||
|
{
|
||||||
|
err = "dashboards array is required";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3* db = db_.handle();
|
||||||
|
char* msg = nullptr;
|
||||||
|
if (sqlite3_exec(db, "BEGIN IMMEDIATE", nullptr, nullptr, &msg) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = msg ? msg : "begin failed";
|
||||||
|
sqlite3_free(msg);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto rollback = [&]() {
|
||||||
|
sqlite3_exec(db, "ROLLBACK", nullptr, nullptr, nullptr);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sqlite3_exec(db, "DELETE FROM dashboard_widgets", nullptr, nullptr, &msg) != SQLITE_OK ||
|
||||||
|
sqlite3_exec(db, "DELETE FROM dashboard_edit_groups", nullptr, nullptr, &msg) != SQLITE_OK ||
|
||||||
|
sqlite3_exec(db, "DELETE FROM dashboards", nullptr, nullptr, &msg) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = msg ? msg : "clear failed";
|
||||||
|
sqlite3_free(msg);
|
||||||
|
rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
int sort = 0;
|
||||||
|
for (const auto& d : payload["dashboards"])
|
||||||
|
{
|
||||||
|
if (!d.is_object() || !d.contains("id"))
|
||||||
|
continue;
|
||||||
|
const std::string id = d.value("id", IdUtil::newId());
|
||||||
|
sqlite3_stmt* ins = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db,
|
||||||
|
"INSERT INTO dashboards(id, name, created_by, created_by_user, is_default, sort_order, "
|
||||||
|
"created_at, updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?7)",
|
||||||
|
-1,
|
||||||
|
&ins,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
rollback();
|
||||||
|
err = "insert dashboard failed";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(ins, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(ins, 2, d.value("name", "Dashboard").c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(ins, 3, d.value("createdBy", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
if (d.contains("createdByUser") && !d["createdByUser"].is_null())
|
||||||
|
sqlite3_bind_text(ins, 4, d["createdByUser"].get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
else
|
||||||
|
sqlite3_bind_null(ins, 4);
|
||||||
|
sqlite3_bind_int(ins, 5, d.value("isDefault", false) ? 1 : 0);
|
||||||
|
sqlite3_bind_int(ins, 6, sort++);
|
||||||
|
sqlite3_bind_text(ins, 7, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_step(ins);
|
||||||
|
sqlite3_finalize(ins);
|
||||||
|
|
||||||
|
if (d.contains("editGroups") && d["editGroups"].is_array())
|
||||||
|
{
|
||||||
|
for (const auto& g : d["editGroups"])
|
||||||
|
{
|
||||||
|
if (!g.is_string())
|
||||||
|
continue;
|
||||||
|
sqlite3_stmt* gs = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db,
|
||||||
|
"INSERT INTO dashboard_edit_groups(dashboard_id, group_id) VALUES(?1,?2)",
|
||||||
|
-1,
|
||||||
|
&gs,
|
||||||
|
nullptr) == SQLITE_OK)
|
||||||
|
{
|
||||||
|
sqlite3_bind_text(gs, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(gs, 2, g.get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_step(gs);
|
||||||
|
sqlite3_finalize(gs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.contains("widgets") && d["widgets"].is_array())
|
||||||
|
{
|
||||||
|
int wsort = 0;
|
||||||
|
for (const auto& w : d["widgets"])
|
||||||
|
{
|
||||||
|
if (!w.is_object())
|
||||||
|
continue;
|
||||||
|
nlohmann::json config = nlohmann::json::object();
|
||||||
|
for (auto it = w.begin(); it != w.end(); ++it)
|
||||||
|
{
|
||||||
|
if (it.key() != "id" && it.key() != "type" && it.key() != "title" && it.key() != "mission_id" &&
|
||||||
|
it.key() != "group")
|
||||||
|
config[it.key()] = it.value();
|
||||||
|
}
|
||||||
|
sqlite3_stmt* ws = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db,
|
||||||
|
"INSERT INTO dashboard_widgets(id, dashboard_id, type, title, mission_id, mission_group, "
|
||||||
|
"config_json, sort_order) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||||
|
-1,
|
||||||
|
&ws,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
continue;
|
||||||
|
const std::string wid = w.value("id", IdUtil::newId());
|
||||||
|
sqlite3_bind_text(ws, 1, wid.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(ws, 2, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(ws, 3, w.value("type", "unknown").c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(ws, 4, w.value("title", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
if (w.contains("mission_id") && w["mission_id"].is_string())
|
||||||
|
sqlite3_bind_text(ws, 5, w["mission_id"].get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
else
|
||||||
|
sqlite3_bind_null(ws, 5);
|
||||||
|
if (w.contains("group") && w["group"].is_string())
|
||||||
|
sqlite3_bind_text(ws, 6, w["group"].get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
else
|
||||||
|
sqlite3_bind_null(ws, 6);
|
||||||
|
const std::string cfg = config.dump();
|
||||||
|
sqlite3_bind_text(ws, 7, cfg.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_int(ws, 8, wsort++);
|
||||||
|
sqlite3_step(ws);
|
||||||
|
sqlite3_finalize(ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string active = payload.value("activeDashboardId", kDefaultId);
|
||||||
|
sqlite3_stmt* ast = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db,
|
||||||
|
"INSERT OR REPLACE INTO dashboard_state(id, active_dashboard_id) VALUES(1, ?1)",
|
||||||
|
-1,
|
||||||
|
&ast,
|
||||||
|
nullptr) == SQLITE_OK)
|
||||||
|
{
|
||||||
|
sqlite3_bind_text(ast, 1, active.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_step(ast);
|
||||||
|
sqlite3_finalize(ast);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sqlite3_exec(db, "COMMIT", nullptr, nullptr, &msg) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = msg ? msg : "commit failed";
|
||||||
|
sqlite3_free(msg);
|
||||||
|
rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
28
src/storage/dashboard_store.hpp
Normal file
28
src/storage/dashboard_store.hpp
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class Database;
|
||||||
|
|
||||||
|
class DashboardStore
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit DashboardStore(Database& db);
|
||||||
|
|
||||||
|
nlohmann::json snapshot() const;
|
||||||
|
bool replace(const nlohmann::json& payload, std::string& err);
|
||||||
|
|
||||||
|
private:
|
||||||
|
Database& db_;
|
||||||
|
mutable std::mutex mu_;
|
||||||
|
|
||||||
|
void ensureDefaultsUnlocked();
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
566
src/storage/database.cpp
Normal file
566
src/storage/database.cpp
Normal 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
53
src/storage/database.hpp
Normal 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
534
src/storage/map_store.cpp
Normal 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
42
src/storage/map_store.hpp
Normal 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
242
src/storage/site_store.cpp
Normal 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
|
||||||
30
src/storage/site_store.hpp
Normal file
30
src/storage/site_store.hpp
Normal 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
257
src/storage/sound_store.cpp
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
#include "storage/sound_store.hpp"
|
||||||
|
|
||||||
|
#include "storage/database.hpp"
|
||||||
|
#include "util/file_util.hpp"
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||||
|
{
|
||||||
|
auto textOrNull = [&](int col) -> nlohmann::json {
|
||||||
|
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
|
||||||
|
return nullptr;
|
||||||
|
return nlohmann::json(reinterpret_cast<const char*>(sqlite3_column_text(stmt, col)));
|
||||||
|
};
|
||||||
|
return {{"id", textOrNull(0)},
|
||||||
|
{"name", textOrNull(1)},
|
||||||
|
{"description", textOrNull(2)},
|
||||||
|
{"file_name", textOrNull(3)},
|
||||||
|
{"duration_ms", sqlite3_column_type(stmt, 4) == SQLITE_NULL
|
||||||
|
? nlohmann::json(nullptr)
|
||||||
|
: nlohmann::json(sqlite3_column_int(stmt, 4))},
|
||||||
|
{"enabled", sqlite3_column_int(stmt, 5) != 0},
|
||||||
|
{"created_at", textOrNull(6)},
|
||||||
|
{"updated_at", textOrNull(7)}};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
SoundStore::SoundStore(Database& db) : db_(db) {}
|
||||||
|
|
||||||
|
nlohmann::json SoundStore::list() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
nlohmann::json sounds = nlohmann::json::array();
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"SELECT id, name, description, file_name, duration_ms, enabled, created_at, updated_at "
|
||||||
|
"FROM sounds ORDER BY name",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
return sounds;
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
sounds.push_back(rowToJson(stmt));
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return sounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> SoundStore::find(const std::string& id) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"SELECT id, name, description, file_name, duration_ms, enabled, created_at, updated_at "
|
||||||
|
"FROM sounds WHERE id = ?1",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
return std::nullopt;
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
std::optional<nlohmann::json> out;
|
||||||
|
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
out = rowToJson(stmt);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> SoundStore::create(const nlohmann::json& payload, std::string& err)
|
||||||
|
{
|
||||||
|
if (!payload.is_object())
|
||||||
|
{
|
||||||
|
err = "payload must be an object";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const std::string name = StringUtil::trimCopy(payload.value("name", ""));
|
||||||
|
if (name.empty())
|
||||||
|
{
|
||||||
|
err = "name is required";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string id = payload.value("id", IdUtil::newId());
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
const std::string description = payload.value("description", "");
|
||||||
|
const bool enabled = payload.value("enabled", true);
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"INSERT INTO sounds(id, name, description, file_name, duration_ms, enabled, created_at, updated_at) "
|
||||||
|
"VALUES(?1,?2,?3,NULL,NULL,?4,?5,?6)",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 3, description.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_int(stmt, 4, enabled ? 1 : 0);
|
||||||
|
sqlite3_bind_text(stmt, 5, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
|
||||||
|
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
|
return nlohmann::json{{"id", id},
|
||||||
|
{"name", name},
|
||||||
|
{"description", description},
|
||||||
|
{"file_name", nullptr},
|
||||||
|
{"duration_ms", nullptr},
|
||||||
|
{"enabled", enabled},
|
||||||
|
{"created_at", now},
|
||||||
|
{"updated_at", now}};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SoundStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
|
||||||
|
{
|
||||||
|
auto existing = find(id);
|
||||||
|
if (!existing)
|
||||||
|
{
|
||||||
|
err = "sound not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json merged = *existing;
|
||||||
|
for (const char* key : {"name", "description", "enabled", "duration_ms"})
|
||||||
|
{
|
||||||
|
if (payload.contains(key))
|
||||||
|
merged[key] = payload[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"UPDATE sounds SET name=?2, description=?3, enabled=?4, duration_ms=?5, updated_at=?6 WHERE id=?1",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, merged.value("name", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 3, merged.value("description", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_int(stmt, 4, merged.value("enabled", true) ? 1 : 0);
|
||||||
|
if (merged["duration_ms"].is_number_integer())
|
||||||
|
sqlite3_bind_int(stmt, 5, merged["duration_ms"].get<int>());
|
||||||
|
else
|
||||||
|
sqlite3_bind_null(stmt, 5);
|
||||||
|
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
|
||||||
|
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||||
|
if (!ok)
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SoundStore::remove(const std::string& id, std::string& err)
|
||||||
|
{
|
||||||
|
auto existing = find(id);
|
||||||
|
if (!existing)
|
||||||
|
{
|
||||||
|
err = "sound not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM sounds WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
|
if (existing->contains("file_name") && (*existing)["file_name"].is_string())
|
||||||
|
{
|
||||||
|
const auto path = db_.soundsDir() / existing->value("file_name", "");
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::remove(path, ec);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::filesystem::path> SoundStore::filePath(const std::string& id) const
|
||||||
|
{
|
||||||
|
const auto sound = find(id);
|
||||||
|
if (!sound || !(*sound)["file_name"].is_string())
|
||||||
|
return std::nullopt;
|
||||||
|
const auto path = db_.soundsDir() / sound->value("file_name", "");
|
||||||
|
if (!std::filesystem::exists(path))
|
||||||
|
return std::nullopt;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SoundStore::saveFile(const std::string& id,
|
||||||
|
const std::string& filename,
|
||||||
|
const std::string& bytes,
|
||||||
|
std::string& err)
|
||||||
|
{
|
||||||
|
if (!find(id))
|
||||||
|
{
|
||||||
|
err = "sound not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::create_directories(db_.soundsDir(), ec);
|
||||||
|
const auto path = db_.soundsDir() / filename;
|
||||||
|
if (!FileUtil::writeBinaryAtomic(path, bytes))
|
||||||
|
{
|
||||||
|
err = "failed to write sound file";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"UPDATE sounds SET file_name = ?2, updated_at = ?3 WHERE id = ?1",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, filename.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||||
|
if (!ok)
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
33
src/storage/sound_store.hpp
Normal file
33
src/storage/sound_store.hpp
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class Database;
|
||||||
|
|
||||||
|
class SoundStore
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
SoundStore(Database& db);
|
||||||
|
|
||||||
|
nlohmann::json list() const;
|
||||||
|
std::optional<nlohmann::json> find(const std::string& id) const;
|
||||||
|
std::optional<nlohmann::json> create(const nlohmann::json& payload, std::string& err);
|
||||||
|
bool update(const std::string& id, const nlohmann::json& payload, std::string& err);
|
||||||
|
bool remove(const std::string& id, std::string& err);
|
||||||
|
|
||||||
|
std::optional<std::filesystem::path> filePath(const std::string& id) const;
|
||||||
|
bool saveFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
|
||||||
|
|
||||||
|
private:
|
||||||
|
Database& db_;
|
||||||
|
mutable std::mutex mu_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "domain/layout_profile.hpp"
|
#include "domain/layout_profile.hpp"
|
||||||
#include "domain/layout_schema.hpp"
|
#include "domain/layout_schema.hpp"
|
||||||
|
#include "storage/database.hpp"
|
||||||
#include "util/file_util.hpp"
|
#include "util/file_util.hpp"
|
||||||
#include "util/id_util.hpp"
|
#include "util/id_util.hpp"
|
||||||
#include "util/string_util.hpp"
|
#include "util/string_util.hpp"
|
||||||
@@ -20,6 +21,8 @@ std::filesystem::path StateRepository::profileFilePath(const std::string& id) co
|
|||||||
|
|
||||||
std::optional<nlohmann::json> StateRepository::loadProfileFromDisk(const std::string& id) const
|
std::optional<nlohmann::json> StateRepository::loadProfileFromDisk(const std::string& id) const
|
||||||
{
|
{
|
||||||
|
if (auto profile = db_.getLayoutProfile(id))
|
||||||
|
return profile;
|
||||||
const auto raw = FileUtil::readBinary(profileFilePath(id));
|
const auto raw = FileUtil::readBinary(profileFilePath(id));
|
||||||
if (raw.empty())
|
if (raw.empty())
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
@@ -37,15 +40,12 @@ bool StateRepository::saveProfileToDisk(const nlohmann::json& profile) const
|
|||||||
{
|
{
|
||||||
if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string())
|
if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string())
|
||||||
return false;
|
return false;
|
||||||
std::error_code ec;
|
return db_.setLayoutProfile(profile);
|
||||||
std::filesystem::create_directories(modelsDir(), ec);
|
|
||||||
auto body = profile.dump(2);
|
|
||||||
body.push_back('\n');
|
|
||||||
return FileUtil::writeBinaryAtomic(profileFilePath(profile["id"].get<std::string>()), body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool StateRepository::deleteProfileFile(const std::string& id) const
|
bool StateRepository::deleteProfileFile(const std::string& id) const
|
||||||
{
|
{
|
||||||
|
db_.deleteLayoutProfile(id);
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
std::filesystem::remove(profileFilePath(id), ec);
|
std::filesystem::remove(profileFilePath(id), ec);
|
||||||
return true;
|
return true;
|
||||||
@@ -243,15 +243,14 @@ void StateRepository::bootstrapDefaultState()
|
|||||||
app_.state["imus"] = profile.contains("imus") ? profile["imus"] : nlohmann::json::array();
|
app_.state["imus"] = profile.contains("imus") ? profile["imus"] : nlohmann::json::array();
|
||||||
}
|
}
|
||||||
|
|
||||||
StateRepository::StateRepository(std::filesystem::path data_path)
|
StateRepository::StateRepository(std::filesystem::path data_path, Database& db) : db_(db)
|
||||||
{
|
{
|
||||||
app_.data_path = std::move(data_path);
|
app_.data_path = std::move(data_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool StateRepository::load()
|
bool StateRepository::load()
|
||||||
{
|
{
|
||||||
const auto raw = FileUtil::readBinary(app_.data_path);
|
if (!db_.getDocument("state", app_.state))
|
||||||
if (raw.empty())
|
|
||||||
{
|
{
|
||||||
bootstrapDefaultState();
|
bootstrapDefaultState();
|
||||||
save();
|
save();
|
||||||
@@ -259,7 +258,6 @@ bool StateRepository::load()
|
|||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
app_.state = nlohmann::json::parse(raw);
|
|
||||||
ensureSchema();
|
ensureSchema();
|
||||||
save();
|
save();
|
||||||
return true;
|
return true;
|
||||||
@@ -309,9 +307,7 @@ bool StateRepository::save() const
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
const nlohmann::json disk = globalStateForDisk(app_.state);
|
const nlohmann::json disk = globalStateForDisk(app_.state);
|
||||||
auto raw = disk.dump(2);
|
return db_.setDocument("state", disk);
|
||||||
raw.push_back('\n');
|
|
||||||
return FileUtil::writeBinaryAtomic(app_.data_path, raw);
|
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
|
|
||||||
namespace lm {
|
namespace lm {
|
||||||
|
|
||||||
|
class Database;
|
||||||
|
|
||||||
class StateRepository
|
class StateRepository
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit StateRepository(std::filesystem::path data_path);
|
StateRepository(std::filesystem::path data_path, Database& db);
|
||||||
|
|
||||||
AppState& app() { return app_; }
|
AppState& app() { return app_; }
|
||||||
const AppState& app() const { return app_; }
|
const AppState& app() const { return app_; }
|
||||||
@@ -30,6 +32,7 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
AppState app_;
|
AppState app_;
|
||||||
|
Database& db_;
|
||||||
|
|
||||||
std::filesystem::path modelsDir() const;
|
std::filesystem::path modelsDir() const;
|
||||||
std::filesystem::path profileFilePath(const std::string& id) const;
|
std::filesystem::path profileFilePath(const std::string& id) const;
|
||||||
|
|||||||
1724
src/third_party/stb_image_write.h
vendored
Normal file
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
212
src/util/map_image_util.cpp
Normal 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
|
||||||
36
src/util/map_image_util.hpp
Normal file
36
src/util/map_image_util.hpp
Normal 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
166
src/util/ros_map_yaml.cpp
Normal 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
26
src/util/ros_map_yaml.hpp
Normal 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
|
||||||
@@ -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_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
15
www/app.js
15
www/app.js
@@ -7,8 +7,10 @@ const listEl = el("lidarList");
|
|||||||
const lidarFormHintEl = el("lidarFormHint");
|
const lidarFormHintEl = el("lidarFormHint");
|
||||||
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 pageMonitoringEl = el("pageMonitoring");
|
||||||
const pageHelpEl = el("pageHelp");
|
const pageHelpEl = el("pageHelp");
|
||||||
const contentEl = document.querySelector(".content");
|
const contentEl = document.querySelector(".content");
|
||||||
@@ -123,8 +125,8 @@ const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function setActivePage(page) {
|
function setActivePage(page) {
|
||||||
const valid = ["dashboard", "config", "missions", "integrations", "monitoring", "help"];
|
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";
|
||||||
@@ -132,7 +134,9 @@ function setActivePage(page) {
|
|||||||
if (page === "overview") p = "dashboard";
|
if (page === "overview") p = "dashboard";
|
||||||
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 (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
||||||
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
|
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
|
||||||
@@ -141,13 +145,18 @@ function setActivePage(page) {
|
|||||||
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--monitoring", p === "monitoring");
|
||||||
contentEl.classList.toggle("content--help", p === "help");
|
contentEl.classList.toggle("content--help", p === "help");
|
||||||
}
|
}
|
||||||
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();
|
||||||
@@ -162,7 +171,7 @@ function setActivePage(page) {
|
|||||||
|
|
||||||
function initNavigation() {
|
function initNavigation() {
|
||||||
if (window.NavApp?.init) window.NavApp.init();
|
if (window.NavApp?.init) window.NavApp.init();
|
||||||
else setActivePage("config");
|
else setActivePage("missions");
|
||||||
}
|
}
|
||||||
|
|
||||||
window.LmApp = { setActivePage };
|
window.LmApp = { setActivePage };
|
||||||
|
|||||||
14
www/auth.js
14
www/auth.js
@@ -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];
|
||||||
@@ -159,6 +166,7 @@
|
|||||||
window.NavApp.applyPermissions();
|
window.NavApp.applyPermissions();
|
||||||
}
|
}
|
||||||
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"));
|
||||||
}
|
}
|
||||||
@@ -212,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;
|
||||||
|
|||||||
1296
www/dashboard.js
1296
www/dashboard.js
File diff suppressed because it is too large
Load Diff
576
www/i18n.js
576
www/i18n.js
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Central i18n for LiDAR Manager — vi / en.
|
* Central i18n for Robot App — vi / en.
|
||||||
* Static DOM: data-i18n, data-i18n-placeholder, data-i18n-title, data-i18n-aria
|
* Static DOM: data-i18n, data-i18n-placeholder, data-i18n-title, data-i18n-aria
|
||||||
* Dynamic JS: I18n.t("key") or I18n.t("key", { name: "..." })
|
* Dynamic JS: I18n.t("key") or I18n.t("key", { name: "..." })
|
||||||
*/
|
*/
|
||||||
(() => {
|
(() => {
|
||||||
const MESSAGES = {
|
const MESSAGES = {
|
||||||
vi: {
|
vi: {
|
||||||
"app.title": "LiDAR Manager",
|
"app.title": "Robot App",
|
||||||
"app.robotName": "RobotApp",
|
"app.robotName": "RobotApp",
|
||||||
"app.status.ready": "Sẵn sàng",
|
"app.status.ready": "Sẵn sàng",
|
||||||
"app.status.reloaded": "Đã tải lại",
|
"app.status.reloaded": "Đã tải lại",
|
||||||
@@ -28,6 +28,9 @@
|
|||||||
"common.error": "Lỗi: {msg}",
|
"common.error": "Lỗi: {msg}",
|
||||||
"common.none": "none",
|
"common.none": "none",
|
||||||
"common.optional": "Tùy chọn",
|
"common.optional": "Tùy chọn",
|
||||||
|
"common.yes": "Có",
|
||||||
|
"common.no": "Không",
|
||||||
|
"common.ok": "OK",
|
||||||
|
|
||||||
"login.prompt": "Chọn cách đăng nhập:",
|
"login.prompt": "Chọn cách đăng nhập:",
|
||||||
"login.tab.password": "Tên đăng nhập và mật khẩu",
|
"login.tab.password": "Tên đăng nhập và mật khẩu",
|
||||||
@@ -66,8 +69,11 @@
|
|||||||
"nav.help": "Help",
|
"nav.help": "Help",
|
||||||
"nav.logout": "Log out",
|
"nav.logout": "Log out",
|
||||||
"nav.dashboard": "Dashboard",
|
"nav.dashboard": "Dashboard",
|
||||||
|
"nav.dashboardsList": "Dashboards",
|
||||||
"nav.missions": "Missions",
|
"nav.missions": "Missions",
|
||||||
"nav.maps": "Maps & layout",
|
"nav.maps": "Maps",
|
||||||
|
"nav.sounds": "Sounds",
|
||||||
|
"nav.build-robot": "Build Robot",
|
||||||
"nav.monitoring-log": "System log",
|
"nav.monitoring-log": "System log",
|
||||||
"nav.integrations": "Tích hợp",
|
"nav.integrations": "Tích hợp",
|
||||||
"nav.help-api": "API documentation",
|
"nav.help-api": "API documentation",
|
||||||
@@ -116,6 +122,52 @@
|
|||||||
|
|
||||||
"dashboard.title": "Dashboard",
|
"dashboard.title": "Dashboard",
|
||||||
"dashboard.subtitle": "Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.",
|
"dashboard.subtitle": "Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.",
|
||||||
|
"dashboard.list.title": "Dashboards",
|
||||||
|
"dashboard.list.subtitle": "Tạo và chỉnh sửa dashboard cho robot.",
|
||||||
|
"dashboard.list.create": "+ Tạo dashboard",
|
||||||
|
"dashboard.list.clearFilters": "Xóa bộ lọc",
|
||||||
|
"dashboard.list.filterLabel": "Lọc:",
|
||||||
|
"dashboard.list.filterPlaceholder": "Nhập tên để lọc…",
|
||||||
|
"dashboard.list.itemsFound": "{count} mục",
|
||||||
|
"dashboard.list.pageOf": "Trang {page} / {total}",
|
||||||
|
"dashboard.list.col.name": "Tên",
|
||||||
|
"dashboard.list.col.createdBy": "Tạo bởi",
|
||||||
|
"dashboard.list.col.functions": "Chức năng",
|
||||||
|
"dashboard.list.empty": "Không có dashboard nào.",
|
||||||
|
"dashboard.list.back": "← Danh sách",
|
||||||
|
"dashboard.list.design": "Thiết kế",
|
||||||
|
"dashboard.list.active": "Dashboard đang active",
|
||||||
|
"dashboard.list.edit": "Sửa",
|
||||||
|
"dashboard.list.delete": "Xóa",
|
||||||
|
"dashboard.list.deleteConfirm": "Xóa dashboard «{name}»?",
|
||||||
|
"dashboard.list.cannotDeleteDefault": "Không thể xóa Default Dashboard.",
|
||||||
|
"dashboard.list.noEditPermission": "Bạn không có quyền chỉnh sửa dashboard này.",
|
||||||
|
"dashboard.dialog.create.title": "Tạo dashboard",
|
||||||
|
"dashboard.create.title": "Tạo dashboard",
|
||||||
|
"dashboard.create.subtitle": "Tạo dashboard mới trên robot.",
|
||||||
|
"dashboard.create.backToList": "← Quay lại danh sách",
|
||||||
|
"dashboard.create.name": "Tên",
|
||||||
|
"dashboard.create.namePlaceholder": "VD: John's Dashboard",
|
||||||
|
"dashboard.create.permissions": "Chọn user groups được phép chỉnh sửa dashboard này.",
|
||||||
|
"dashboard.create.permissionsBtn": "Quyền",
|
||||||
|
"dashboard.create.permissionsTitle": "Quyền chỉnh sửa",
|
||||||
|
"dashboard.create.submit": "Tạo dashboard",
|
||||||
|
"dashboard.create.cancel": "Hủy",
|
||||||
|
"dashboard.dialog.editDashboard.title": "Sửa dashboard",
|
||||||
|
"dashboard.designer.empty": "Chưa có widget trên dashboard này.",
|
||||||
|
"dashboard.designer.emptyEdit": "Chưa có widget. Chọn loại widget trên thanh Maps / Missions / Miscellaneous.",
|
||||||
|
"dashboard.designer.dragHint": "Kéo thanh tiêu đề để di chuyển widget trên lưới",
|
||||||
|
"dashboard.designer.configure": "Cấu hình widget",
|
||||||
|
"dashboard.designer.resize": "Kéo để đổi kích thước",
|
||||||
|
"dashboard.designer.save": "Lưu",
|
||||||
|
"dashboard.designer.saved": "Đã lưu",
|
||||||
|
"dashboard.menu.maps": "Maps",
|
||||||
|
"dashboard.menu.missions": "Missions",
|
||||||
|
"dashboard.menu.plc": "PLC Registers",
|
||||||
|
"dashboard.menu.io": "I/O",
|
||||||
|
"dashboard.menu.misc": "Miscellaneous",
|
||||||
|
"dashboard.menu.comingSoon": "Widget nhóm này sẽ có trong bản cập nhật sau.",
|
||||||
|
"dashboard.createdBy.system": "MiR",
|
||||||
"dashboard.addWidget": "Thêm widget",
|
"dashboard.addWidget": "Thêm widget",
|
||||||
"dashboard.editLayout": "Sửa layout",
|
"dashboard.editLayout": "Sửa layout",
|
||||||
"dashboard.editDone": "Xong",
|
"dashboard.editDone": "Xong",
|
||||||
@@ -136,6 +188,19 @@
|
|||||||
"dashboard.widget.mission_group": "Nhóm mission",
|
"dashboard.widget.mission_group": "Nhóm mission",
|
||||||
"dashboard.widget.mission_queue": "Mission queue",
|
"dashboard.widget.mission_queue": "Mission queue",
|
||||||
"dashboard.widget.pause_continue": "Tạm dừng / Tiếp tục",
|
"dashboard.widget.pause_continue": "Tạm dừng / Tiếp tục",
|
||||||
|
"dashboard.widget.mission_action_log": "Mission action log",
|
||||||
|
"dashboard.widget.logout_button": "Nút đăng xuất",
|
||||||
|
"dashboard.widget.map_locked": "Locked map",
|
||||||
|
"dashboard.widget.map": "Map",
|
||||||
|
"dashboard.widget.robot_summary": "Robot summary",
|
||||||
|
"dashboard.widget.field.map": "Map",
|
||||||
|
"dashboard.widget.mapActive": "Active map (robot)",
|
||||||
|
"dashboard.widget.mapHint": "Chọn map cố định hoặc để «Active map» dùng map đang gắn với robot.",
|
||||||
|
"dashboard.widget.mapLoading": "Đang tải map…",
|
||||||
|
"dashboard.widget.mapEmpty": "Chưa có map. Distributor tạo map qua API /api/maps.",
|
||||||
|
"dashboard.widget.mapNoImage": "Chưa có ảnh map — upload qua POST /api/maps/{id}/image",
|
||||||
|
"dashboard.widget.mapImageError": "Không tải được ảnh map.",
|
||||||
|
"dashboard.widget.actionLog.empty": "Chưa có action đang chạy.",
|
||||||
"dashboard.widget.field.mission": "Mission",
|
"dashboard.widget.field.mission": "Mission",
|
||||||
"dashboard.widget.field.group": "Nhóm mission",
|
"dashboard.widget.field.group": "Nhóm mission",
|
||||||
"dashboard.widget.field.title": "Tiêu đề widget (tùy chọn)",
|
"dashboard.widget.field.title": "Tiêu đề widget (tùy chọn)",
|
||||||
@@ -220,6 +285,224 @@
|
|||||||
"config.motor.custom": "Tùy chỉnh",
|
"config.motor.custom": "Tùy chỉnh",
|
||||||
"config.motor.customMotor": "Motor tùy chỉnh",
|
"config.motor.customMotor": "Motor tùy chỉnh",
|
||||||
|
|
||||||
|
"maps.title": "Maps",
|
||||||
|
"maps.subtitle": "Tạo và chỉnh sửa map.",
|
||||||
|
"maps.create": "Tạo map",
|
||||||
|
"maps.importSite": "Import site",
|
||||||
|
"maps.clearFilters": "Xóa bộ lọc",
|
||||||
|
"maps.filterLabel": "Lọc:",
|
||||||
|
"maps.filterPlaceholder": "Nhập tên để lọc…",
|
||||||
|
"maps.itemsFound": "{n} mục",
|
||||||
|
"maps.pageOf": "Trang {page} / {total}",
|
||||||
|
"maps.colName": "Tên",
|
||||||
|
"maps.colCreatedBy": "Tạo bởi",
|
||||||
|
"maps.colFunctions": "Chức năng",
|
||||||
|
"maps.empty": "Chưa có map. Bấm Tạo map để bắt đầu.",
|
||||||
|
"maps.emptyFilter": "Không có map khớp bộ lọc.",
|
||||||
|
"maps.activeBadge": "ACTIVE",
|
||||||
|
"maps.activeHint": "Map đang hoạt động: {name}",
|
||||||
|
"maps.view": "Xem",
|
||||||
|
"maps.importComingSoon": "Import site sẽ có trong phiên bản sau.",
|
||||||
|
"maps.helpTitle": "Trợ giúp Maps",
|
||||||
|
"maps.helpText": "Tạo map mới, upload ảnh PNG qua menu ⋮, sau đó kích hoạt map cho robot.",
|
||||||
|
"maps.createDialog.title": "Tạo map",
|
||||||
|
"maps.createDialog.name": "Tên *",
|
||||||
|
"maps.createDialog.site": "Site *",
|
||||||
|
"maps.createDialog.manageSite": "Tạo / Sửa site…",
|
||||||
|
"maps.createDialog.submit": "Tạo map",
|
||||||
|
"maps.createPage.title": "Tạo map",
|
||||||
|
"maps.createPage.subtitle": "Tạo map mới.",
|
||||||
|
"maps.createPage.goBack": "Quay lại",
|
||||||
|
"maps.createPage.name": "Tên",
|
||||||
|
"maps.createPage.namePlaceholder": "Nhập tên map…",
|
||||||
|
"maps.createPage.nameHelp": "Tên hiển thị trong danh sách Maps.",
|
||||||
|
"maps.createPage.site": "Site",
|
||||||
|
"maps.createPage.siteHelp": "Site chứa map trong cơ sở.",
|
||||||
|
"maps.createPage.siteManage": "Tạo / Sửa",
|
||||||
|
"maps.createPage.submit": "Tạo map",
|
||||||
|
"maps.createPage.cancel": "Hủy",
|
||||||
|
"maps.createPage.helpText": "Nhập tên map và chọn site, sau đó bấm Tạo map để mở trình editor.",
|
||||||
|
"maps.siteDialog.create": "Tạo site",
|
||||||
|
"maps.siteDialog.edit": "Sửa site",
|
||||||
|
"maps.siteDialog.name": "Tên *",
|
||||||
|
"maps.siteForm.create": "Tạo site",
|
||||||
|
"maps.siteForm.edit": "Sửa site",
|
||||||
|
"maps.siteForm.name": "Tên *",
|
||||||
|
"maps.sitesDialog.title": "Sites",
|
||||||
|
"maps.sitesDialog.createSite": "Tạo site",
|
||||||
|
"maps.sitesDialog.description": "Site là container chứa maps và dữ liệu cơ sở trên robot.",
|
||||||
|
"maps.sitesDialog.empty": "Chưa có site.",
|
||||||
|
"maps.sitesDialog.deleteConfirm": "Xóa site \"{name}\"?",
|
||||||
|
"maps.deleteConfirm": "Xóa map \"{name}\"?",
|
||||||
|
"maps.deleteDialog.title": "Xóa map?",
|
||||||
|
"maps.deleteDialog.text": "Xóa map \"{name}\"? Hành động không hoàn tác.",
|
||||||
|
"maps.deleteDialog.activeWarning": "Map này đang là map hoạt động trên robot.",
|
||||||
|
"maps.deleteForbidden": "Bạn không thể xóa map thuộc nhóm người dùng khác.",
|
||||||
|
"maps.error.nameEmpty": "Tên map không được để trống.",
|
||||||
|
"maps.error.noImage": "Map chưa có ảnh — upload PNG trước khi kích hoạt.",
|
||||||
|
"maps.error.pngOnly": "Chỉ chấp nhận file PNG.",
|
||||||
|
"maps.activateDialog.title": "Kích hoạt map?",
|
||||||
|
"maps.activateDialog.text": "Đặt \"{name}\" làm map hoạt động của robot?",
|
||||||
|
"maps.menu.title": "Upload, download and record maps",
|
||||||
|
"maps.menu.uploadOverwrite": "Upload and overwrite",
|
||||||
|
"maps.menu.uploadOverwriteDesc": "Thay map hiện tại bằng map upload.",
|
||||||
|
"maps.menu.uploadAppend": "Upload and append",
|
||||||
|
"maps.menu.uploadAppendDesc": "Upload map mới và ghép vào map hiện tại.",
|
||||||
|
"maps.menu.download": "Download map",
|
||||||
|
"maps.menu.downloadDesc": "Tải map hiện tại.",
|
||||||
|
"maps.menu.recordOverwrite": "Record and overwrite",
|
||||||
|
"maps.menu.recordOverwriteDesc": "Thay map hiện tại bằng bản ghi map mới.",
|
||||||
|
"maps.menu.recordAppend": "Record and append",
|
||||||
|
"maps.menu.recordAppendDesc": "Ghi map mới và ghép vào map hiện tại.",
|
||||||
|
"maps.menu.comingSoon": "Sắp có",
|
||||||
|
"maps.menu.recordHint": "Cần LiDAR",
|
||||||
|
"maps.settings.title": "Cài đặt map",
|
||||||
|
"maps.settings.name": "Tên",
|
||||||
|
"maps.settings.description": "Mô tả",
|
||||||
|
"maps.settings.resolution": "Resolution (m/px)",
|
||||||
|
"maps.settings.originX": "Origin X",
|
||||||
|
"maps.settings.originY": "Origin Y",
|
||||||
|
"maps.settings.originYaw": "Origin yaw",
|
||||||
|
"maps.editor.originLabelShort": "Gốc ({x}, {y})",
|
||||||
|
"maps.editor.originTooltip": "Origin map: X={x} m, Y={y} m, yaw={yaw}°",
|
||||||
|
"maps.uploadConfirm.title": "Ghi đè floor plan?",
|
||||||
|
"maps.uploadConfirm.text": "Ảnh map hiện tại sẽ bị thay thế. Tiếp tục?",
|
||||||
|
"maps.uploadConfirm.yes": "Ghi đè",
|
||||||
|
"maps.uploadMeta.title": "Metadata map (ROS)",
|
||||||
|
"maps.uploadMeta.hint": "Nhập origin, resolution và ngưỡng occupancy — hoặc import file .yaml.",
|
||||||
|
"maps.uploadMeta.importYaml": "Import file YAML…",
|
||||||
|
"maps.uploadMeta.negate": "Negate",
|
||||||
|
"maps.uploadMeta.occupiedThresh": "Occupied thresh",
|
||||||
|
"maps.uploadMeta.freeThresh": "Free thresh",
|
||||||
|
"maps.uploadMeta.continue": "Tiếp tục — chọn PNG",
|
||||||
|
"maps.uploadMeta.invalidResolution": "Resolution phải lớn hơn 0.",
|
||||||
|
"maps.uploadMeta.invalidYaml": "Không đọc được file YAML.",
|
||||||
|
"maps.editor.back": "Maps",
|
||||||
|
"maps.editor.goBack": "Quay lại",
|
||||||
|
"maps.editor.subtitle": "Chỉnh sửa và vẽ map.",
|
||||||
|
"maps.editor.helpTitle": "Trợ giúp map editor",
|
||||||
|
"maps.editor.helpText": "Ba lớp: View (pan/zoom màn hình) → Image (pixel floor plan, 20 px/m) → World (X,Y mét). Dùng Pan, zoom, Fit; di chuột để xem tọa độ.",
|
||||||
|
"maps.editor.toolbarAria": "Mapping tools",
|
||||||
|
"maps.editor.canvasTip": "Kéo map để di chuyển vùng nhìn hoặc dùng nút zoom in/out để phóng to/thu nhỏ.",
|
||||||
|
"maps.editor.unsaved": "Chưa lưu",
|
||||||
|
"maps.editor.unsavedLeave": "Có thay đổi chưa lưu. Rời editor?",
|
||||||
|
"maps.editor.menu": "Menu",
|
||||||
|
"maps.editor.undo": "Hoàn tác",
|
||||||
|
"maps.editor.save": "Lưu",
|
||||||
|
"maps.editor.settings": "Cài đặt",
|
||||||
|
"maps.editor.tool.search": "Tìm kiếm",
|
||||||
|
"maps.editor.tool.save": "Lưu map",
|
||||||
|
"maps.editor.tool.pan": "Pan — di chuyển vùng nhìn",
|
||||||
|
"maps.editor.tool.crosshair": "Crosshair",
|
||||||
|
"maps.editor.tool.origin": "Hiển thị gốc tọa độ",
|
||||||
|
"maps.editor.tool.center": "Căn giữa vùng nhìn",
|
||||||
|
"maps.editor.tool.lidar": "Hiển thị LiDAR",
|
||||||
|
"maps.editor.tool.waypoints": "Vị trí / waypoint",
|
||||||
|
"maps.editor.fit": "Vừa khung",
|
||||||
|
"maps.editor.zoomIn": "Phóng to",
|
||||||
|
"maps.editor.zoomOut": "Thu nhỏ",
|
||||||
|
"maps.editor.noData": "Chưa có floor plan — menu ⋮ để upload PNG.",
|
||||||
|
"maps.editor.statusView": "zoom {zoom}% · pan ({panX}, {panY})",
|
||||||
|
"maps.editor.statusImageIdle": "— px (di chuột trên map)",
|
||||||
|
"maps.editor.statusImage": "({px}, {py}) px",
|
||||||
|
"maps.editor.statusWorldIdle": "— m",
|
||||||
|
"maps.editor.statusWorld": "X {x}, Y {y} m",
|
||||||
|
"maps.editor.objectTypesNone": "Chưa chọn object-type",
|
||||||
|
"maps.editor.objectType.wall": "Walls",
|
||||||
|
"maps.editor.objectType.floor": "Floors",
|
||||||
|
"maps.editor.objectType.position": "Positions",
|
||||||
|
"maps.editor.objectType.forbidden": "Forbidden zones",
|
||||||
|
"maps.editor.objectType.preferred": "Preferred zones",
|
||||||
|
"maps.editor.objectType.unpreferred": "Unpreferred zones",
|
||||||
|
"maps.editor.objectType.speed": "Speed zones",
|
||||||
|
"maps.editor.objectType.sound": "Sound zones",
|
||||||
|
"maps.editor.objectType.directional": "Directional zones",
|
||||||
|
"maps.editor.objectType.directionalLine": "Directional lines",
|
||||||
|
"maps.editor.objectType.planner": "Planner zones",
|
||||||
|
"maps.editor.objectType.io": "I/O zones",
|
||||||
|
"maps.editor.filter.walls": "Walls",
|
||||||
|
"maps.editor.filter.floors": "Floors",
|
||||||
|
"maps.editor.filter.positions": "Positions",
|
||||||
|
"maps.editor.filter.forbidden": "Forbidden",
|
||||||
|
"maps.editor.filter.preferred": "Preferred",
|
||||||
|
"maps.editor.filter.unpreferred": "Unpreferred",
|
||||||
|
"maps.editor.filter.speed": "Speed",
|
||||||
|
"maps.editor.filter.sound": "Sound",
|
||||||
|
"maps.editor.tool.drawLine": "Vẽ line — polyline (wall, drive zone line…)",
|
||||||
|
"maps.editor.tool.drawShape": "Vẽ shape — polygon (floor, zones…)",
|
||||||
|
"maps.editor.drawLine.title": "Vẽ line mới",
|
||||||
|
"maps.editor.drawLine.lineWidth": "Độ rộng line (cm)",
|
||||||
|
"maps.editor.drawLine.hint": "Chỉnh độ rộng line (cm), xác nhận, rồi click trên map để đặt các điểm.",
|
||||||
|
"maps.editor.drawLine.confirm": "Xác nhận",
|
||||||
|
"maps.editor.drawLine.lineWidthInvalid": "Nhập độ rộng line hợp lệ (wall ≥ 5 cm, zone line ≥ 10 cm).",
|
||||||
|
"maps.editor.tool.draw": "Vẽ — line hoặc polygon (walls, zones…)",
|
||||||
|
"maps.editor.tool.select": "Chọn object",
|
||||||
|
"maps.editor.tool.erase": "Tẩy object",
|
||||||
|
"maps.editor.tool.eraser": "Tẩy pixel — xóa noise trên floor plan (Walls/Floors layer)",
|
||||||
|
"maps.editor.tool.eraseShape": "Xóa line/shape đã vẽ",
|
||||||
|
"maps.editor.tool.eraseSelection": "Xóa vùng chọn — tẩy pixel trong khung",
|
||||||
|
"maps.editor.tool.confirmDraw": "Xác nhận hình vẽ",
|
||||||
|
"maps.editor.drawNeedMorePoints": "Cần thêm điểm trước khi xác nhận (line ≥ 2, polygon ≥ 3).",
|
||||||
|
"maps.editor.position.title": "Position",
|
||||||
|
"maps.editor.position.name": "Tên",
|
||||||
|
"maps.editor.position.x": "X (m)",
|
||||||
|
"maps.editor.position.y": "Y (m)",
|
||||||
|
"maps.editor.position.yaw": "Hướng (°)",
|
||||||
|
"maps.editor.position.hint": "Click map và kéo để đặt hướng, rồi xác nhận.",
|
||||||
|
"maps.editor.position.invalid": "Nhập X, Y và hướng hợp lệ.",
|
||||||
|
"maps.editor.speed.title": "Speed zone",
|
||||||
|
"maps.editor.speed.limit": "Giới hạn tốc độ (m/s)",
|
||||||
|
"maps.editor.speed.hint": "Robot giảm tốc trong vùng này (0.1–1.5 m/s).",
|
||||||
|
"maps.editor.speed.invalid": "Nhập tốc độ hợp lệ (0.1–1.5 m/s).",
|
||||||
|
"maps.editor.sound.title": "Sound zone",
|
||||||
|
"maps.editor.sound.select": "Sound",
|
||||||
|
"maps.editor.sound.noSound": "— Chọn sound —",
|
||||||
|
"maps.editor.sound.manage": "Quản lý sounds tại Setup → Sounds",
|
||||||
|
"maps.editor.sound.invalid": "Chọn một sound.",
|
||||||
|
"maps.editor.directional.title": "Directional zone",
|
||||||
|
"maps.editor.directional.direction": "Hướng",
|
||||||
|
"maps.editor.directional.degOption": "{deg}°",
|
||||||
|
"maps.editor.directional.shapeHint": "Robot không được di chuyển ngược hướng mũi tên (bước 45°).",
|
||||||
|
"maps.editor.directional.lineWidth": "Độ rộng line (cm)",
|
||||||
|
"maps.editor.directional.reversed": "Đảo hướng",
|
||||||
|
"maps.editor.directional.lineHint": "Hướng theo line từ điểm đầu đến điểm cuối.",
|
||||||
|
"maps.editor.directional.lineWidthInvalid": "Nhập độ rộng line hợp lệ (≥ 10 cm).",
|
||||||
|
"maps.editor.planner.title": "Planner zone",
|
||||||
|
"maps.editor.planner.noLocalization": "No localization (chỉ encoder)",
|
||||||
|
"maps.editor.planner.lookAhead": "Look-ahead (thu hẹp field of view)",
|
||||||
|
"maps.editor.planner.ignoreObstacles": "Ignore obstacles",
|
||||||
|
"maps.editor.planner.pathDeviation": "Path deviation (m)",
|
||||||
|
"maps.editor.planner.pathTimeout": "Path timeout (s)",
|
||||||
|
"maps.editor.io.title": "I/O zone",
|
||||||
|
"maps.editor.io.module": "I/O module",
|
||||||
|
"maps.editor.io.plcRegister": "PLC register",
|
||||||
|
"maps.editor.io.plcValue": "Giá trị",
|
||||||
|
"maps.editor.io.plcMode": "PLC mode",
|
||||||
|
"maps.editor.io.plcModeSet": "Set",
|
||||||
|
"maps.editor.io.plcModeAdd": "Add",
|
||||||
|
"maps.editor.io.plcModeSubtract": "Subtract",
|
||||||
|
"maps.editor.io.hint": "Robot kích hoạt I/O khi vào vùng này.",
|
||||||
|
"maps.editor.io.moduleRequired": "Nhập tên I/O module.",
|
||||||
|
"maps.menu.save": "Lưu map",
|
||||||
|
|
||||||
|
"sounds.title": "Sounds",
|
||||||
|
"sounds.subtitle": "Setup → Sounds — upload và quản lý âm thanh robot cho sound zones.",
|
||||||
|
"sounds.create": "Tạo sound",
|
||||||
|
"sounds.createTitle": "Tạo sound",
|
||||||
|
"sounds.editTitle": "Sửa sound",
|
||||||
|
"sounds.empty": "Chưa có sound. Tạo mới để dùng trong sound zones.",
|
||||||
|
"sounds.name": "Tên",
|
||||||
|
"sounds.description": "Mô tả",
|
||||||
|
"sounds.enabled": "Bật",
|
||||||
|
"sounds.file": "File âm thanh",
|
||||||
|
"sounds.noFile": "Chưa có file",
|
||||||
|
"sounds.upload": "Upload file…",
|
||||||
|
"sounds.play": "Phát",
|
||||||
|
"sounds.playFailed": "Không phát được file.",
|
||||||
|
"sounds.fileMeta": "{name} · {duration}",
|
||||||
|
"sounds.nameRequired": "Nhập tên sound.",
|
||||||
|
"sounds.deleteConfirm": "Xóa sound này?",
|
||||||
|
|
||||||
"missions.title": "Missions",
|
"missions.title": "Missions",
|
||||||
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
|
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
|
||||||
"missions.create": "Tạo mission",
|
"missions.create": "Tạo mission",
|
||||||
@@ -341,7 +624,7 @@
|
|||||||
"help.api.body2": "Reference Guide MiR rev 1.9: docs/Reference guide.pdf",
|
"help.api.body2": "Reference Guide MiR rev 1.9: docs/Reference guide.pdf",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
"app.title": "LiDAR Manager",
|
"app.title": "Robot App",
|
||||||
"app.robotName": "RobotApp",
|
"app.robotName": "RobotApp",
|
||||||
"app.status.ready": "Ready",
|
"app.status.ready": "Ready",
|
||||||
"app.status.reloaded": "Reloaded",
|
"app.status.reloaded": "Reloaded",
|
||||||
@@ -363,6 +646,9 @@
|
|||||||
"common.error": "Error: {msg}",
|
"common.error": "Error: {msg}",
|
||||||
"common.none": "none",
|
"common.none": "none",
|
||||||
"common.optional": "Optional",
|
"common.optional": "Optional",
|
||||||
|
"common.yes": "Yes",
|
||||||
|
"common.no": "No",
|
||||||
|
"common.ok": "OK",
|
||||||
|
|
||||||
"login.prompt": "Choose sign-in method:",
|
"login.prompt": "Choose sign-in method:",
|
||||||
"login.tab.password": "Username and password",
|
"login.tab.password": "Username and password",
|
||||||
@@ -401,8 +687,11 @@
|
|||||||
"nav.help": "Help",
|
"nav.help": "Help",
|
||||||
"nav.logout": "Log out",
|
"nav.logout": "Log out",
|
||||||
"nav.dashboard": "Dashboard",
|
"nav.dashboard": "Dashboard",
|
||||||
|
"nav.dashboardsList": "Dashboards",
|
||||||
"nav.missions": "Missions",
|
"nav.missions": "Missions",
|
||||||
"nav.maps": "Maps & layout",
|
"nav.maps": "Maps",
|
||||||
|
"nav.sounds": "Sounds",
|
||||||
|
"nav.build-robot": "Build Robot",
|
||||||
"nav.monitoring-log": "System log",
|
"nav.monitoring-log": "System log",
|
||||||
"nav.integrations": "Integrations",
|
"nav.integrations": "Integrations",
|
||||||
"nav.help-api": "API documentation",
|
"nav.help-api": "API documentation",
|
||||||
@@ -451,6 +740,52 @@
|
|||||||
|
|
||||||
"dashboard.title": "Dashboard",
|
"dashboard.title": "Dashboard",
|
||||||
"dashboard.subtitle": "Mission widgets — run, queue and pause like MiR Fleet.",
|
"dashboard.subtitle": "Mission widgets — run, queue and pause like MiR Fleet.",
|
||||||
|
"dashboard.list.title": "Dashboards",
|
||||||
|
"dashboard.list.subtitle": "Create and edit dashboards for the robot.",
|
||||||
|
"dashboard.list.create": "+ Create dashboard",
|
||||||
|
"dashboard.list.clearFilters": "Clear filters",
|
||||||
|
"dashboard.list.filterLabel": "Filter:",
|
||||||
|
"dashboard.list.filterPlaceholder": "Write name to filter by…",
|
||||||
|
"dashboard.list.itemsFound": "{count} item(s) found",
|
||||||
|
"dashboard.list.pageOf": "Page {page} of {total}",
|
||||||
|
"dashboard.list.col.name": "Name",
|
||||||
|
"dashboard.list.col.createdBy": "Created by",
|
||||||
|
"dashboard.list.col.functions": "Functions",
|
||||||
|
"dashboard.list.empty": "No dashboards found.",
|
||||||
|
"dashboard.list.back": "← Back to list",
|
||||||
|
"dashboard.list.design": "Design",
|
||||||
|
"dashboard.list.active": "Active dashboard",
|
||||||
|
"dashboard.list.edit": "Edit",
|
||||||
|
"dashboard.list.delete": "Delete",
|
||||||
|
"dashboard.list.deleteConfirm": "Delete dashboard «{name}»?",
|
||||||
|
"dashboard.list.cannotDeleteDefault": "Cannot delete Default Dashboard.",
|
||||||
|
"dashboard.list.noEditPermission": "You do not have permission to edit this dashboard.",
|
||||||
|
"dashboard.dialog.create.title": "Create dashboard",
|
||||||
|
"dashboard.create.title": "Create dashboard",
|
||||||
|
"dashboard.create.subtitle": "Create a new dashboard in the robot.",
|
||||||
|
"dashboard.create.backToList": "← Back to the list",
|
||||||
|
"dashboard.create.name": "Name",
|
||||||
|
"dashboard.create.namePlaceholder": "John's Dashboard",
|
||||||
|
"dashboard.create.permissions": "Select user groups allowed to edit this dashboard.",
|
||||||
|
"dashboard.create.permissionsBtn": "Permissions",
|
||||||
|
"dashboard.create.permissionsTitle": "Permissions",
|
||||||
|
"dashboard.create.submit": "Create dashboard",
|
||||||
|
"dashboard.create.cancel": "Cancel",
|
||||||
|
"dashboard.dialog.editDashboard.title": "Edit dashboard",
|
||||||
|
"dashboard.designer.empty": "This dashboard has no widgets yet.",
|
||||||
|
"dashboard.designer.emptyEdit": "No widgets yet. Pick a type from the Maps / Missions / Miscellaneous toolbar.",
|
||||||
|
"dashboard.designer.dragHint": "Drag the header bar to move the widget on the grid",
|
||||||
|
"dashboard.designer.configure": "Configure widget",
|
||||||
|
"dashboard.designer.resize": "Drag to resize",
|
||||||
|
"dashboard.designer.save": "Save",
|
||||||
|
"dashboard.designer.saved": "Saved",
|
||||||
|
"dashboard.menu.maps": "Maps",
|
||||||
|
"dashboard.menu.missions": "Missions",
|
||||||
|
"dashboard.menu.plc": "PLC Registers",
|
||||||
|
"dashboard.menu.io": "I/O",
|
||||||
|
"dashboard.menu.misc": "Miscellaneous",
|
||||||
|
"dashboard.menu.comingSoon": "Widgets in this category are coming in a future release.",
|
||||||
|
"dashboard.createdBy.system": "MiR",
|
||||||
"dashboard.addWidget": "Add widget",
|
"dashboard.addWidget": "Add widget",
|
||||||
"dashboard.editLayout": "Edit layout",
|
"dashboard.editLayout": "Edit layout",
|
||||||
"dashboard.editDone": "Done",
|
"dashboard.editDone": "Done",
|
||||||
@@ -471,6 +806,19 @@
|
|||||||
"dashboard.widget.mission_group": "Mission group",
|
"dashboard.widget.mission_group": "Mission group",
|
||||||
"dashboard.widget.mission_queue": "Mission queue",
|
"dashboard.widget.mission_queue": "Mission queue",
|
||||||
"dashboard.widget.pause_continue": "Pause / Continue",
|
"dashboard.widget.pause_continue": "Pause / Continue",
|
||||||
|
"dashboard.widget.mission_action_log": "Mission action log",
|
||||||
|
"dashboard.widget.logout_button": "Log-out button",
|
||||||
|
"dashboard.widget.map_locked": "Locked map",
|
||||||
|
"dashboard.widget.map": "Map",
|
||||||
|
"dashboard.widget.robot_summary": "Robot summary",
|
||||||
|
"dashboard.widget.field.map": "Map",
|
||||||
|
"dashboard.widget.mapActive": "Active map (robot)",
|
||||||
|
"dashboard.widget.mapHint": "Pick a fixed map or leave «Active map» to follow the robot's current map.",
|
||||||
|
"dashboard.widget.mapLoading": "Loading map…",
|
||||||
|
"dashboard.widget.mapEmpty": "No maps yet. A Distributor can create maps via /api/maps.",
|
||||||
|
"dashboard.widget.mapNoImage": "No map image yet — upload via POST /api/maps/{id}/image",
|
||||||
|
"dashboard.widget.mapImageError": "Could not load the map image.",
|
||||||
|
"dashboard.widget.actionLog.empty": "No running action to show.",
|
||||||
"dashboard.widget.field.mission": "Mission",
|
"dashboard.widget.field.mission": "Mission",
|
||||||
"dashboard.widget.field.group": "Mission group",
|
"dashboard.widget.field.group": "Mission group",
|
||||||
"dashboard.widget.field.title": "Widget title (optional)",
|
"dashboard.widget.field.title": "Widget title (optional)",
|
||||||
@@ -555,6 +903,224 @@
|
|||||||
"config.motor.custom": "Custom",
|
"config.motor.custom": "Custom",
|
||||||
"config.motor.customMotor": "Custom motor",
|
"config.motor.customMotor": "Custom motor",
|
||||||
|
|
||||||
|
"maps.title": "Maps",
|
||||||
|
"maps.subtitle": "Create and edit maps.",
|
||||||
|
"maps.create": "Create map",
|
||||||
|
"maps.importSite": "Import site",
|
||||||
|
"maps.clearFilters": "Clear filters",
|
||||||
|
"maps.filterLabel": "Filter:",
|
||||||
|
"maps.filterPlaceholder": "Write name to filter by...",
|
||||||
|
"maps.itemsFound": "{n} item(s) found",
|
||||||
|
"maps.pageOf": "Page {page} of {total}",
|
||||||
|
"maps.colName": "Name",
|
||||||
|
"maps.colCreatedBy": "Created by",
|
||||||
|
"maps.colFunctions": "Functions",
|
||||||
|
"maps.empty": "No maps yet. Click Create map to get started.",
|
||||||
|
"maps.emptyFilter": "No maps match the filter.",
|
||||||
|
"maps.activeBadge": "ACTIVE",
|
||||||
|
"maps.activeHint": "Active map: {name}",
|
||||||
|
"maps.view": "View",
|
||||||
|
"maps.importComingSoon": "Import site will be available in a future release.",
|
||||||
|
"maps.helpTitle": "Maps help",
|
||||||
|
"maps.helpText": "Create a new map, upload a PNG via the ⋮ menu, then activate the map for the robot.",
|
||||||
|
"maps.createDialog.title": "Create map",
|
||||||
|
"maps.createDialog.name": "Name *",
|
||||||
|
"maps.createDialog.site": "Site *",
|
||||||
|
"maps.createDialog.manageSite": "Create / Edit site…",
|
||||||
|
"maps.createDialog.submit": "Create map",
|
||||||
|
"maps.createPage.title": "Create map",
|
||||||
|
"maps.createPage.subtitle": "Create a new map.",
|
||||||
|
"maps.createPage.goBack": "Go back",
|
||||||
|
"maps.createPage.name": "Name",
|
||||||
|
"maps.createPage.namePlaceholder": "Enter the map's name...",
|
||||||
|
"maps.createPage.nameHelp": "Display name shown in the Maps list.",
|
||||||
|
"maps.createPage.site": "Site",
|
||||||
|
"maps.createPage.siteHelp": "The facility site that contains this map.",
|
||||||
|
"maps.createPage.siteManage": "Create / Edit",
|
||||||
|
"maps.createPage.submit": "Create map",
|
||||||
|
"maps.createPage.cancel": "Cancel",
|
||||||
|
"maps.createPage.helpText": "Enter a map name and select a site, then click Create map to open the editor.",
|
||||||
|
"maps.siteDialog.create": "Create site",
|
||||||
|
"maps.siteDialog.edit": "Edit site",
|
||||||
|
"maps.siteDialog.name": "Name *",
|
||||||
|
"maps.siteForm.create": "Create site",
|
||||||
|
"maps.siteForm.edit": "Edit site",
|
||||||
|
"maps.siteForm.name": "Name *",
|
||||||
|
"maps.sitesDialog.title": "Sites",
|
||||||
|
"maps.sitesDialog.createSite": "Create site",
|
||||||
|
"maps.sitesDialog.description": "A site is a container for maps and other facility data on the robot.",
|
||||||
|
"maps.sitesDialog.empty": "No sites yet.",
|
||||||
|
"maps.sitesDialog.deleteConfirm": "Delete site \"{name}\"?",
|
||||||
|
"maps.deleteConfirm": "Delete map \"{name}\"?",
|
||||||
|
"maps.deleteDialog.title": "Delete map?",
|
||||||
|
"maps.deleteDialog.text": "Delete map \"{name}\"? This cannot be undone.",
|
||||||
|
"maps.deleteDialog.activeWarning": "This map is currently active on the robot.",
|
||||||
|
"maps.deleteForbidden": "You cannot delete a map from another user group.",
|
||||||
|
"maps.error.nameEmpty": "Map name is required.",
|
||||||
|
"maps.error.noImage": "Map has no image — upload a PNG before activating.",
|
||||||
|
"maps.error.pngOnly": "Only PNG files are accepted.",
|
||||||
|
"maps.activateDialog.title": "Activate map?",
|
||||||
|
"maps.activateDialog.text": "Set \"{name}\" as the robot's active map?",
|
||||||
|
"maps.menu.title": "Upload, download and record maps",
|
||||||
|
"maps.menu.uploadOverwrite": "Upload and overwrite",
|
||||||
|
"maps.menu.uploadOverwriteDesc": "Replace existing map with uploaded map.",
|
||||||
|
"maps.menu.uploadAppend": "Upload and append",
|
||||||
|
"maps.menu.uploadAppendDesc": "Upload a new map and append it to current map.",
|
||||||
|
"maps.menu.download": "Download map",
|
||||||
|
"maps.menu.downloadDesc": "Download the current map.",
|
||||||
|
"maps.menu.recordOverwrite": "Record and overwrite",
|
||||||
|
"maps.menu.recordOverwriteDesc": "Replace existing map with new recording of map.",
|
||||||
|
"maps.menu.recordAppend": "Record and append",
|
||||||
|
"maps.menu.recordAppendDesc": "Record a new map and append it to current map.",
|
||||||
|
"maps.menu.comingSoon": "Coming soon",
|
||||||
|
"maps.menu.recordHint": "Requires LiDAR",
|
||||||
|
"maps.settings.title": "Map settings",
|
||||||
|
"maps.settings.name": "Name",
|
||||||
|
"maps.settings.description": "Description",
|
||||||
|
"maps.settings.resolution": "Resolution (m/px)",
|
||||||
|
"maps.settings.originX": "Origin X",
|
||||||
|
"maps.settings.originY": "Origin Y",
|
||||||
|
"maps.settings.originYaw": "Origin yaw",
|
||||||
|
"maps.editor.originLabelShort": "Origin ({x}, {y})",
|
||||||
|
"maps.editor.originTooltip": "Map origin: X={x} m, Y={y} m, yaw={yaw}°",
|
||||||
|
"maps.uploadConfirm.title": "Overwrite floor plan?",
|
||||||
|
"maps.uploadConfirm.text": "The current map image will be replaced. Continue?",
|
||||||
|
"maps.uploadConfirm.yes": "Overwrite",
|
||||||
|
"maps.uploadMeta.title": "Map metadata",
|
||||||
|
"maps.uploadMeta.hint": "Enter origin, resolution, and occupancy thresholds — or import a .yaml file.",
|
||||||
|
"maps.uploadMeta.importYaml": "Import YAML file…",
|
||||||
|
"maps.uploadMeta.negate": "Negate",
|
||||||
|
"maps.uploadMeta.occupiedThresh": "Occupied thresh",
|
||||||
|
"maps.uploadMeta.freeThresh": "Free thresh",
|
||||||
|
"maps.uploadMeta.continue": "Continue — choose PNG",
|
||||||
|
"maps.uploadMeta.invalidResolution": "Resolution must be greater than 0.",
|
||||||
|
"maps.uploadMeta.invalidYaml": "Could not read YAML file.",
|
||||||
|
"maps.editor.back": "Maps",
|
||||||
|
"maps.editor.goBack": "Go back",
|
||||||
|
"maps.editor.subtitle": "Edit and draw the map.",
|
||||||
|
"maps.editor.helpTitle": "Map editor help",
|
||||||
|
"maps.editor.helpText": "Three layers: View (screen pan/zoom) → Image (floor plan pixels, 20 px/m) → World (X,Y in metres). Use Pan, zoom, Fit; hover for coordinates.",
|
||||||
|
"maps.editor.toolbarAria": "Mapping tools",
|
||||||
|
"maps.editor.canvasTip": "Drag the map to move your view or use the zoom-in and -out buttons to zoom.",
|
||||||
|
"maps.editor.unsaved": "Unsaved",
|
||||||
|
"maps.editor.unsavedLeave": "You have unsaved changes. Leave the editor?",
|
||||||
|
"maps.editor.menu": "Menu",
|
||||||
|
"maps.editor.undo": "Undo",
|
||||||
|
"maps.editor.save": "Save",
|
||||||
|
"maps.editor.settings": "Settings",
|
||||||
|
"maps.editor.tool.search": "Search",
|
||||||
|
"maps.editor.tool.save": "Save map",
|
||||||
|
"maps.editor.tool.pan": "Pan — move view",
|
||||||
|
"maps.editor.tool.crosshair": "Crosshair",
|
||||||
|
"maps.editor.tool.origin": "Show map origin",
|
||||||
|
"maps.editor.tool.center": "Center view",
|
||||||
|
"maps.editor.tool.lidar": "LiDAR overlay",
|
||||||
|
"maps.editor.tool.waypoints": "Positions",
|
||||||
|
"maps.editor.fit": "Fit to view",
|
||||||
|
"maps.editor.zoomIn": "Zoom in",
|
||||||
|
"maps.editor.zoomOut": "Zoom out",
|
||||||
|
"maps.editor.noData": "No floor plan — ⋮ menu to upload PNG.",
|
||||||
|
"maps.editor.statusView": "zoom {zoom}% · pan ({panX}, {panY})",
|
||||||
|
"maps.editor.statusImageIdle": "— px (hover map)",
|
||||||
|
"maps.editor.statusImage": "({px}, {py}) px",
|
||||||
|
"maps.editor.statusWorldIdle": "— m",
|
||||||
|
"maps.editor.statusWorld": "X {x}, Y {y} m",
|
||||||
|
"maps.editor.objectTypesNone": "No object-type selected",
|
||||||
|
"maps.editor.objectType.wall": "Walls",
|
||||||
|
"maps.editor.objectType.floor": "Floors",
|
||||||
|
"maps.editor.objectType.position": "Positions",
|
||||||
|
"maps.editor.objectType.forbidden": "Forbidden zones",
|
||||||
|
"maps.editor.objectType.preferred": "Preferred zones",
|
||||||
|
"maps.editor.objectType.unpreferred": "Unpreferred zones",
|
||||||
|
"maps.editor.objectType.speed": "Speed zones",
|
||||||
|
"maps.editor.objectType.sound": "Sound zones",
|
||||||
|
"maps.editor.objectType.directional": "Directional zones",
|
||||||
|
"maps.editor.objectType.directionalLine": "Directional lines",
|
||||||
|
"maps.editor.objectType.planner": "Planner zones",
|
||||||
|
"maps.editor.objectType.io": "I/O zones",
|
||||||
|
"maps.editor.filter.walls": "Walls",
|
||||||
|
"maps.editor.filter.floors": "Floors",
|
||||||
|
"maps.editor.filter.positions": "Positions",
|
||||||
|
"maps.editor.filter.forbidden": "Forbidden",
|
||||||
|
"maps.editor.filter.preferred": "Preferred",
|
||||||
|
"maps.editor.filter.unpreferred": "Unpreferred",
|
||||||
|
"maps.editor.filter.speed": "Speed",
|
||||||
|
"maps.editor.filter.sound": "Sound",
|
||||||
|
"maps.editor.tool.drawLine": "Draw line — polyline (wall, drive zone line…)",
|
||||||
|
"maps.editor.tool.drawShape": "Draw shape — polygon (floor, zones…)",
|
||||||
|
"maps.editor.drawLine.title": "Draw a new line",
|
||||||
|
"maps.editor.drawLine.lineWidth": "Line width (cm)",
|
||||||
|
"maps.editor.drawLine.hint": "Set the line width in cm, confirm, then click on the map to place points.",
|
||||||
|
"maps.editor.drawLine.confirm": "Confirm",
|
||||||
|
"maps.editor.drawLine.lineWidthInvalid": "Enter a valid line width (wall ≥ 5 cm, zone line ≥ 10 cm).",
|
||||||
|
"maps.editor.tool.draw": "Draw — line or polygon (walls, zones…)",
|
||||||
|
"maps.editor.tool.select": "Select object",
|
||||||
|
"maps.editor.tool.erase": "Eraser",
|
||||||
|
"maps.editor.tool.eraser": "Pixel eraser — remove noise on floor plan (Walls/Floors layer)",
|
||||||
|
"maps.editor.tool.eraseShape": "Erase shape or line",
|
||||||
|
"maps.editor.tool.eraseSelection": "Erase by selection — clear pixels in rectangle",
|
||||||
|
"maps.editor.tool.confirmDraw": "Confirm shape",
|
||||||
|
"maps.editor.drawNeedMorePoints": "Add more points before confirming (line ≥ 2, polygon ≥ 3).",
|
||||||
|
"maps.editor.position.title": "Position",
|
||||||
|
"maps.editor.position.name": "Name",
|
||||||
|
"maps.editor.position.x": "X (m)",
|
||||||
|
"maps.editor.position.y": "Y (m)",
|
||||||
|
"maps.editor.position.yaw": "Orientation (°)",
|
||||||
|
"maps.editor.position.hint": "Click map and drag to set orientation, then confirm.",
|
||||||
|
"maps.editor.position.invalid": "Enter valid X, Y and orientation.",
|
||||||
|
"maps.editor.speed.title": "Speed zone",
|
||||||
|
"maps.editor.speed.limit": "Speed limit (m/s)",
|
||||||
|
"maps.editor.speed.hint": "Robot slows to this speed inside the zone (0.1–1.5 m/s).",
|
||||||
|
"maps.editor.speed.invalid": "Enter a valid speed (0.1–1.5 m/s).",
|
||||||
|
"maps.editor.sound.title": "Sound zone",
|
||||||
|
"maps.editor.sound.select": "Sound",
|
||||||
|
"maps.editor.sound.noSound": "— Select sound —",
|
||||||
|
"maps.editor.sound.manage": "Manage sounds in Setup → Sounds",
|
||||||
|
"maps.editor.sound.invalid": "Select a sound.",
|
||||||
|
"maps.editor.directional.title": "Directional zone",
|
||||||
|
"maps.editor.directional.direction": "Direction",
|
||||||
|
"maps.editor.directional.degOption": "{deg}°",
|
||||||
|
"maps.editor.directional.shapeHint": "Robot cannot move opposite to the arrow (45° steps).",
|
||||||
|
"maps.editor.directional.lineWidth": "Line width (cm)",
|
||||||
|
"maps.editor.directional.reversed": "Reverse direction",
|
||||||
|
"maps.editor.directional.lineHint": "Direction follows the line from first to last point.",
|
||||||
|
"maps.editor.directional.lineWidthInvalid": "Enter a valid line width (≥ 10 cm).",
|
||||||
|
"maps.editor.planner.title": "Planner zone",
|
||||||
|
"maps.editor.planner.noLocalization": "No localization (encoders only)",
|
||||||
|
"maps.editor.planner.lookAhead": "Look-ahead (narrow field of view)",
|
||||||
|
"maps.editor.planner.ignoreObstacles": "Ignore obstacles",
|
||||||
|
"maps.editor.planner.pathDeviation": "Path deviation (m)",
|
||||||
|
"maps.editor.planner.pathTimeout": "Path timeout (s)",
|
||||||
|
"maps.editor.io.title": "I/O zone",
|
||||||
|
"maps.editor.io.module": "I/O module",
|
||||||
|
"maps.editor.io.plcRegister": "PLC register",
|
||||||
|
"maps.editor.io.plcValue": "Value",
|
||||||
|
"maps.editor.io.plcMode": "PLC mode",
|
||||||
|
"maps.editor.io.plcModeSet": "Set",
|
||||||
|
"maps.editor.io.plcModeAdd": "Add",
|
||||||
|
"maps.editor.io.plcModeSubtract": "Subtract",
|
||||||
|
"maps.editor.io.hint": "Robot activates I/O when entering the zone.",
|
||||||
|
"maps.editor.io.moduleRequired": "Enter an I/O module name.",
|
||||||
|
"maps.menu.save": "Save map",
|
||||||
|
|
||||||
|
"sounds.title": "Sounds",
|
||||||
|
"sounds.subtitle": "Setup → Sounds — upload and manage robot sounds for sound zones.",
|
||||||
|
"sounds.create": "Create sound",
|
||||||
|
"sounds.createTitle": "Create sound",
|
||||||
|
"sounds.editTitle": "Edit sound",
|
||||||
|
"sounds.empty": "No sounds yet. Create one to use in sound zones.",
|
||||||
|
"sounds.name": "Name",
|
||||||
|
"sounds.description": "Description",
|
||||||
|
"sounds.enabled": "Enabled",
|
||||||
|
"sounds.file": "Audio file",
|
||||||
|
"sounds.noFile": "No file uploaded",
|
||||||
|
"sounds.upload": "Upload file…",
|
||||||
|
"sounds.play": "Play",
|
||||||
|
"sounds.playFailed": "Could not play file.",
|
||||||
|
"sounds.fileMeta": "{name} · {duration}",
|
||||||
|
"sounds.nameRequired": "Enter a sound name.",
|
||||||
|
"sounds.deleteConfirm": "Delete this sound?",
|
||||||
|
|
||||||
"missions.title": "Missions",
|
"missions.title": "Missions",
|
||||||
"missions.subtitle": "Setup → Missions — robot task list.",
|
"missions.subtitle": "Setup → Missions — robot task list.",
|
||||||
"missions.create": "Create mission",
|
"missions.create": "Create mission",
|
||||||
|
|||||||
962
www/index.html
962
www/index.html
File diff suppressed because it is too large
Load Diff
233
www/map-advanced-zones.js
Normal file
233
www/map-advanced-zones.js
Normal 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
89
www/map-behavior-zones.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
(() => {
|
||||||
|
/**
|
||||||
|
* Runtime hooks for Speed / Sound map zones (MiR §4.2.6.8–9).
|
||||||
|
* 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
2476
www/map-editor.js
Normal file
File diff suppressed because it is too large
Load Diff
176
www/map-geo.js
Normal file
176
www/map-geo.js
Normal 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
97
www/map-import.js
Normal 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
868
www/map-objects.js
Normal 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
178
www/map-occupancy-canvas.js
Normal 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
241
www/map-occupancy-edit.js
Normal 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
133
www/map-planner-zones.js
Normal 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
104
www/map-yaml.js
Normal 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
641
www/maps.js
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
50
www/nav.js
50
www/nav.js
@@ -8,12 +8,15 @@
|
|||||||
|
|
||||||
const MODULES = {
|
const MODULES = {
|
||||||
dashboards: {
|
dashboards: {
|
||||||
items: [{ section: "dashboard", page: "dashboard" }],
|
items: [{ section: "dashboard-list", page: "dashboard" }],
|
||||||
|
dynamic: true,
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
items: [
|
items: [
|
||||||
{ section: "missions", page: "missions" },
|
{ section: "missions", page: "missions" },
|
||||||
{ section: "maps", page: "config" },
|
{ section: "maps", page: "maps" },
|
||||||
|
{ section: "sounds", page: "sounds" },
|
||||||
|
{ section: "build-robot", page: "config" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
monitoring: {
|
monitoring: {
|
||||||
@@ -28,16 +31,18 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_NAV = {
|
const PAGE_NAV = {
|
||||||
dashboard: { module: "dashboards", section: "dashboard" },
|
dashboard: { module: "dashboards", section: "dashboard-list" },
|
||||||
config: { module: "setup", section: "maps" },
|
config: { module: "setup", section: "build-robot" },
|
||||||
|
maps: { module: "setup", section: "maps" },
|
||||||
missions: { module: "setup", section: "missions" },
|
missions: { module: "setup", section: "missions" },
|
||||||
|
sounds: { module: "setup", section: "sounds" },
|
||||||
integrations: { module: "system", section: "integrations" },
|
integrations: { module: "system", section: "integrations" },
|
||||||
monitoring: { module: "monitoring", section: "monitoring-log" },
|
monitoring: { module: "monitoring", section: "monitoring-log" },
|
||||||
help: { module: "help", section: "help-api" },
|
help: { module: "help", section: "help-api" },
|
||||||
};
|
};
|
||||||
|
|
||||||
let activeModule = "setup";
|
let activeModule = "setup";
|
||||||
let activeSection = "maps";
|
let activeSection = "missions";
|
||||||
let flyoutOpen = true;
|
let flyoutOpen = true;
|
||||||
|
|
||||||
const shellEl = () => document.getElementById("mirNavShell");
|
const shellEl = () => document.getElementById("mirNavShell");
|
||||||
@@ -54,10 +59,24 @@
|
|||||||
return true;
|
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) {
|
function visibleItems(moduleId) {
|
||||||
const mod = MODULES[moduleId];
|
const mod = MODULES[moduleId];
|
||||||
if (!mod) return [];
|
if (!mod) return [];
|
||||||
return mod.items.filter((item) => canAccessPage(item.page));
|
return moduleItems(moduleId).filter((item) => canAccessPage(item.page));
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemLabel(item) {
|
||||||
|
if (item.label) return item.label;
|
||||||
|
return t(item.section);
|
||||||
}
|
}
|
||||||
|
|
||||||
function moduleHasAccess(moduleId) {
|
function moduleHasAccess(moduleId) {
|
||||||
@@ -89,7 +108,7 @@
|
|||||||
btn.className = "mirNavFlyoutItem";
|
btn.className = "mirNavFlyoutItem";
|
||||||
btn.dataset.section = item.section;
|
btn.dataset.section = item.section;
|
||||||
btn.dataset.page = item.page;
|
btn.dataset.page = item.page;
|
||||||
btn.textContent = t(item.section);
|
btn.textContent = itemLabel(item);
|
||||||
if (item.section === activeSection) {
|
if (item.section === activeSection) {
|
||||||
btn.classList.add("is-active");
|
btn.classList.add("is-active");
|
||||||
btn.setAttribute("aria-current", "page");
|
btn.setAttribute("aria-current", "page");
|
||||||
@@ -159,6 +178,14 @@
|
|||||||
saveState();
|
saveState();
|
||||||
updateRailUI();
|
updateRailUI();
|
||||||
navigateToPage(page);
|
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) {
|
function navigateToPage(page) {
|
||||||
@@ -172,6 +199,7 @@
|
|||||||
activeSection = nav.section;
|
activeSection = nav.section;
|
||||||
saveState();
|
saveState();
|
||||||
updateRailUI();
|
updateRailUI();
|
||||||
|
if (page === "dashboard") window.DashboardApp?.handleNav?.(activeSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFlyout() {
|
function toggleFlyout() {
|
||||||
@@ -195,7 +223,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreInitialPage() {
|
function restoreInitialPage() {
|
||||||
let page = "config";
|
let page = "missions";
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem("activePage");
|
const saved = localStorage.getItem("activePage");
|
||||||
if (saved && PAGE_NAV[saved]) page = saved;
|
if (saved && PAGE_NAV[saved]) page = saved;
|
||||||
@@ -231,6 +259,10 @@
|
|||||||
navigateToPage(page);
|
navigateToPage(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshFlyout() {
|
||||||
|
updateRailUI();
|
||||||
|
}
|
||||||
|
|
||||||
function refreshLabels() {
|
function refreshLabels() {
|
||||||
window.I18n?.applyDOM?.();
|
window.I18n?.applyDOM?.();
|
||||||
updateRailUI();
|
updateRailUI();
|
||||||
@@ -260,9 +292,11 @@
|
|||||||
window.NavApp = {
|
window.NavApp = {
|
||||||
init,
|
init,
|
||||||
syncFromPage,
|
syncFromPage,
|
||||||
|
syncDashboardSection,
|
||||||
applyPermissions,
|
applyPermissions,
|
||||||
selectModule,
|
selectModule,
|
||||||
selectSection,
|
selectSection,
|
||||||
toggleFlyout,
|
toggleFlyout,
|
||||||
|
refreshFlyout,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
280
www/sounds.js
Normal file
280
www/sounds.js
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
3042
www/style.css
3042
www/style.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user