Compare commits
19 Commits
1a8bddb037
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 064c9b5758 | |||
| 365a15c32a | |||
| 90e8e9d252 | |||
| 819323f8c8 | |||
| a6cf06d7eb | |||
| 098e1b2b69 | |||
| 4054d81aaf | |||
| a2e87aeb29 | |||
| 1156e1ab29 | |||
| 9aee5f4100 | |||
| 6fa15b69e7 | |||
| 4b372100eb | |||
| 6cc51a35c4 | |||
| 1716351016 | |||
| 9776e29d7d | |||
| c05b1d5f5c | |||
| fbc0c11be2 | |||
| d6f22132ce | |||
| 695a942a5d |
33
.github/workflows/test.yml
vendored
Normal file
33
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "src/**"
|
||||||
|
- "tests/**"
|
||||||
|
- "www/**"
|
||||||
|
- "CMakeLists.txt"
|
||||||
|
- "scripts/**"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "src/**"
|
||||||
|
- "tests/**"
|
||||||
|
- "www/**"
|
||||||
|
- "CMakeLists.txt"
|
||||||
|
- "scripts/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends build-essential cmake curl python3 python3-pip
|
||||||
|
|
||||||
|
- name: Run test suite
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/lm.sh scripts/test/*.sh scripts/lib/*.sh
|
||||||
|
./scripts/lm.sh test run
|
||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -13,3 +13,18 @@
|
|||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# Runtime data (SQLite + media)
|
||||||
|
maps/
|
||||||
|
RBS.db
|
||||||
|
RBS.db-wal
|
||||||
|
RBS.db-shm
|
||||||
|
data/RBS.db
|
||||||
|
data/RBS.db-wal
|
||||||
|
data/RBS.db-shm
|
||||||
|
data/maps/*
|
||||||
|
!data/maps/.gitkeep
|
||||||
|
data/sounds/*
|
||||||
|
!data/sounds/.gitkeep
|
||||||
|
data/recordings/*
|
||||||
|
!data/recordings/.gitkeep
|
||||||
@@ -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
|
||||||
@@ -34,8 +36,15 @@ add_executable(lidar_manager_web
|
|||||||
src/util/string_util.cpp
|
src/util/string_util.cpp
|
||||||
src/util/id_util.cpp
|
src/util/id_util.cpp
|
||||||
src/util/http_util.cpp
|
src/util/http_util.cpp
|
||||||
|
src/util/crypto_util.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
|
||||||
@@ -45,10 +54,14 @@ add_executable(lidar_manager_web
|
|||||||
src/mission/mission_enqueue.cpp
|
src/mission/mission_enqueue.cpp
|
||||||
src/mission/modbus_trigger_service.cpp
|
src/mission/modbus_trigger_service.cpp
|
||||||
src/mission/mission_scheduler.cpp
|
src/mission/mission_scheduler.cpp
|
||||||
|
src/robot/robot_runtime.cpp
|
||||||
src/server/api_mission_routes.cpp
|
src/server/api_mission_routes.cpp
|
||||||
|
src/server/api_robot_routes.cpp
|
||||||
|
src/server/api_media_routes.cpp
|
||||||
|
src/server/api_dashboard_routes.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads)
|
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads SQLite::SQLite3)
|
||||||
|
|
||||||
target_include_directories(lidar_manager_web PRIVATE
|
target_include_directories(lidar_manager_web PRIVATE
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||||
@@ -62,3 +75,47 @@ target_include_directories(lidar_manager_web SYSTEM PRIVATE
|
|||||||
target_compile_definitions(lidar_manager_web PRIVATE
|
target_compile_definitions(lidar_manager_web PRIVATE
|
||||||
_DEFAULT_SOURCE
|
_DEFAULT_SOURCE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
option(BUILD_TESTING "Build unit tests (requires tests/ directory)" OFF)
|
||||||
|
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
enable_testing()
|
||||||
|
include(FetchContent)
|
||||||
|
FetchContent_Declare(
|
||||||
|
googletest
|
||||||
|
URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.tar.gz
|
||||||
|
)
|
||||||
|
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
|
||||||
|
FetchContent_MakeAvailable(googletest)
|
||||||
|
|
||||||
|
set(LM_TEST_LIB_SOURCES
|
||||||
|
src/util/file_util.cpp
|
||||||
|
src/util/string_util.cpp
|
||||||
|
src/util/id_util.cpp
|
||||||
|
src/storage/database.cpp
|
||||||
|
src/mission/mission_store.cpp
|
||||||
|
src/mission/mission_enqueue.cpp
|
||||||
|
src/validation/sensor_validator.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
add_executable(lidar_manager_tests
|
||||||
|
tests/test_mission_enqueue.cpp
|
||||||
|
tests/test_mission_store.cpp
|
||||||
|
tests/test_sensor_validator.cpp
|
||||||
|
${LM_TEST_LIB_SOURCES}
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(lidar_manager_tests PRIVATE
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||||
|
)
|
||||||
|
target_include_directories(lidar_manager_tests SYSTEM PRIVATE
|
||||||
|
"${nlohmann_json_SOURCE_DIR}/single_include"
|
||||||
|
)
|
||||||
|
target_compile_definitions(lidar_manager_tests PRIVATE
|
||||||
|
TEST_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/data"
|
||||||
|
)
|
||||||
|
target_link_libraries(lidar_manager_tests PRIVATE GTest::gtest_main SQLite::SQLite3)
|
||||||
|
include(GoogleTest)
|
||||||
|
gtest_discover_tests(lidar_manager_tests WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
|
||||||
|
add_test(NAME unit COMMAND lidar_manager_tests)
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ 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
|
||||||
COPY CMakeLists.txt ./
|
COPY CMakeLists.txt ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
|
||||||
RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
|
RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF \
|
||||||
&& cmake --build build -j"$(nproc)"
|
&& cmake --build build -j"$(nproc)"
|
||||||
|
|
||||||
FROM ubuntu:20.04 AS runtime
|
FROM ubuntu:20.04 AS runtime
|
||||||
@@ -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.
96
README.md
96
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,31 +27,61 @@ 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)
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|------|----------|------|
|
||||||
|
| Admin | admin | Administrators (full quyền) |
|
||||||
|
| User | user | Users (dashboard write, còn lại read) |
|
||||||
|
| Distributor | distributor | Distributors (full quyền) |
|
||||||
|
|
||||||
|
PIN 4 chữ số chỉ dùng được với user thuộc nhóm **Users** sau khi admin gán PIN (`PUT /api/users/:id`).
|
||||||
|
|
||||||
|
Tắt auth cho dev/test: `LM_AUTH_DISABLED=1 ./build/lidar_manager_web …`
|
||||||
|
Tài liệu đầy đủ: [`docs/Reference_guide.md` §2.1](docs/Reference_guide.md#21-signing-in).
|
||||||
|
|
||||||
## Docker (giới hạn 2 CPU, 4 GB RAM)
|
## Docker (giới hạn 2 CPU, 4 GB RAM)
|
||||||
|
|
||||||
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
|
||||||
sudo docker compose up --build -d
|
./scripts/lm.sh docker up
|
||||||
# hoặc: sudo ./scripts/docker-up.sh
|
# hoặc: sudo docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Kiểm tra giới hạn:
|
Kiểm tra giới hạn:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./scripts/docker-stats.sh
|
./scripts/lm.sh docker stats
|
||||||
```
|
```
|
||||||
|
|
||||||
Dừng:
|
Dừng:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo docker compose down
|
./scripts/lm.sh docker down
|
||||||
```
|
```
|
||||||
|
|
||||||
Dữ liệu layout vẫn lưu tại `data/` trên host (volume mount).
|
Dữ liệu layout vẫn lưu tại `data/` trên host (volume mount).
|
||||||
@@ -58,8 +90,7 @@ Kiểm tra tài nguyên trong container:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Vào shell container
|
# Vào shell container
|
||||||
sudo docker exec -it lidar-manager-limited bash
|
./scripts/lm.sh docker shell
|
||||||
# hoặc: sudo ./scripts/docker-shell.sh
|
|
||||||
|
|
||||||
# Trong container, thử:
|
# Trong container, thử:
|
||||||
htop # CPU/RAM (q để thoát)
|
htop # CPU/RAM (q để thoát)
|
||||||
@@ -70,8 +101,43 @@ cat /proc/meminfo | head
|
|||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# htop / stats từ ngoài (không cần vào shell)
|
./scripts/lm.sh docker htop
|
||||||
sudo ./scripts/docker-htop.sh
|
./scripts/lm.sh docker stats
|
||||||
sudo ./scripts/docker-stats.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Test tự động
|
||||||
|
|
||||||
|
Chạy toàn bộ: unit C++ (GTest), API smoke (`curl`), pytest integration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/robotics/RD/RBS
|
||||||
|
chmod +x scripts/lm.sh scripts/test/*.sh
|
||||||
|
./scripts/lm.sh test run
|
||||||
|
```
|
||||||
|
|
||||||
|
Chỉ unit test C++:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -S . -B build -DBUILD_TESTING=ON
|
||||||
|
cmake --build build -j
|
||||||
|
ctest --test-dir build --output-on-failure
|
||||||
|
```
|
||||||
|
|
||||||
|
Chỉ API smoke (server đang chạy, dùng fixture `tests/fixtures/data/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build/lidar_manager_web 18080 www tests/fixtures/data/state.json &
|
||||||
|
./scripts/lm.sh test smoke http://127.0.0.1:18080
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixture mission id mặc định: `testmission00001` (`tests/fixtures/data/missions.json`).
|
||||||
|
|
||||||
|
Benchmark hiệu năng trong container (cần `docker compose up -d`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/lm.sh docker bench
|
||||||
|
# hoặc chỉ HTTP: ./scripts/lm.sh bench http
|
||||||
|
```
|
||||||
|
|
||||||
|
CI: GitHub Actions workflow `.github/workflows/test.yml`.
|
||||||
|
|
||||||
|
|||||||
1
Testing/Temporary/CTestCostData.txt
Normal file
1
Testing/Temporary/CTestCostData.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
---
|
||||||
3
Testing/Temporary/LastTest.log
Normal file
3
Testing/Temporary/LastTest.log
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Start testing: Jun 13 13:41 +07
|
||||||
|
----------------------------------------------------------
|
||||||
|
End testing: Jun 13 13:41 +07
|
||||||
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
|
||||||
0
data/maps/.gitkeep
Normal file
0
data/maps/.gitkeep
Normal file
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"queue": [
|
|
||||||
{
|
|
||||||
"created_at": "2026-06-13T06:34:14Z",
|
|
||||||
"finished_at": "2026-06-13T06:34:15Z",
|
|
||||||
"id": "e164539b35bf3886",
|
|
||||||
"log": [
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"message": "Wait 1000ms",
|
|
||||||
"ts": "2026-06-13T06:34:14Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"mission": {
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"id": "a1",
|
|
||||||
"kind": "action",
|
|
||||||
"label": "Wait",
|
|
||||||
"params": {
|
|
||||||
"seconds": 1
|
|
||||||
},
|
|
||||||
"type": "wait"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "",
|
|
||||||
"group": "Missions",
|
|
||||||
"id": "5ae9dbcb0722dffb",
|
|
||||||
"name": "Test run",
|
|
||||||
"updated_at": "2026-06-13T04:44:03Z"
|
|
||||||
},
|
|
||||||
"mission_group": "Missions",
|
|
||||||
"mission_id": "5ae9dbcb0722dffb",
|
|
||||||
"mission_name": "Test run",
|
|
||||||
"parameters": {},
|
|
||||||
"priority": 0,
|
|
||||||
"robot_id": "default",
|
|
||||||
"source": "rest_api_v2",
|
|
||||||
"started_at": "2026-06-13T06:34:14Z",
|
|
||||||
"status": "completed"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"runner": {
|
|
||||||
"current_action": null,
|
|
||||||
"current_queue_id": null,
|
|
||||||
"message": "Hoàn thành: Test run",
|
|
||||||
"paused": false,
|
|
||||||
"state": "idle",
|
|
||||||
"updated_at": "2026-06-13T06:34:15Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
{
|
|
||||||
"dashboard": {
|
|
||||||
"widgets": []
|
|
||||||
},
|
|
||||||
"groups": [
|
|
||||||
"Missions",
|
|
||||||
"Move",
|
|
||||||
"Logic",
|
|
||||||
"I/O",
|
|
||||||
"Cart",
|
|
||||||
"Misc"
|
|
||||||
],
|
|
||||||
"missions": [
|
|
||||||
{
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"id": "a1",
|
|
||||||
"kind": "action",
|
|
||||||
"label": "Wait",
|
|
||||||
"params": {
|
|
||||||
"seconds": 1
|
|
||||||
},
|
|
||||||
"type": "wait"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "",
|
|
||||||
"group": "Missions",
|
|
||||||
"id": "5ae9dbcb0722dffb",
|
|
||||||
"name": "Test run",
|
|
||||||
"updated_at": "2026-06-13T04:44:03Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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-05-29T10:11:49Z"
|
|
||||||
}
|
|
||||||
0
data/recordings/.gitkeep
Normal file
0
data/recordings/.gitkeep
Normal file
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-05-29T10:11:49Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"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"
|
||||||
|
|||||||
BIN
docs/Reference guide.pdf
Normal file
BIN
docs/Reference guide.pdf
Normal file
Binary file not shown.
446
docs/Reference_guide.md
Normal file
446
docs/Reference_guide.md
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
# MiR Robot Reference Guide — Tóm tắt
|
||||||
|
|
||||||
|
> Nguồn: `docs/Reference guide.pdf`
|
||||||
|
> **MiR robot Reference guide (en), rev. 1.9, 03/2019**
|
||||||
|
> Mô tả giao diện web trên robot MiR (không phải User Guide phần cứng MiR250).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Giới thiệu
|
||||||
|
|
||||||
|
Tài liệu dành cho **administrator** và người cấu hình hệ thống: tạo mission, map, user, dashboard, Modbus trigger.
|
||||||
|
|
||||||
|
Tài liệu liên quan khác (Distributor site / Support Portal):
|
||||||
|
|
||||||
|
| Loại | Nội dung |
|
||||||
|
|------|----------|
|
||||||
|
| Quick Start | Vận hành nhanh (in trong hộp robot) |
|
||||||
|
| User Guide | Vận hành & bảo trì robot (MiR250 có bản riêng) |
|
||||||
|
| Commissioning / Risk Analysis | Đưa robot vào sản xuất an toàn |
|
||||||
|
| REST API Reference | Robot, Hook, Fleet |
|
||||||
|
| Network & WiFi Guide | Yêu cầu mạng |
|
||||||
|
|
||||||
|
- Fleet (scheduler, robot groups): tài liệu riêng *MiR Fleet Reference Guide*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MiR robot interface (ch. 2)
|
||||||
|
|
||||||
|
Giao diện web trên robot: **responsive** (PC, tablet, portrait/landscape). Truy cập qua WiFi AP robot hoặc LAN (`http://<robot_ip>` / `mir.com`).
|
||||||
|
|
||||||
|
### 2.1 Signing in
|
||||||
|
|
||||||
|
> **RBS:** tính năng đã triển khai — xem [RBS — Signing in](#RBS--signing-in-đã-triển-khai).
|
||||||
|
|
||||||
|
#### Luồng truy cập (MiR)
|
||||||
|
|
||||||
|
```
|
||||||
|
Thiết bị → kết nối mạng robot → trình duyệt → trang Sign in → shell app (Dashboard / Setup / …)
|
||||||
|
```
|
||||||
|
|
||||||
|
Toàn bộ UI bị chặn cho đến khi đăng nhập thành công.
|
||||||
|
|
||||||
|
#### Hai cách đăng nhập
|
||||||
|
|
||||||
|
| Cách | Đối tượng | Giao diện |
|
||||||
|
|------|-----------|-----------|
|
||||||
|
| **Username + password** | Distributor, Administrator, kỹ sư | Tab form username + password |
|
||||||
|
| **PIN 4 chữ số** | Operator sàn (quyền thấp) | Tab PIN; **không có PIN mặc định** |
|
||||||
|
|
||||||
|
#### Ba cấp truy cập mặc định
|
||||||
|
|
||||||
|
| Role | Username | Password mặc định | Vai trò |
|
||||||
|
|------|----------|-------------------|---------|
|
||||||
|
| **Distributor** | `Distributor` | Liên hệ MiR Support | Đại lý; full quyền; quản lý quyền Admin/User |
|
||||||
|
| **Administrator** | `Admin` | `admin` | Kỹ sư khách hàng; full read/write |
|
||||||
|
| **User** | `User` | `user` | Operator hàng ngày |
|
||||||
|
|
||||||
|
> *MiR250 Quick Start:* đổi password mặc định ngay; mỗi người một tài khoản; chỉ operator cấp thấp dùng PIN; Admin/Distributor dùng password mạnh.
|
||||||
|
|
||||||
|
#### Tách credentials và permissions
|
||||||
|
|
||||||
|
| Lớp | Gắn với | Nội dung |
|
||||||
|
|-----|---------|----------|
|
||||||
|
| **Credentials** | **User** (cá nhân) | username, password, PIN |
|
||||||
|
| **Permissions** | **User group** (nhóm) | module nào được xem / sửa |
|
||||||
|
|
||||||
|
Mỗi user thuộc **một** user group. Mục không có quyền write: **vẫn hiển thị nhưng không chỉnh sửa được**.
|
||||||
|
|
||||||
|
#### User groups mặc định (mục 4.6)
|
||||||
|
|
||||||
|
| Nhóm | Quyền mặc định |
|
||||||
|
|------|----------------|
|
||||||
|
| **Distributors** | Full R/W; quản lý quyền Administrators và Users |
|
||||||
|
| **Administrators** | Full R/W; quản lý quyền Users |
|
||||||
|
| **Users** | Xem toàn UI; tạo/sửa **dashboard** |
|
||||||
|
|
||||||
|
Admin có thể tạo thêm user group (ví dụ `Operators`) và gán quyền từng module (Maps, Missions, System…).
|
||||||
|
|
||||||
|
#### Sau khi đăng nhập
|
||||||
|
|
||||||
|
- **Góc phải trên:** tên user → đổi password, Sign out.
|
||||||
|
- Admin tạo user tại **Setup → Users**; nhóm tại **Setup → User groups** (tạo group **trước** user).
|
||||||
|
- Dashboard gán quyền theo group qua nút **Permissions** khi tạo/sửa dashboard.
|
||||||
|
- Widget **Log-out button** trên dashboard (hữu ích trên tablet).
|
||||||
|
|
||||||
|
#### Bảo mật (Quick Start + SW mới)
|
||||||
|
|
||||||
|
- MiR **không** ép password policy phức tạp trên robot đơn lẻ.
|
||||||
|
- 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**.
|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
|
||||||
|
##### Luồng người dùng
|
||||||
|
|
||||||
|
```
|
||||||
|
Trình duyệt → / (trang Sign in)
|
||||||
|
→ POST /api/auth/login (password hoặc PIN)
|
||||||
|
→ Cookie lm_session + shell app (Dashboard / Cấu hình / Missions / Tích hợp)
|
||||||
|
→ Menu user (góc phải): đổi mật khẩu, đăng xuất
|
||||||
|
```
|
||||||
|
|
||||||
|
- Static (`www/`) phục vụ công khai để tải trang login.
|
||||||
|
- `auth.js` gọi `GET /api/auth/me` khi mở trang; session hợp lệ thì vào app ngay.
|
||||||
|
- `app.js`, `missions.js`, `dashboard.js`, `integrations.js` chỉ khởi động sau sự kiện `lm:auth-ready`.
|
||||||
|
- API mission queue và các endpoint khác **không** được gọi trước khi đăng nhập.
|
||||||
|
|
||||||
|
##### Giao diện web (MiR-style)
|
||||||
|
|
||||||
|
| Thành phần | Mô tả |
|
||||||
|
|------------|--------|
|
||||||
|
| Nền | Xanh `#3d6cb3`, full-screen |
|
||||||
|
| Header | Tên robot (`RobotApp`) + «Chọn cách đăng nhập» + 2 tab |
|
||||||
|
| Tab **Tên đăng nhập và mật khẩu** | 2 cột: hướng dẫn trái, form phải; nút xanh «Đăng nhập» |
|
||||||
|
| Tab **Mã PIN** | Trái: hướng dẫn + 4 ô vuông (•); phải: keypad 1–9, 0, ✕ |
|
||||||
|
| PIN | Tự đăng nhập khi đủ 4 số; hỗ trợ bàn phím vật lý |
|
||||||
|
| Sau login | Menu user topbar; ẩn/vô hiệu menu theo quyền read-only |
|
||||||
|
|
||||||
|
File: `www/index.html`, `www/auth.js`, `www/style.css`.
|
||||||
|
|
||||||
|
##### Tài khoản mặc định
|
||||||
|
|
||||||
|
Tự tạo lần đầu trong `data/auth.json` (cùng thư mục `state.json`):
|
||||||
|
|
||||||
|
| Username | Password | User group | Ghi chú |
|
||||||
|
|----------|----------|------------|---------|
|
||||||
|
| `Admin` | `admin` | Administrators | Full quyền |
|
||||||
|
| `User` | `user` | Users | Dashboard write; phần còn lại read |
|
||||||
|
| `Distributor` | `distributor` | Distributors | Full quyền |
|
||||||
|
|
||||||
|
- Username đăng nhập **không phân biệt hoa thường** (`admin` = `Admin`).
|
||||||
|
- **PIN:** không có mã mặc định (giống MiR). Chỉ nhóm **Users** (`allow_pin: true`); admin gán qua API.
|
||||||
|
|
||||||
|
##### User groups và permissions
|
||||||
|
|
||||||
|
Credentials → **user**; quyền → **group**. Module: `dashboard`, `config`, `missions`, `integrations`, `users` — giá trị `none` | `read` | `write`.
|
||||||
|
|
||||||
|
| Group | PIN | dashboard | config | missions | integrations | users |
|
||||||
|
|-------|-----|-----------|--------|----------|--------------|-------|
|
||||||
|
| Distributors | Không | write | write | write | write | write |
|
||||||
|
| Administrators | Không | write | write | write | write | write |
|
||||||
|
| Users | Sau khi gán | write | read | read | read | none |
|
||||||
|
|
||||||
|
| Group | Menu UI |
|
||||||
|
|-------|---------|
|
||||||
|
| Users | Dashboard + xem Cấu hình/Missions/Tích hợp (nút ghi read-only) |
|
||||||
|
| Administrators / Distributors | Toàn bộ menu; quản lý user qua API |
|
||||||
|
|
||||||
|
##### Session và middleware
|
||||||
|
|
||||||
|
| Cơ chế | Chi tiết |
|
||||||
|
|--------|----------|
|
||||||
|
| Session | Server-side; mất khi restart process |
|
||||||
|
| Cookie | `lm_session=<token>; HttpOnly; SameSite=Lax` |
|
||||||
|
| Header | `Authorization: Bearer <token>` |
|
||||||
|
| Middleware | `AuthService::preRoute` trên `/api/*` |
|
||||||
|
| Public | `GET /api/health`, `POST /api/auth/login`, `POST /api/auth/logout`, `OPTIONS` |
|
||||||
|
| Dev | `LM_AUTH_DISABLED=1` tắt auth |
|
||||||
|
|
||||||
|
**API → module** (kiểm tra read/write):
|
||||||
|
|
||||||
|
| Module | Prefix |
|
||||||
|
|--------|--------|
|
||||||
|
| config | `/api/lidars`, `/api/imus`, `/api/layouts`, `/api/state`, … |
|
||||||
|
| missions | `/api/missions`, `/api/mission_queue` |
|
||||||
|
| integrations | `/api/triggers`, `/api/schedules`, `/api/fleet`, `/api/modbus`, `/api/v2.0.0/` |
|
||||||
|
| users | `/api/users`, `/api/user_groups` |
|
||||||
|
|
||||||
|
##### REST API
|
||||||
|
|
||||||
|
| Method | Endpoint | Auth | Mô tả |
|
||||||
|
|--------|----------|------|--------|
|
||||||
|
| POST | `/api/auth/login` | Public | `{ username, password }` hoặc `{ pin }` |
|
||||||
|
| POST | `/api/auth/logout` | Public | Xóa session + cookie |
|
||||||
|
| GET | `/api/auth/me` | Session | User, group, permissions |
|
||||||
|
| PUT | `/api/auth/password` | Session | Đổi mật khẩu |
|
||||||
|
| GET | `/api/user_groups` | users read | Danh sách nhóm |
|
||||||
|
| GET | `/api/users` | users read | Danh sách user |
|
||||||
|
| POST | `/api/users` | users write | Tạo user |
|
||||||
|
| PUT | `/api/users/:id` | users write | Sửa user / gán PIN (`pin: null` = xóa) |
|
||||||
|
| DELETE | `/api/users/:id` | users write | Xóa user |
|
||||||
|
|
||||||
|
**Ví dụ login + gán PIN**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -c c.txt -X POST http://localhost:8080/api/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"Admin","password":"admin"}'
|
||||||
|
|
||||||
|
curl -b c.txt -X PUT http://localhost:8080/api/users/user_operator \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"pin":"1234"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Lưu trữ và mã nguồn
|
||||||
|
|
||||||
|
| Thành phần | Vị trí |
|
||||||
|
|------------|--------|
|
||||||
|
| Dữ liệu | `data/auth.json` — groups, users (hash + salt) |
|
||||||
|
| Backend | `src/auth/auth_service.cpp`, `src/util/crypto_util.cpp`, `src/app/lidar_manager_app.cpp` |
|
||||||
|
| Frontend | `www/auth.js`, `www/index.html`, `www/style.css` |
|
||||||
|
| Test | `scripts/test/smoke.sh`, `tests/test_api_integration.py` |
|
||||||
|
|
||||||
|
Hash: SHA-256 + salt (`sha256(salt:password)` / `sha256(salt:pin:pin)`).
|
||||||
|
|
||||||
|
##### Kiểm thử và vận hành
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/lm.sh test run # smoke tự login Admin; pytest test_auth_*
|
||||||
|
```
|
||||||
|
|
||||||
|
- Docker: `www/` copy lúc build → `docker compose up --build -d` sau sửa UI.
|
||||||
|
- Hard refresh (`Ctrl+Shift+R`) nếu cache JS/CSS.
|
||||||
|
|
||||||
|
##### So sánh MiR ↔ RBS
|
||||||
|
|
||||||
|
| MiR §2.1 | RBS |
|
||||||
|
|----------|-------|
|
||||||
|
| Sign in bắt buộc | Có |
|
||||||
|
| Tab password \| PIN + keypad | Có |
|
||||||
|
| 3 role mặc định | Admin / User / Distributor |
|
||||||
|
| PIN không mặc định | Có — admin gán API |
|
||||||
|
| User menu, đổi password, sign out | Có |
|
||||||
|
| Credentials / permissions tách biệt | Có |
|
||||||
|
| Setup → Users (UI) | Chưa — chỉ API |
|
||||||
|
| Auto sign-out / OAuth Fleet | Chưa |
|
||||||
|
|
||||||
|
### 2.2 Navigating the MiR robot interface
|
||||||
|
|
||||||
|
- Menu chính → **tối đa một submenu** rồi vào section (ví dụ Setup → Sounds).
|
||||||
|
- **Top bar:** trạng thái robot, nút start/pause.
|
||||||
|
|
||||||
|
### 2.3 Getting started
|
||||||
|
|
||||||
|
Thiết lập user trước vận hành:
|
||||||
|
|
||||||
|
1. **Users** (Setup → Users)
|
||||||
|
2. **User groups** (Setup → User groups)
|
||||||
|
3. **Dashboards** theo vai trò (Dashboards → Create)
|
||||||
|
|
||||||
|
Thiết lập hệ thống: map → chỉnh map (positions, zones) → missions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mục lục đầy đủ
|
||||||
|
|
||||||
|
### 1. About this document
|
||||||
|
- 1.1. Where to find more information
|
||||||
|
- 1.2. Document history
|
||||||
|
|
||||||
|
### 2. MiR robot interface
|
||||||
|
- 2.1. Signing in
|
||||||
|
- 2.2. Navigating the MiR robot interface
|
||||||
|
- 2.3. Getting started
|
||||||
|
|
||||||
|
### 3. Dashboards
|
||||||
|
- **3.1. Dashboards** — Create / designer / edit / delete
|
||||||
|
- **3.2. Widgets**
|
||||||
|
- 3.2.1. **Maps** — Locked map, Map
|
||||||
|
- 3.2.2. **Missions** — Mission button, Pause/Continue, Mission queue, Mission action log, Mission group
|
||||||
|
- 3.2.3. **PLC registers** — PLC button/display
|
||||||
|
- 3.2.4. **I/O module** — Connect, configuration, status
|
||||||
|
- 3.2.5. **Hook** — Cart actions (Pick up / Place cart)
|
||||||
|
- 3.2.6. **Miscellaneous** — Joystick, Robot summary, Distributor, Log-out button
|
||||||
|
|
||||||
|
### 4. Setup
|
||||||
|
- **4.1. Missions** — Start, Create, Editor, Actions (xem §4 bên dưới)
|
||||||
|
- **4.2. Maps** — Site, mapping, object types (walls, zones…), delete
|
||||||
|
- **4.3. Sounds** — Edit sound
|
||||||
|
- **4.4. Transitions** — Chuyển map tự động
|
||||||
|
- **4.5. Users** — CRUD user
|
||||||
|
- **4.6. User groups** — CRUD nhóm
|
||||||
|
- **4.7. Shelf types**
|
||||||
|
- **4.8. I/O modules** — Kết nối Modbus/TCP I/O
|
||||||
|
- **4.9. Paths**
|
||||||
|
- **4.10. Path guides**
|
||||||
|
|
||||||
|
### 5. Monitoring
|
||||||
|
- Analytics, System log, Error logs, Hardware health, Safety system
|
||||||
|
- **Mission log** / Mission action log
|
||||||
|
|
||||||
|
### 6. System
|
||||||
|
- Settings (WiFi, date/time), Processes, PLC registers, Software versions, Backups
|
||||||
|
- **Robot setup** — bật Modbus, cấu hình robot
|
||||||
|
- **Triggers** — gắn coil Modbus → mission_id
|
||||||
|
|
||||||
|
### 7. Help
|
||||||
|
- Robot & Hook info, **API documentation**, Remote access, Service book, Manual
|
||||||
|
|
||||||
|
### 8. Hook
|
||||||
|
- Manual control, Carts (type, calibration), Settings, Setup
|
||||||
|
|
||||||
|
### 9. Modbus register reference
|
||||||
|
- 9.1. Status messages (registers 4001+)
|
||||||
|
- 9.2. PLC triggers (int/float)
|
||||||
|
- 9.3. Mission triggers (coil **1001–2000**)
|
||||||
|
- 9.4. Action commands (coil **1–6**)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Ba cách chạy Mission (mục 4.1.1)
|
||||||
|
|
||||||
|
| Cách | Mô tả |
|
||||||
|
|------|--------|
|
||||||
|
| **Dashboard** | Widget Mission button — một mission cố định |
|
||||||
|
| **Setup → Missions** | Bấm icon **queue** → thêm vào mission queue |
|
||||||
|
| **Tích hợp ngoài** | Modbus trigger (coil), REST API (xem Help → API) |
|
||||||
|
|
||||||
|
**Mission queue:** robot chạy tuần tự từ trên xuống; operator có thể sắp xếp lại.
|
||||||
|
|
||||||
|
**Biến (variables):** nếu mission có tham số biến (ví dụ position), operator chọn giá trị khi enqueue — hiển thị **màu xanh** trong queue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Mission editor
|
||||||
|
|
||||||
|
- Mission = chuỗi **actions** (Move, Logic, Battery, I/O, Cart…).
|
||||||
|
- Action có thể dùng **giá trị cố định** hoặc **biến** (hỏi operator mỗi lần enqueue).
|
||||||
|
- Kéo thả ↕ để sắp xếp; thực thi **từ trên xuống dưới**.
|
||||||
|
- Có thể **embed mission con** (icon ◎) trong mission lớn.
|
||||||
|
- **Save** / **Save as** / đổi tên & nhóm qua ⚙.
|
||||||
|
|
||||||
|
### 4.1.4. Mission actions — các nhóm
|
||||||
|
|
||||||
|
#### Variables (4.1.4.1–2)
|
||||||
|
- Khai báo biến dùng chung trong mission.
|
||||||
|
|
||||||
|
#### Move (4.1.4.3)
|
||||||
|
| Action | Mục đích |
|
||||||
|
|--------|----------|
|
||||||
|
| Adjust localization | Hiệu chỉnh vị trí trên map |
|
||||||
|
| Check position status | Kiểm tra position free/occupied (timeout) |
|
||||||
|
| Docking | Dock vào marker / trạm sạc |
|
||||||
|
| Move | Đi tới position (retries, distance threshold) |
|
||||||
|
| Move to entry position | Đi tới entry position trước khi dock/pick |
|
||||||
|
| Move to coordinate | X, Y, orientation tuyệt đối trên map |
|
||||||
|
| Planner settings | Desired speed, path deviation, path timeout |
|
||||||
|
| Relative Move | Dịch chuyển tương đối X/Y/yaw |
|
||||||
|
| Set footprint | Đổi footprint (top module, cart) |
|
||||||
|
| Switch Map | Chuyển map trong mission (cần overlap vật lý) |
|
||||||
|
|
||||||
|
#### Battery (4.1.4.4)
|
||||||
|
- **Charging** — đi dock + sạc theo thời gian tối thiểu hoặc % pin; có thể giữ sạc đến khi có mission mới.
|
||||||
|
|
||||||
|
#### Logic (4.1.4.5)
|
||||||
|
| Action | Mục đích |
|
||||||
|
|--------|----------|
|
||||||
|
| **Break** | Thoát vòng **Loop** |
|
||||||
|
| **Continue** | Bỏ phần còn lại của vòng loop, sang vòng tiếp theo |
|
||||||
|
| **If** | Điều kiện: pin %, pending missions, PLC register, I/O input → nhánh True/False |
|
||||||
|
| **Loop** | Lặp N lần hoặc **endlessly** (đến khi operator dừng); kéo action vào body loop |
|
||||||
|
| **Pause** | Dừng mission đến khi operator bấm Continue |
|
||||||
|
| **Prompt User** | Hỏi Yes/No/Timeout |
|
||||||
|
| **Return** | **Abort mission** (thường trong Try/Catch) |
|
||||||
|
| **Wait** | Chờ N giây |
|
||||||
|
| **While** | Lặp action khi điều kiện còn đúng |
|
||||||
|
|
||||||
|
#### Error handling (4.1.4.6)
|
||||||
|
- **Try/Catch** — Try thất bại → chạy Catch (ví dụ Return).
|
||||||
|
|
||||||
|
#### Sound/Light, PLC, Email, I/O module, Cart, Shelf, UR
|
||||||
|
- Set/wait I/O, PLC register, pick/drop cart, shelf, tích hợp UR cobot, v.v.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Maps
|
||||||
|
|
||||||
|
- **Site** — nhóm nhiều map (tầng/khu vực); robot chuyển map qua Transition hoặc Switch Map action.
|
||||||
|
- **Object types:** Walls, Floors, Positions, Markers, Directional zones, Preferred/Unpreferred/Forbidden/Critical zones, Speed zones, Sound/light zones, Planner zones, I/O zones, Limit-robots (Fleet), Evacuation zones (Fleet).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dashboard widgets
|
||||||
|
|
||||||
|
| Widget MiR | RBS (Cách B) |
|
||||||
|
|------------|----------------|
|
||||||
|
| Mission button | `dashboard.js` — mission_button |
|
||||||
|
| Mission group | mission_group |
|
||||||
|
| Mission queue | mission_queue |
|
||||||
|
| Pause/Continue | pause_continue (+ **Hủy mission** bổ sung trong RBS) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Modbus
|
||||||
|
|
||||||
|
Robot là **Modbus TCP server**. Bật tại **System → Robot setup**, cấu hình trigger tại **System → Triggers**.
|
||||||
|
|
||||||
|
### Mission triggers (coil 1001–2000)
|
||||||
|
Rising edge coil → enqueue mission đã gắn `mission_id`.
|
||||||
|
|
||||||
|
### Action commands (coil 1–6)
|
||||||
|
| Coil | Chức năng |
|
||||||
|
|------|-----------|
|
||||||
|
| 1 | Continue robot |
|
||||||
|
| 2 | Pause robot |
|
||||||
|
| 3 | **Cancel current mission** |
|
||||||
|
| 4 | Clear mission queue |
|
||||||
|
| 5 | Clear error |
|
||||||
|
| 6 | Continue robot |
|
||||||
|
|
||||||
|
### Status registers (ví dụ)
|
||||||
|
Software version, mode, state, error code, battery %, uptime… (registers 4001+).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. REST API
|
||||||
|
|
||||||
|
Tài liệu API đầy đủ: **Help → API documentation → Launch** trên giao diện robot.
|
||||||
|
|
||||||
|
Base URL: `http://<robot_ip>/api/v2.0.0/`
|
||||||
|
|
||||||
|
| Endpoint | Mô tả |
|
||||||
|
|----------|--------|
|
||||||
|
| `GET /status` | Trạng thái robot |
|
||||||
|
| `GET /missions` | Danh sách mission |
|
||||||
|
| `GET /mission_queue` | Queue hiện tại |
|
||||||
|
| `POST /mission_queue` | Enqueue (`mission_id`) |
|
||||||
|
| `DELETE /mission_queue` | Xóa queue |
|
||||||
|
|
||||||
|
Xác thực: HTTP Basic (user/password robot).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Mapping sang dự án RBS
|
||||||
|
|
||||||
|
| Khái niệm MiR Reference Guide | RBS |
|
||||||
|
|------------------------------|--------|
|
||||||
|
| Setup → Missions → queue | **Cách A** — `www/missions.js` |
|
||||||
|
| Dashboard widgets | **Cách B** — `www/dashboard.js` |
|
||||||
|
| Modbus triggers 1001–2000 | **Cách C** — `:5502`, `integrations.js` |
|
||||||
|
| REST v2 mission_queue | `POST /api/v2.0.0/mission_queue` |
|
||||||
|
| MiR Fleet schedule | `/api/fleet/schedules` |
|
||||||
|
| Loop / Break / Continue | `www/missions.js` + `mission_queue.cpp` |
|
||||||
|
| Pause / Continue | `/api/mission_queue/pause`, `/continue` |
|
||||||
|
| Cancel (Modbus coil 3) | `/api/mission_queue/cancel` |
|
||||||
|
| Sign in / User groups | **Đã triển khai** — §2.1 (`AuthService`, UI MiR, `data/auth.json`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Ghi chú
|
||||||
|
|
||||||
|
- Rev. 1.9 (2019) — firmware mới có thể khác; đối chiếu bản API trên robot thực tế.
|
||||||
|
- Phần cứng MiR250: xem `docs/mir250_user_guide_11_en.pdf`.
|
||||||
|
- Fleet (scheduler, robot groups): tài liệu riêng *MiR Fleet Reference Guide*.
|
||||||
BIN
docs/mir250_user_guide_11_en.pdf
Normal file
BIN
docs/mir250_user_guide_11_en.pdf
Normal file
Binary file not shown.
43
scripts/README.md
Normal file
43
scripts/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Scripts RBS
|
||||||
|
|
||||||
|
CLI thống nhất: `./scripts/lm.sh <nhóm> <lệnh>`
|
||||||
|
|
||||||
|
## Cấu trúc
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/
|
||||||
|
├── lm.sh # entry point
|
||||||
|
├── lib/
|
||||||
|
│ ├── common.sh # LM_ROOT, wait_for_health, free_port
|
||||||
|
│ ├── docker.sh # docker_cmd, require_container
|
||||||
|
│ └── bench.sh # bench_http_suite
|
||||||
|
├── test/
|
||||||
|
│ ├── run.sh # GTest + smoke + pytest (server tạm :18080)
|
||||||
|
│ └── smoke.sh # API smoke (curl)
|
||||||
|
├── docker/
|
||||||
|
│ ├── up.sh down.sh stats.sh htop.sh shell.sh
|
||||||
|
│ ├── bench.sh # stats + HTTP latency trong container
|
||||||
|
│ └── test.sh # up + smoke + pytest trên :8080
|
||||||
|
└── bench/
|
||||||
|
└── http.sh # đo latency (local hoặc URL tùy chọn)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lệnh thường dùng
|
||||||
|
|
||||||
|
| Mục đích | Lệnh |
|
||||||
|
|----------|------|
|
||||||
|
| Test đầy đủ (local) | `./scripts/lm.sh test run` |
|
||||||
|
| API smoke | `./scripts/lm.sh test smoke [url]` |
|
||||||
|
| Docker up | `./scripts/lm.sh docker up` |
|
||||||
|
| Test trên container | `./scripts/lm.sh docker test` |
|
||||||
|
| Benchmark HTTP | `./scripts/lm.sh bench http [url]` |
|
||||||
|
|
||||||
|
## Biến môi trường
|
||||||
|
|
||||||
|
| Biến | Mặc định | Ý nghĩa |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `LM_URL` | `http://127.0.0.1:8080` | URL container |
|
||||||
|
| `LM_TEST_PORT` | `18080` | Port server tạm khi `test run` |
|
||||||
|
| `LM_CONTAINER` | `lidar-manager-limited` | Tên container |
|
||||||
|
| `TEST_BASE_URL` | — | Base URL cho pytest |
|
||||||
|
| `BENCH_REQUESTS` | `100` | Số request mỗi endpoint benchmark |
|
||||||
17
scripts/bench/http.sh
Executable file
17
scripts/bench/http.sh
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$(dirname "$0")/../lib/common.sh"
|
||||||
|
# shellcheck source=../lib/bench.sh
|
||||||
|
source "$(dirname "$0")/../lib/bench.sh"
|
||||||
|
|
||||||
|
BASE="${1:-$LM_URL}"
|
||||||
|
echo "=== HTTP benchmark ==="
|
||||||
|
echo "URL: $BASE"
|
||||||
|
echo
|
||||||
|
bench_http_suite "$BASE"
|
||||||
|
echo
|
||||||
|
echo "=== Process ==="
|
||||||
|
ps -C lidar_manager_web -o pid,rss,vsz,pcpu,pmem,etime,cmd 2>/dev/null \
|
||||||
|
|| pgrep -af '[./]lidar_manager_web' | grep -v pgrep || true
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
cd "$ROOT"
|
|
||||||
# shellcheck source=docker-lib.sh
|
|
||||||
source "$ROOT/scripts/docker-lib.sh"
|
|
||||||
|
|
||||||
docker_cmd
|
|
||||||
|
|
||||||
if ! "${DOCKER[@]}" ps --format '{{.Names}}' | grep -qx 'lidar-manager-limited'; then
|
|
||||||
echo "Container chưa chạy. Khởi động: sudo docker compose up -d"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Giới hạn container:"
|
|
||||||
print_container_limits lidar-manager-limited
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Mở htop trong container (q để thoát)..."
|
|
||||||
exec "${DOCKER[@]}" exec -it lidar-manager-limited htop
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
# shellcheck source=docker-lib.sh
|
|
||||||
source "$ROOT/scripts/docker-lib.sh"
|
|
||||||
|
|
||||||
docker_cmd
|
|
||||||
|
|
||||||
NAME="${1:-lidar-manager-limited}"
|
|
||||||
|
|
||||||
if ! "${DOCKER[@]}" ps --format '{{.Names}}' | grep -qx "$NAME"; then
|
|
||||||
echo "Container '$NAME' không chạy."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== docker stats (live) ==="
|
|
||||||
"${DOCKER[@]}" stats --no-stream "$NAME"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "=== limits ==="
|
|
||||||
print_container_limits "$NAME"
|
|
||||||
|
|
||||||
CID="$("${DOCKER[@]}" inspect -f '{{.Id}}' "$NAME")"
|
|
||||||
CG="/sys/fs/cgroup"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "=== cgroup (host) ==="
|
|
||||||
printf 'memory.usage = '
|
|
||||||
cat "$CG/memory/docker/$CID/memory.usage_in_bytes" 2>/dev/null \
|
|
||||||
|| cat "$CG/memory/system.slice/docker-$CID.scope/memory.usage_in_bytes" 2>/dev/null \
|
|
||||||
|| echo "n/a"
|
|
||||||
printf ' bytes\n'
|
|
||||||
|
|
||||||
printf 'cpu.cfs_quota / period = '
|
|
||||||
cat "$CG/cpu,cpuacct/docker/$CID/cpu.cfs_quota_us" 2>/dev/null \
|
|
||||||
|| echo -n "n/a"
|
|
||||||
printf ' / '
|
|
||||||
cat "$CG/cpu,cpuacct/docker/$CID/cpu.cfs_period_us" 2>/dev/null \
|
|
||||||
|| echo "n/a"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "=== processes in container ==="
|
|
||||||
"${DOCKER[@]}" top "$NAME"
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
cd "$ROOT"
|
|
||||||
# shellcheck source=docker-lib.sh
|
|
||||||
source "$ROOT/scripts/docker-lib.sh"
|
|
||||||
|
|
||||||
docker_cmd
|
|
||||||
|
|
||||||
if ! docker info >/dev/null 2>&1 && ! sudo -n docker info >/dev/null 2>&1; then
|
|
||||||
echo "Cần quyền Docker. Chạy một trong hai cách:"
|
|
||||||
echo " sudo usermod -aG docker \$USER # rồi đăng nhập lại"
|
|
||||||
echo " sudo $0"
|
|
||||||
fi
|
|
||||||
|
|
||||||
"${DOCKER[@]}" compose up --build -d
|
|
||||||
echo "Đã chạy: http://localhost:8080/"
|
|
||||||
echo -n "Giới hạn: "
|
|
||||||
print_container_limits lidar-manager-limited
|
|
||||||
31
scripts/docker/bench.sh
Executable file
31
scripts/docker/bench.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$(dirname "$0")/../lib/common.sh"
|
||||||
|
# shellcheck source=../lib/docker.sh
|
||||||
|
source "$(dirname "$0")/../lib/docker.sh"
|
||||||
|
# shellcheck source=../lib/bench.sh
|
||||||
|
source "$(dirname "$0")/../lib/bench.sh"
|
||||||
|
|
||||||
|
NAME="${1:-$LM_CONTAINER}"
|
||||||
|
BASE="${2:-$LM_URL}"
|
||||||
|
require_container "$NAME"
|
||||||
|
|
||||||
|
echo "=== Benchmark container: $NAME ==="
|
||||||
|
echo "URL: $BASE"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== docker stats (trước) ==="
|
||||||
|
"${DOCKER[@]}" stats --no-stream --format \
|
||||||
|
'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}' "$NAME"
|
||||||
|
print_container_limits "$NAME"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== HTTP latency ==="
|
||||||
|
bench_http_suite "$BASE"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== docker stats (sau) ==="
|
||||||
|
"${DOCKER[@]}" stats --no-stream --format \
|
||||||
|
'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}' "$NAME"
|
||||||
11
scripts/docker/down.sh
Executable file
11
scripts/docker/down.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$(dirname "$0")/../lib/common.sh"
|
||||||
|
# shellcheck source=../lib/docker.sh
|
||||||
|
source "$(dirname "$0")/../lib/docker.sh"
|
||||||
|
|
||||||
|
cd "$LM_ROOT"
|
||||||
|
docker_cmd
|
||||||
|
"${DOCKER[@]}" compose down
|
||||||
14
scripts/docker/htop.sh
Executable file
14
scripts/docker/htop.sh
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$(dirname "$0")/../lib/common.sh"
|
||||||
|
# shellcheck source=../lib/docker.sh
|
||||||
|
source "$(dirname "$0")/../lib/docker.sh"
|
||||||
|
|
||||||
|
require_container "$LM_CONTAINER"
|
||||||
|
echo "Giới hạn container:"
|
||||||
|
print_container_limits "$LM_CONTAINER"
|
||||||
|
echo
|
||||||
|
echo "htop (q để thoát)..."
|
||||||
|
exec "${DOCKER[@]}" exec -it "$LM_CONTAINER" htop
|
||||||
10
scripts/docker/shell.sh
Executable file
10
scripts/docker/shell.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$(dirname "$0")/../lib/common.sh"
|
||||||
|
# shellcheck source=../lib/docker.sh
|
||||||
|
source "$(dirname "$0")/../lib/docker.sh"
|
||||||
|
|
||||||
|
require_container "$LM_CONTAINER"
|
||||||
|
exec "${DOCKER[@]}" exec -it "$LM_CONTAINER" bash
|
||||||
29
scripts/docker/stats.sh
Executable file
29
scripts/docker/stats.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$(dirname "$0")/../lib/common.sh"
|
||||||
|
# shellcheck source=../lib/docker.sh
|
||||||
|
source "$(dirname "$0")/../lib/docker.sh"
|
||||||
|
|
||||||
|
NAME="${1:-$LM_CONTAINER}"
|
||||||
|
require_container "$NAME"
|
||||||
|
|
||||||
|
echo "=== docker stats ==="
|
||||||
|
"${DOCKER[@]}" stats --no-stream "$NAME"
|
||||||
|
echo
|
||||||
|
echo "=== limits ==="
|
||||||
|
print_container_limits "$NAME"
|
||||||
|
|
||||||
|
CID="$("${DOCKER[@]}" inspect -f '{{.Id}}' "$NAME")"
|
||||||
|
CG="/sys/fs/cgroup"
|
||||||
|
echo
|
||||||
|
echo "=== cgroup (host) ==="
|
||||||
|
printf 'memory.usage = '
|
||||||
|
cat "$CG/memory/docker/$CID/memory.usage_in_bytes" 2>/dev/null \
|
||||||
|
|| cat "$CG/memory/system.slice/docker-$CID.scope/memory.usage_in_bytes" 2>/dev/null \
|
||||||
|
|| echo "n/a"
|
||||||
|
printf ' bytes\n'
|
||||||
|
echo
|
||||||
|
echo "=== processes ==="
|
||||||
|
"${DOCKER[@]}" top "$NAME"
|
||||||
26
scripts/docker/test.sh
Executable file
26
scripts/docker/test.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$(dirname "$0")/../lib/common.sh"
|
||||||
|
|
||||||
|
BASE="${TEST_BASE_URL:-$LM_URL}"
|
||||||
|
export LM_URL="$BASE"
|
||||||
|
|
||||||
|
echo "==> Docker up"
|
||||||
|
"$(dirname "$0")/up.sh"
|
||||||
|
|
||||||
|
echo "==> Đợi server ($BASE)"
|
||||||
|
if ! wait_for_health "$BASE"; then
|
||||||
|
echo "Container không phản hồi tại $BASE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> API smoke"
|
||||||
|
"$LM_SCRIPTS/test/smoke.sh" "$BASE"
|
||||||
|
|
||||||
|
echo "==> pytest"
|
||||||
|
TEST_BASE_URL="$BASE" python3 -m pytest "$LM_ROOT/tests/test_api_integration.py" -q
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Docker tests OK."
|
||||||
19
scripts/docker/up.sh
Executable file
19
scripts/docker/up.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$(dirname "$0")/../lib/common.sh"
|
||||||
|
# shellcheck source=../lib/docker.sh
|
||||||
|
source "$(dirname "$0")/../lib/docker.sh"
|
||||||
|
|
||||||
|
cd "$LM_ROOT"
|
||||||
|
docker_cmd
|
||||||
|
|
||||||
|
if ! docker info >/dev/null 2>&1 && ! sudo -n docker info >/dev/null 2>&1; then
|
||||||
|
echo "Cần quyền Docker. Thử: sudo $0 hoặc usermod -aG docker \$USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
"${DOCKER[@]}" compose up --build -d
|
||||||
|
echo "Đã chạy: http://localhost:8080/"
|
||||||
|
echo -n "Giới hạn: "
|
||||||
|
print_container_limits "$LM_CONTAINER"
|
||||||
71
scripts/lib/bench.sh
Executable file
71
scripts/lib/bench.sh
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
|
||||||
|
bench_http_endpoint() {
|
||||||
|
local label="$1" method="$2" path="$3" base="$4"
|
||||||
|
local body="${5:-}" requests="${6:-${BENCH_REQUESTS:-100}}" warmup="${7:-${BENCH_WARMUP:-10}}"
|
||||||
|
python3 - "$label" "$method" "$base$path" "$requests" "$warmup" "$body" <<'PY'
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
label, method, url, n_req, n_warm, body = sys.argv[1:7]
|
||||||
|
n_req = int(n_req)
|
||||||
|
n_warm = int(n_warm)
|
||||||
|
data = body.encode() if body else None
|
||||||
|
headers = {"Content-Type": "application/json"} if data else {}
|
||||||
|
|
||||||
|
def once():
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
t0 = __import__("time").perf_counter()
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
resp.read()
|
||||||
|
return (__import__("time").perf_counter() - t0) * 1000.0
|
||||||
|
|
||||||
|
for _ in range(n_warm):
|
||||||
|
try:
|
||||||
|
once()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
samples = []
|
||||||
|
errors = 0
|
||||||
|
for _ in range(n_req):
|
||||||
|
try:
|
||||||
|
samples.append(once())
|
||||||
|
except Exception:
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
if not samples:
|
||||||
|
print(f"{label}: FAIL errors={errors}")
|
||||||
|
else:
|
||||||
|
samples.sort()
|
||||||
|
def pct(p):
|
||||||
|
i = max(0, min(len(samples) - 1, int(len(samples) * p / 100.0) - 1))
|
||||||
|
return samples[i]
|
||||||
|
print(
|
||||||
|
f"{label}: ok={len(samples)} err={errors} "
|
||||||
|
f"p50={pct(50):.2f}ms p95={pct(95):.2f}ms avg={statistics.mean(samples):.2f}ms max={samples[-1]:.2f}ms"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
bench_http_suite() {
|
||||||
|
local base="${1:-$LM_URL}"
|
||||||
|
if ! curl -sf "${base}/api/health" >/dev/null; then
|
||||||
|
echo "Server không phản hồi tại $base" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
bench_http_endpoint "GET /api/health" GET "/api/health" "$base"
|
||||||
|
bench_http_endpoint "GET /api/state" GET "/api/state" "$base"
|
||||||
|
bench_http_endpoint "GET /api/missions" GET "/api/missions" "$base"
|
||||||
|
bench_http_endpoint "GET /api/mission_queue" GET "/api/mission_queue" "$base"
|
||||||
|
bench_http_endpoint "GET /api/v2.0.0/mission_queue" GET "/api/v2.0.0/mission_queue" "$base"
|
||||||
|
bench_http_endpoint "GET /" GET "/" "$base"
|
||||||
|
bench_http_endpoint "GET /missions.js" GET "/missions.js" "$base"
|
||||||
|
local mid
|
||||||
|
mid="$(curl -sf "${base}/api/missions" | python3 -c "import json,sys; m=json.load(sys.stdin).get('missions',[]); print(m[0]['id'] if m else '')" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$mid" ]]; then
|
||||||
|
bench_http_endpoint "POST /api/v2.0.0/mission_queue" POST "/api/v2.0.0/mission_queue" "$base" \
|
||||||
|
"{\"mission_id\":\"${mid}\",\"priority\":0}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
33
scripts/lib/common.sh
Executable file
33
scripts/lib/common.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
# Shared paths and helpers for RBS scripts.
|
||||||
|
# shellcheck shell=bash
|
||||||
|
|
||||||
|
_lm_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
LM_ROOT="$(cd "$_lm_lib_dir/../.." && pwd)"
|
||||||
|
LM_SCRIPTS="$(cd "$_lm_lib_dir/.." && pwd)"
|
||||||
|
LM_CONTAINER="${LM_CONTAINER:-lidar-manager-limited}"
|
||||||
|
LM_URL="${LM_URL:-http://127.0.0.1:8080}"
|
||||||
|
LM_TEST_PORT="${LM_TEST_PORT:-18080}"
|
||||||
|
|
||||||
|
wait_for_health() {
|
||||||
|
local base="${1:-$LM_URL}"
|
||||||
|
local tries="${2:-40}"
|
||||||
|
local i
|
||||||
|
for ((i = 1; i <= tries; i++)); do
|
||||||
|
if curl -sf "${base}/api/health" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
free_port() {
|
||||||
|
local port="$1"
|
||||||
|
if command -v fuser >/dev/null 2>&1; then
|
||||||
|
fuser -k "${port}/tcp" 2>/dev/null || true
|
||||||
|
elif command -v lsof >/dev/null 2>&1; then
|
||||||
|
local pids
|
||||||
|
pids="$(lsof -ti "tcp:${port}" 2>/dev/null || true)"
|
||||||
|
[[ -n "$pids" ]] && kill $pids 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
15
scripts/docker-lib.sh → scripts/lib/docker.sh
Normal file → Executable file
15
scripts/docker-lib.sh → scripts/lib/docker.sh
Normal file → Executable file
@@ -1,4 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
# shellcheck shell=bash
|
||||||
|
# shellcheck source=common.sh
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
||||||
|
|
||||||
docker_cmd() {
|
docker_cmd() {
|
||||||
if docker info >/dev/null 2>&1; then
|
if docker info >/dev/null 2>&1; then
|
||||||
@@ -10,8 +12,17 @@ docker_cmd() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require_container() {
|
||||||
|
local name="${1:-$LM_CONTAINER}"
|
||||||
|
docker_cmd
|
||||||
|
if ! "${DOCKER[@]}" ps --format '{{.Names}}' | grep -qx "$name"; then
|
||||||
|
echo "Container '$name' không chạy. Thử: ./scripts/lm.sh docker up" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
print_container_limits() {
|
print_container_limits() {
|
||||||
local name="${1:-lidar-manager-limited}"
|
local name="${1:-$LM_CONTAINER}"
|
||||||
local nano mem cpus ram_mb
|
local nano mem cpus ram_mb
|
||||||
nano="$("${DOCKER[@]}" inspect -f '{{.HostConfig.NanoCpus}}' "$name")"
|
nano="$("${DOCKER[@]}" inspect -f '{{.HostConfig.NanoCpus}}' "$name")"
|
||||||
mem="$("${DOCKER[@]}" inspect -f '{{.HostConfig.Memory}}' "$name")"
|
mem="$("${DOCKER[@]}" inspect -f '{{.HostConfig.Memory}}' "$name")"
|
||||||
74
scripts/lm.sh
Executable file
74
scripts/lm.sh
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PhenikaaX RBS — CLI gom script theo nhóm.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: ./scripts/lm.sh <nhóm> <lệnh> [args...]
|
||||||
|
|
||||||
|
Test (local build + server tạm):
|
||||||
|
test run GTest + smoke + pytest (port \$LM_TEST_PORT, mặc định 18080)
|
||||||
|
test smoke [url] Chỉ API smoke (mặc định http://127.0.0.1:18080)
|
||||||
|
|
||||||
|
Docker (container :8080):
|
||||||
|
docker up compose up --build -d
|
||||||
|
docker down compose down
|
||||||
|
docker stats [name] CPU/RAM/cgroup
|
||||||
|
docker htop htop trong container
|
||||||
|
docker shell bash trong container
|
||||||
|
docker bench [url] Stats + đo latency HTTP
|
||||||
|
docker test up + smoke + pytest trên container
|
||||||
|
|
||||||
|
Benchmark:
|
||||||
|
bench http [url] Đo latency (mặc định \$LM_URL)
|
||||||
|
|
||||||
|
Biến môi trường: LM_URL, LM_TEST_PORT, LM_CONTAINER, TEST_BASE_URL, BENCH_REQUESTS
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd="${1:-help}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
case "$cmd" in
|
||||||
|
help|-h|--help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
test)
|
||||||
|
sub="${1:-run}"
|
||||||
|
shift || true
|
||||||
|
case "$sub" in
|
||||||
|
run) exec "$ROOT/test/run.sh" "$@" ;;
|
||||||
|
smoke) exec "$ROOT/test/smoke.sh" "$@" ;;
|
||||||
|
*) echo "Unknown: test $sub" >&2; usage >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
docker)
|
||||||
|
sub="${1:-}"
|
||||||
|
shift || true
|
||||||
|
case "$sub" in
|
||||||
|
up) exec "$ROOT/docker/up.sh" "$@" ;;
|
||||||
|
down) exec "$ROOT/docker/down.sh" "$@" ;;
|
||||||
|
stats) exec "$ROOT/docker/stats.sh" "$@" ;;
|
||||||
|
htop) exec "$ROOT/docker/htop.sh" "$@" ;;
|
||||||
|
shell) exec "$ROOT/docker/shell.sh" "$@" ;;
|
||||||
|
bench) exec "$ROOT/docker/bench.sh" "$@" ;;
|
||||||
|
test) exec "$ROOT/docker/test.sh" "$@" ;;
|
||||||
|
*) echo "Unknown: docker $sub" >&2; usage >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
bench)
|
||||||
|
sub="${1:-http}"
|
||||||
|
shift || true
|
||||||
|
case "$sub" in
|
||||||
|
http) exec "$ROOT/bench/http.sh" "$@" ;;
|
||||||
|
*) echo "Unknown: bench $sub" >&2; usage >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown group: $cmd" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
66
scripts/test/run.sh
Executable file
66
scripts/test/run.sh
Executable file
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$(dirname "$0")/../lib/common.sh"
|
||||||
|
|
||||||
|
cd "$LM_ROOT"
|
||||||
|
PORT="${TEST_PORT:-$LM_TEST_PORT}"
|
||||||
|
BASE="http://127.0.0.1:${PORT}"
|
||||||
|
BIN="${LM_ROOT}/build/lidar_manager_web"
|
||||||
|
DATA_DIR="$(mktemp -d)"
|
||||||
|
SERVER_PID=""
|
||||||
|
|
||||||
|
free_port "$PORT"
|
||||||
|
sleep 0.2
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||||
|
kill "$SERVER_PID" 2>/dev/null || true
|
||||||
|
wait "$SERVER_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -rf "$DATA_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "==> Configure & build (BUILD_TESTING=ON)"
|
||||||
|
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
|
||||||
|
cmake --build build -j
|
||||||
|
|
||||||
|
echo "==> C++ unit tests (GTest)"
|
||||||
|
./build/lidar_manager_tests
|
||||||
|
|
||||||
|
echo "==> Prepare isolated data directory"
|
||||||
|
cp -a tests/fixtures/data/. "$DATA_DIR/"
|
||||||
|
mkdir -p "$DATA_DIR/models"
|
||||||
|
|
||||||
|
echo "==> Start server on port $PORT"
|
||||||
|
"$BIN" "$PORT" "$LM_ROOT/www" "$DATA_DIR/state.json" >"$DATA_DIR/server.log" 2>&1 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
if ! wait_for_health "$BASE" 30; then
|
||||||
|
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||||
|
echo "Server exited early:" >&2
|
||||||
|
cat "$DATA_DIR/server.log" >&2 || true
|
||||||
|
else
|
||||||
|
echo "Server did not become ready on $BASE" >&2
|
||||||
|
cat "$DATA_DIR/server.log" >&2 || true
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> API smoke tests"
|
||||||
|
"$LM_SCRIPTS/test/smoke.sh" "$BASE"
|
||||||
|
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
echo "==> Python integration tests (pytest)"
|
||||||
|
if ! python3 -c "import pytest" 2>/dev/null; then
|
||||||
|
python3 -m pip install --user -q -r tests/requirements.txt
|
||||||
|
fi
|
||||||
|
TEST_BASE_URL="$BASE" python3 -m pytest tests/test_api_integration.py -q
|
||||||
|
else
|
||||||
|
echo "==> Skipping pytest (python3 not found)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "All tests passed."
|
||||||
302
scripts/test/smoke.sh
Executable file
302
scripts/test/smoke.sh
Executable file
@@ -0,0 +1,302 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# API smoke tests — chạy trên server đang lắng nghe.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$(dirname "$0")/../lib/common.sh"
|
||||||
|
|
||||||
|
BASE="${1:-http://127.0.0.1:${LM_TEST_PORT}}"
|
||||||
|
MISSION_ID="${TEST_MISSION_ID:-}"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
NC='\033[0m'
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
log_pass() { echo -e "${GREEN}PASS${NC} $*"; PASS=$((PASS + 1)); }
|
||||||
|
log_fail() { echo -e "${RED}FAIL${NC} $*"; FAIL=$((FAIL + 1)); }
|
||||||
|
|
||||||
|
json_field() {
|
||||||
|
python3 - "$1" "$2" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
doc = json.loads(open(sys.argv[1]).read())
|
||||||
|
path = sys.argv[2].split(".")
|
||||||
|
cur = doc
|
||||||
|
for p in path:
|
||||||
|
cur = cur[p]
|
||||||
|
print(cur)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
http_code() {
|
||||||
|
curl -s -o "$1" -w '%{http_code}' "${CURL_OPTS[@]}" "${@:2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_code() {
|
||||||
|
local name="$1" expect="$2" file="$3"
|
||||||
|
shift 3
|
||||||
|
local code
|
||||||
|
code="$(http_code "$file" "$@")"
|
||||||
|
if [[ "$code" == "$expect" ]]; then
|
||||||
|
log_pass "$name (HTTP $code)"
|
||||||
|
else
|
||||||
|
log_fail "$name — expected HTTP $expect, got $code"
|
||||||
|
[[ -f "$file" ]] && head -c 400 "$file" >&2 || true
|
||||||
|
echo >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_json_true() {
|
||||||
|
local name="$1" file="$2" expr="$3"
|
||||||
|
if python3 - "$file" "$expr" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
doc = json.loads(open(sys.argv[1]).read())
|
||||||
|
env = {"doc": doc, "any": any, "all": all, "len": len, "list": list, "isinstance": isinstance}
|
||||||
|
ok = eval(sys.argv[2], {"__builtins__": {}}, env)
|
||||||
|
sys.exit(0 if ok else 1)
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
log_pass "$name"
|
||||||
|
else
|
||||||
|
log_fail "$name"
|
||||||
|
head -c 400 "$file" >&2 || true
|
||||||
|
echo >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
COOKIE_JAR="$TMP/cookies.txt"
|
||||||
|
CURL_OPTS=()
|
||||||
|
|
||||||
|
echo "API smoke tests → $BASE"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# --- Health & static (no auth) ---
|
||||||
|
assert_code "GET /api/health" 200 "$TMP/health.json" -X GET "$BASE/api/health"
|
||||||
|
assert_json_true "health ok" "$TMP/health.json" 'doc.get("ok") is True'
|
||||||
|
|
||||||
|
assert_code "GET /api/state without auth" 401 "$TMP/state_unauth.json" -X GET "$BASE/api/state"
|
||||||
|
|
||||||
|
assert_code "POST /api/auth/login bad password" 401 "$TMP/login_bad.json" \
|
||||||
|
-X POST "$BASE/api/auth/login" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"Admin","password":"wrong"}'
|
||||||
|
|
||||||
|
assert_code "POST /api/auth/login" 200 "$TMP/login.json" \
|
||||||
|
-X POST "$BASE/api/auth/login" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-c "$COOKIE_JAR" \
|
||||||
|
-d '{"username":"Admin","password":"admin"}'
|
||||||
|
assert_json_true "login user" "$TMP/login.json" 'doc.get("user",{}).get("username") == "Admin"'
|
||||||
|
|
||||||
|
CURL_OPTS=(-b "$COOKIE_JAR" -c "$COOKIE_JAR")
|
||||||
|
|
||||||
|
assert_code "GET /api/auth/me" 200 "$TMP/me.json" -X GET "$BASE/api/auth/me"
|
||||||
|
assert_json_true "auth me" "$TMP/me.json" 'doc.get("user",{}).get("group_name") == "Administrators"'
|
||||||
|
|
||||||
|
assert_code "GET /" 200 "$TMP/index.html" -X GET "$BASE/"
|
||||||
|
assert_code "GET /auth.js" 200 "$TMP/auth.js" -X GET "$BASE/auth.js"
|
||||||
|
assert_code "GET /missions.js" 200 "$TMP/missions.js" -X GET "$BASE/missions.js"
|
||||||
|
assert_code "GET /topbar.js" 200 "$TMP/topbar.js" -X GET "$BASE/topbar.js"
|
||||||
|
|
||||||
|
assert_code "GET /api/robot/status" 200 "$TMP/robot_status.json" -X GET "$BASE/api/robot/status"
|
||||||
|
assert_json_true "robot status motion" "$TMP/robot_status.json" 'doc.get("motion") in ("paused", "running")'
|
||||||
|
assert_json_true "robot status battery" "$TMP/robot_status.json" 'doc.get("battery_percent", 0) >= 0'
|
||||||
|
|
||||||
|
assert_code "POST /api/robot/start" 200 "$TMP/robot_start.json" \
|
||||||
|
-X POST "$BASE/api/robot/start" -H 'Content-Type: application/json' -d '{}'
|
||||||
|
assert_json_true "robot started" "$TMP/robot_start.json" 'doc.get("motion") == "running"'
|
||||||
|
|
||||||
|
assert_code "POST /api/robot/pause" 200 "$TMP/robot_pause.json" \
|
||||||
|
-X POST "$BASE/api/robot/pause" -H 'Content-Type: application/json' -d '{}'
|
||||||
|
assert_json_true "robot paused" "$TMP/robot_pause.json" 'doc.get("motion") == "paused"'
|
||||||
|
|
||||||
|
assert_code "POST /api/robot/errors/reset" 200 "$TMP/robot_reset.json" \
|
||||||
|
-X POST "$BASE/api/robot/errors/reset" -H 'Content-Type: application/json' -d '{}'
|
||||||
|
assert_json_true "robot health ok" "$TMP/robot_reset.json" 'doc.get("health") == "ok"'
|
||||||
|
|
||||||
|
assert_code "PUT /api/auth/profile" 200 "$TMP/profile.json" \
|
||||||
|
-X PUT "$BASE/api/auth/profile" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"display_name":"Admin Test"}'
|
||||||
|
assert_json_true "profile display_name" "$TMP/profile.json" 'doc.get("user",{}).get("display_name") == "Admin Test"'
|
||||||
|
|
||||||
|
assert_code "GET /api/state" 200 "$TMP/state.json" -X GET "$BASE/api/state"
|
||||||
|
|
||||||
|
assert_code "GET /api/missions" 200 "$TMP/missions.json" -X GET "$BASE/api/missions"
|
||||||
|
|
||||||
|
if [[ -z "$MISSION_ID" ]]; then
|
||||||
|
MISSION_ID="$(python3 -c "
|
||||||
|
import json
|
||||||
|
doc = json.load(open('$TMP/missions.json'))
|
||||||
|
missions = doc.get('missions', [])
|
||||||
|
preferred = 'testmission00001'
|
||||||
|
ids = [m.get('id') for m in missions if isinstance(m, dict)]
|
||||||
|
if preferred in ids:
|
||||||
|
print(preferred)
|
||||||
|
elif ids:
|
||||||
|
print(ids[0])
|
||||||
|
")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$MISSION_ID" ]]; then
|
||||||
|
log_fail "no mission available for queue tests"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Mission id: $MISSION_ID"
|
||||||
|
echo
|
||||||
|
|
||||||
|
assert_json_true "missions available" "$TMP/missions.json" 'len(doc.get("missions", [])) >= 1'
|
||||||
|
|
||||||
|
# --- Queue pause/continue (chạy sớm, trước các test enqueue khác) ---
|
||||||
|
curl -s "${CURL_OPTS[@]}" -X DELETE "$BASE/api/mission_queue" -o /dev/null || true
|
||||||
|
curl -s "${CURL_OPTS[@]}" -X POST "$BASE/api/mission_queue" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"mission_id\":\"$MISSION_ID\"}" -o "$TMP/qpost.json"
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
curl -s "${CURL_OPTS[@]}" "$BASE/api/mission_queue" -o "$TMP/runner_poll.json"
|
||||||
|
RUNNER_STATE="$(python3 -c "import json; print(json.load(open('$TMP/runner_poll.json')).get('runner',{}).get('state',''))")"
|
||||||
|
if [[ "$RUNNER_STATE" == "running" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
if [[ "$RUNNER_STATE" == "running" ]]; then
|
||||||
|
assert_code "POST /api/mission_queue/pause" 200 "$TMP/pause.json" \
|
||||||
|
-X POST "$BASE/api/mission_queue/pause"
|
||||||
|
assert_json_true "runner paused" "$TMP/pause.json" 'doc.get("state") == "paused"'
|
||||||
|
assert_code "POST /api/mission_queue/continue" 200 "$TMP/cont.json" \
|
||||||
|
-X POST "$BASE/api/mission_queue/continue"
|
||||||
|
assert_json_true "runner not paused" "$TMP/cont.json" 'doc.get("state") != "paused"'
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
curl -s "${CURL_OPTS[@]}" "$BASE/api/mission_queue" -o "$TMP/runner_poll.json"
|
||||||
|
RUNNER_STATE="$(python3 -c "import json; print(json.load(open('$TMP/runner_poll.json')).get('runner',{}).get('state',''))")"
|
||||||
|
if [[ "$RUNNER_STATE" == "running" || "$RUNNER_STATE" == "paused" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
if [[ "$RUNNER_STATE" == "running" || "$RUNNER_STATE" == "paused" ]]; then
|
||||||
|
assert_code "POST /api/mission_queue/cancel" 200 "$TMP/cancel.json" \
|
||||||
|
-X POST "$BASE/api/mission_queue/cancel"
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
curl -s "${CURL_OPTS[@]}" "$BASE/api/mission_queue" -o "$TMP/runner_poll.json"
|
||||||
|
RUNNER_STATE="$(python3 -c "import json; print(json.load(open('$TMP/runner_poll.json')).get('runner',{}).get('state',''))")"
|
||||||
|
if [[ "$RUNNER_STATE" == "idle" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.15
|
||||||
|
done
|
||||||
|
assert_json_true "runner idle after cancel" "$TMP/runner_poll.json" \
|
||||||
|
'doc.get("runner",{}).get("state") == "idle"'
|
||||||
|
else
|
||||||
|
log_fail "runner not active for cancel test (skipped)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_fail "runner never reached running (pause test skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
assert_code "POST /api/mission_queue/cancel idle" 400 "$TMP/cancel_idle.json" \
|
||||||
|
-X POST "$BASE/api/mission_queue/cancel"
|
||||||
|
|
||||||
|
# --- LiDAR CRUD ---
|
||||||
|
assert_code "POST /api/lidars" 201 "$TMP/lidar.json" \
|
||||||
|
-X POST "$BASE/api/lidars" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"smoke-lidar","ip":"192.168.99.1","port":2112}'
|
||||||
|
LIDAR_ID="$(json_field "$TMP/lidar.json" id 2>/dev/null || echo "")"
|
||||||
|
if [[ -n "$LIDAR_ID" ]]; then
|
||||||
|
log_pass "lidar created id=$LIDAR_ID"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
assert_code "PUT /api/lidars" 200 "$TMP/lidar_put.json" \
|
||||||
|
-X PUT "$BASE/api/lidars/$LIDAR_ID" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"smoke-lidar","ip":"192.168.99.2","port":2112}'
|
||||||
|
assert_code "DELETE /api/lidars" 204 "$TMP/lidar_del.txt" \
|
||||||
|
-X DELETE "$BASE/api/lidars/$LIDAR_ID"
|
||||||
|
else
|
||||||
|
log_fail "lidar create — no id in response"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- IMU CRUD ---
|
||||||
|
assert_code "POST /api/imus" 201 "$TMP/imu.json" \
|
||||||
|
-X POST "$BASE/api/imus" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"smoke-imu","frame_id":"imu_smoke","topic":"/imu/smoke","source":"external"}'
|
||||||
|
IMU_ID="$(json_field "$TMP/imu.json" id 2>/dev/null || echo "")"
|
||||||
|
if [[ -n "$IMU_ID" ]]; then
|
||||||
|
log_pass "imu created id=$IMU_ID"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
assert_code "DELETE /api/imus" 204 "$TMP/imu_del.txt" -X DELETE "$BASE/api/imus/$IMU_ID"
|
||||||
|
else
|
||||||
|
log_fail "imu create — no id in response"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Clear queue ---
|
||||||
|
curl -s "${CURL_OPTS[@]}" -X DELETE "$BASE/api/mission_queue" -o /dev/null || true
|
||||||
|
curl -s "${CURL_OPTS[@]}" -X DELETE "$BASE/api/v2.0.0/mission_queue" -o /dev/null || true
|
||||||
|
|
||||||
|
# --- MiR v2 enqueue (Cách C REST) ---
|
||||||
|
assert_code "POST /api/v2.0.0/mission_queue" 201 "$TMP/v2q.json" \
|
||||||
|
-X POST "$BASE/api/v2.0.0/mission_queue" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"mission_id\":\"$MISSION_ID\",\"priority\":2,\"robot_id\":\"default\"}"
|
||||||
|
assert_json_true "v2 queue entry mission_id" "$TMP/v2q.json" \
|
||||||
|
'doc.get("mission_id") == "'"$MISSION_ID"'"'
|
||||||
|
|
||||||
|
assert_code "GET /api/v2.0.0/mission_queue" 200 "$TMP/v2list.json" -X GET "$BASE/api/v2.0.0/mission_queue"
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
curl -s "${CURL_OPTS[@]}" "$BASE/api/v2.0.0/mission_queue" -o "$TMP/v2list.json"
|
||||||
|
if python3 -c "import json; d=json.load(open('$TMP/v2list.json')); exit(0 if isinstance(d,list) and len(d)>=1 else 1)" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
assert_json_true "v2 queue non-empty" "$TMP/v2list.json" 'isinstance(doc, list) and len(doc) >= 1'
|
||||||
|
|
||||||
|
assert_code "GET /api/v2.0.0/status" 200 "$TMP/v2status.json" -X GET "$BASE/api/v2.0.0/status"
|
||||||
|
assert_json_true "v2 status has state_text" "$TMP/v2status.json" '"state_text" in doc'
|
||||||
|
|
||||||
|
# --- Modbus trigger (Cách C) ---
|
||||||
|
assert_code "POST /api/triggers" 201 "$TMP/trig.json" \
|
||||||
|
-X POST "$BASE/api/triggers" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"name\":\"smoke-trigger\",\"coil_id\":1001,\"mission_id\":\"$MISSION_ID\"}"
|
||||||
|
TRIG_ID="$(json_field "$TMP/trig.json" id 2>/dev/null || echo "")"
|
||||||
|
if [[ -n "$TRIG_ID" ]]; then
|
||||||
|
log_pass "trigger created id=$TRIG_ID"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
assert_code "POST modbus fire coil" 200 "$TMP/fire.json" \
|
||||||
|
-X POST "$BASE/api/modbus/coils/1001/trigger"
|
||||||
|
assert_code "DELETE /api/triggers" 204 "$TMP/trig_del.txt" \
|
||||||
|
-X DELETE "$BASE/api/triggers/$TRIG_ID"
|
||||||
|
else
|
||||||
|
log_fail "trigger create — no id"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Fleet schedule ---
|
||||||
|
assert_code "POST /api/fleet/schedules" 201 "$TMP/sched.json" \
|
||||||
|
-X POST "$BASE/api/fleet/schedules" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"name\":\"smoke-schedule\",\"mission_id\":\"$MISSION_ID\",\"start_mode\":\"asap\",\"priority\":1}"
|
||||||
|
SCHED_ID="$(json_field "$TMP/sched.json" id 2>/dev/null || echo "")"
|
||||||
|
if [[ -n "$SCHED_ID" ]]; then
|
||||||
|
log_pass "schedule created id=$SCHED_ID"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
assert_code "DELETE /api/fleet/schedules" 204 "$TMP/sched_del.txt" \
|
||||||
|
-X DELETE "$BASE/api/fleet/schedules/$SCHED_ID"
|
||||||
|
else
|
||||||
|
log_fail "schedule create — no id"
|
||||||
|
fi
|
||||||
|
|
||||||
|
assert_code "GET /api/fleet/robots" 200 "$TMP/robots.json" -X GET "$BASE/api/fleet/robots"
|
||||||
|
assert_json_true "robots list" "$TMP/robots.json" 'isinstance(doc, list) and len(doc) >= 1'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
if [[ "$FAIL" -gt 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
#include "app/lidar_manager_app.hpp"
|
#include "app/lidar_manager_app.hpp"
|
||||||
|
|
||||||
|
#include "auth/auth_service.hpp"
|
||||||
#include "mission/mission_enqueue.hpp"
|
#include "mission/mission_enqueue.hpp"
|
||||||
#include "mission/mission_queue.hpp"
|
#include "mission/mission_queue.hpp"
|
||||||
#include "mission/mission_scheduler.hpp"
|
#include "mission/mission_scheduler.hpp"
|
||||||
#include "mission/mission_store.hpp"
|
#include "mission/mission_store.hpp"
|
||||||
#include "mission/modbus_trigger_service.hpp"
|
#include "mission/modbus_trigger_service.hpp"
|
||||||
|
#include "robot/robot_runtime.hpp"
|
||||||
#include "server/api_server.hpp"
|
#include "server/api_server.hpp"
|
||||||
#include "server/static_file_server.hpp"
|
#include "server/static_file_server.hpp"
|
||||||
|
#include "storage/dashboard_store.hpp"
|
||||||
|
#include "storage/database.hpp"
|
||||||
|
#include "storage/map_store.hpp"
|
||||||
|
#include "storage/site_store.hpp"
|
||||||
|
#include "storage/sound_store.hpp"
|
||||||
#include "storage/state_repository.hpp"
|
#include "storage/state_repository.hpp"
|
||||||
|
|
||||||
#include <httplib.h>
|
#include <httplib.h>
|
||||||
@@ -14,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)
|
||||||
@@ -23,13 +48,27 @@ LidarManagerApp::LidarManagerApp(int port,
|
|||||||
|
|
||||||
int LidarManagerApp::run()
|
int LidarManagerApp::run()
|
||||||
{
|
{
|
||||||
StateRepository repo(data_path_);
|
data_path_ = resolveDataPath(data_path_);
|
||||||
|
const std::filesystem::path data_dir = data_path_.parent_path();
|
||||||
|
Database database(data_dir);
|
||||||
|
std::string db_err;
|
||||||
|
if (!database.init(db_err))
|
||||||
|
{
|
||||||
|
std::fprintf(stderr, "database init failed: %s\n", db_err.c_str());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
StateRepository repo(data_path_, database);
|
||||||
repo.load();
|
repo.load();
|
||||||
|
|
||||||
const std::filesystem::path mission_queue_path = data_path_.parent_path() / "mission_queue.json";
|
MissionQueue mission_queue(database);
|
||||||
const std::filesystem::path missions_store_path = data_path_.parent_path() / "missions.json";
|
MissionStore mission_store(database);
|
||||||
MissionQueue mission_queue(mission_queue_path);
|
RobotRuntime robot_runtime(database, mission_queue);
|
||||||
MissionStore mission_store(missions_store_path);
|
MapStore map_store(database);
|
||||||
|
SiteStore site_store(database);
|
||||||
|
site_store.ensureDefaultSiteId();
|
||||||
|
SoundStore sound_store(database);
|
||||||
|
DashboardStore dashboard_store(database);
|
||||||
|
|
||||||
const auto enqueue_fn = [&mission_store, &mission_queue](const nlohmann::json& request, std::string& err) -> bool {
|
const auto enqueue_fn = [&mission_store, &mission_queue](const nlohmann::json& request, std::string& err) -> bool {
|
||||||
nlohmann::json payload;
|
nlohmann::json payload;
|
||||||
@@ -41,17 +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(database);
|
||||||
|
|
||||||
httplib::Server svr;
|
httplib::Server svr;
|
||||||
ApiServer api(repo, mission_queue, mission_store, modbus, scheduler);
|
svr.set_pre_routing_handler([&auth](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
return auth.preRoute(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
ApiServer api(repo,
|
||||||
|
mission_queue,
|
||||||
|
mission_store,
|
||||||
|
modbus,
|
||||||
|
scheduler,
|
||||||
|
robot_runtime,
|
||||||
|
map_store,
|
||||||
|
site_store,
|
||||||
|
sound_store,
|
||||||
|
dashboard_store);
|
||||||
api.registerRoutes(svr);
|
api.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");
|
||||||
|
|
||||||
|
|||||||
895
src/auth/auth_service.cpp
Normal file
895
src/auth/auth_service.cpp
Normal file
@@ -0,0 +1,895 @@
|
|||||||
|
#include "auth/auth_service.hpp"
|
||||||
|
|
||||||
|
#include "storage/database.hpp"
|
||||||
|
#include "util/crypto_util.hpp"
|
||||||
|
#include "util/http_util.hpp"
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <ctime>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
thread_local const AuthSession* AuthService::tls_session_ = nullptr;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr const char* kSessionCookie = "lm_session";
|
||||||
|
|
||||||
|
nlohmann::json defaultPermissionsAllWrite()
|
||||||
|
{
|
||||||
|
return {{"dashboard", "write"},
|
||||||
|
{"config", "write"},
|
||||||
|
{"maps", "write"},
|
||||||
|
{"missions", "write"},
|
||||||
|
{"integrations", "write"},
|
||||||
|
{"users", "write"}};
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json defaultPermissionsAdministrator()
|
||||||
|
{
|
||||||
|
return {{"dashboard", "write"},
|
||||||
|
{"config", "none"},
|
||||||
|
{"maps", "write"},
|
||||||
|
{"missions", "write"},
|
||||||
|
{"integrations", "write"},
|
||||||
|
{"users", "write"}};
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json defaultPermissionsUserGroup()
|
||||||
|
{
|
||||||
|
return {{"dashboard", "write"},
|
||||||
|
{"config", "none"},
|
||||||
|
{"maps", "none"},
|
||||||
|
{"missions", "read"},
|
||||||
|
{"integrations", "read"},
|
||||||
|
{"users", "none"}};
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json makeUser(const std::string& id,
|
||||||
|
const std::string& username,
|
||||||
|
const std::string& password,
|
||||||
|
const std::string& group_id,
|
||||||
|
const std::string& display_name)
|
||||||
|
{
|
||||||
|
const std::string salt = CryptoUtil::randomToken(16);
|
||||||
|
return {{"id", id},
|
||||||
|
{"username", username},
|
||||||
|
{"display_name", display_name},
|
||||||
|
{"group_id", group_id},
|
||||||
|
{"password_salt", salt},
|
||||||
|
{"password_hash", CryptoUtil::hashPassword(salt, password)},
|
||||||
|
{"pin_salt", nullptr},
|
||||||
|
{"pin_hash", nullptr},
|
||||||
|
{"enabled", true}};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
AuthService::AuthService(Database& db) : db_(db)
|
||||||
|
{
|
||||||
|
loadOrSeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::loadOrSeed()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
data_ = nlohmann::json::object();
|
||||||
|
if (!db_.getDocument("auth", data_))
|
||||||
|
data_ = nlohmann::json::object();
|
||||||
|
|
||||||
|
if (!data_.contains("version"))
|
||||||
|
data_["version"] = 1;
|
||||||
|
if (!data_.contains("groups") || !data_["groups"].is_array() || data_["groups"].empty())
|
||||||
|
{
|
||||||
|
data_["groups"] = nlohmann::json::array({
|
||||||
|
{{"id", "group_distributors"},
|
||||||
|
{"name", "Distributors"},
|
||||||
|
{"allow_pin", false},
|
||||||
|
{"permissions", defaultPermissionsAllWrite()}},
|
||||||
|
{{"id", "group_administrators"},
|
||||||
|
{"name", "Administrators"},
|
||||||
|
{"allow_pin", false},
|
||||||
|
{"permissions", defaultPermissionsAdministrator()}},
|
||||||
|
{{"id", "group_users"},
|
||||||
|
{"name", "Users"},
|
||||||
|
{"allow_pin", true},
|
||||||
|
{"permissions", defaultPermissionsUserGroup()}},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data_.contains("users") || !data_["users"].is_array() || data_["users"].empty())
|
||||||
|
{
|
||||||
|
data_["users"] = nlohmann::json::array({
|
||||||
|
makeUser("user_distributor", "Distributor", "distributor", "group_distributors", "Distributor"),
|
||||||
|
makeUser("user_admin", "Admin", "admin", "group_administrators", "Administrator"),
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::saveUnlocked()
|
||||||
|
{
|
||||||
|
db_.setDocument("auth", data_);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthSession* AuthService::currentSession() const
|
||||||
|
{
|
||||||
|
return tls_session_;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string AuthService::extractToken(const httplib::Request& req) const
|
||||||
|
{
|
||||||
|
if (req.has_header("Cookie"))
|
||||||
|
{
|
||||||
|
const std::string cookie = req.get_header_value("Cookie");
|
||||||
|
const std::string prefix = std::string(kSessionCookie) + "=";
|
||||||
|
const auto pos = cookie.find(prefix);
|
||||||
|
if (pos != std::string::npos)
|
||||||
|
{
|
||||||
|
const auto start = pos + prefix.size();
|
||||||
|
const auto end = cookie.find(';', start);
|
||||||
|
return cookie.substr(start, end == std::string::npos ? std::string::npos : end - start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (req.has_header("Authorization"))
|
||||||
|
{
|
||||||
|
const std::string auth = req.get_header_value("Authorization");
|
||||||
|
constexpr const char* kBearer = "Bearer ";
|
||||||
|
if (auth.rfind(kBearer, 0) == 0)
|
||||||
|
return auth.substr(std::strlen(kBearer));
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AuthService::isPublicApiPath(const std::string& path, const std::string& method)
|
||||||
|
{
|
||||||
|
if (method == "OPTIONS")
|
||||||
|
return true;
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (path.rfind("/api/auth/", 0) == 0)
|
||||||
|
return std::nullopt;
|
||||||
|
if (path.rfind("/api/users", 0) == 0 || path.rfind("/api/user_groups", 0) == 0)
|
||||||
|
return "users";
|
||||||
|
if (path.rfind("/api/missions", 0) == 0 || path.rfind("/api/mission_queue", 0) == 0 ||
|
||||||
|
path.rfind("/api/robot", 0) == 0)
|
||||||
|
return "missions";
|
||||||
|
if (path.rfind("/api/triggers", 0) == 0 || path.rfind("/api/schedules", 0) == 0 ||
|
||||||
|
path.rfind("/api/robots", 0) == 0 || path.rfind("/api/fleet", 0) == 0 ||
|
||||||
|
path.rfind("/api/modbus", 0) == 0 || path.rfind("/api/v2.0.0/", 0) == 0)
|
||||||
|
return "integrations";
|
||||||
|
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 std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AuthService::requiresWrite(const std::string& method)
|
||||||
|
{
|
||||||
|
return method != "GET" && method != "HEAD" && method != "OPTIONS";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AuthService::permissionAllows(const nlohmann::json& perms,
|
||||||
|
const std::string& resource,
|
||||||
|
bool write) const
|
||||||
|
{
|
||||||
|
if (!perms.is_object() || !perms.contains(resource))
|
||||||
|
return false;
|
||||||
|
const std::string level = perms.value(resource, "none");
|
||||||
|
if (level == "none")
|
||||||
|
return false;
|
||||||
|
if (write)
|
||||||
|
return level == "write";
|
||||||
|
return level == "read" || level == "write";
|
||||||
|
}
|
||||||
|
|
||||||
|
const nlohmann::json* AuthService::findUserByIdUnlocked(const std::string& id) const
|
||||||
|
{
|
||||||
|
if (!data_.contains("users") || !data_["users"].is_array())
|
||||||
|
return nullptr;
|
||||||
|
for (const auto& u : data_["users"])
|
||||||
|
{
|
||||||
|
if (u.value("id", "") == id)
|
||||||
|
return &u;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nlohmann::json* AuthService::findUserByUsernameUnlocked(const std::string& username) const
|
||||||
|
{
|
||||||
|
if (!data_.contains("users") || !data_["users"].is_array())
|
||||||
|
return nullptr;
|
||||||
|
const std::string needle = StringUtil::toLower(StringUtil::trimCopy(username));
|
||||||
|
for (const auto& u : data_["users"])
|
||||||
|
{
|
||||||
|
if (StringUtil::toLower(u.value("username", "")) == needle)
|
||||||
|
return &u;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nlohmann::json* AuthService::findGroupByIdUnlocked(const std::string& id) const
|
||||||
|
{
|
||||||
|
if (!data_.contains("groups") || !data_["groups"].is_array())
|
||||||
|
return nullptr;
|
||||||
|
for (const auto& g : data_["groups"])
|
||||||
|
{
|
||||||
|
if (g.value("id", "") == id)
|
||||||
|
return &g;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AuthService::verifyPasswordUnlocked(const nlohmann::json& user, const std::string& password) const
|
||||||
|
{
|
||||||
|
const std::string salt = user.value("password_salt", "");
|
||||||
|
const std::string hash = user.value("password_hash", "");
|
||||||
|
if (salt.empty() || hash.empty())
|
||||||
|
return false;
|
||||||
|
return CryptoUtil::hashPassword(salt, password) == hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AuthService::verifyPinUnlocked(const nlohmann::json& user, const std::string& pin) const
|
||||||
|
{
|
||||||
|
if (pin.size() != 4 || !std::all_of(pin.begin(), pin.end(), ::isdigit))
|
||||||
|
return false;
|
||||||
|
if (user.value("pin_hash", nlohmann::json()).is_null())
|
||||||
|
return false;
|
||||||
|
const std::string salt = user.value("pin_salt", "");
|
||||||
|
const std::string hash = user.value("pin_hash", "");
|
||||||
|
if (salt.empty() || hash.empty())
|
||||||
|
return false;
|
||||||
|
return CryptoUtil::hashPin(salt, pin) == hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AuthService::groupAllowsPinUnlocked(const std::string& group_id) const
|
||||||
|
{
|
||||||
|
const auto* group = findGroupByIdUnlocked(group_id);
|
||||||
|
return group && group->value("allow_pin", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<AuthSession> AuthService::buildSessionUnlocked(const nlohmann::json& user)
|
||||||
|
{
|
||||||
|
const auto* group = findGroupByIdUnlocked(user.value("group_id", ""));
|
||||||
|
if (!group)
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
AuthSession session;
|
||||||
|
session.token = CryptoUtil::randomToken(32);
|
||||||
|
session.user_id = user.value("id", "");
|
||||||
|
session.username = user.value("username", "");
|
||||||
|
session.group_id = group->value("id", "");
|
||||||
|
session.group_name = group->value("name", "");
|
||||||
|
session.permissions = group->value("permissions", nlohmann::json::object());
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json AuthService::userPublicView(const nlohmann::json& user, const nlohmann::json& group)
|
||||||
|
{
|
||||||
|
return {{"id", user.value("id", "")},
|
||||||
|
{"username", user.value("username", "")},
|
||||||
|
{"display_name", user.value("display_name", "")},
|
||||||
|
{"group_id", user.value("group_id", "")},
|
||||||
|
{"group_name", group.value("name", "")},
|
||||||
|
{"permissions", group.value("permissions", nlohmann::json::object())},
|
||||||
|
{"has_pin", !user.value("pin_hash", nlohmann::json()).is_null()}};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> AuthService::loginPassword(const std::string& username,
|
||||||
|
const std::string& password,
|
||||||
|
std::string& err)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
const auto* user = findUserByUsernameUnlocked(username);
|
||||||
|
if (!user || !user->value("enabled", true))
|
||||||
|
{
|
||||||
|
err = "invalid credentials";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (!verifyPasswordUnlocked(*user, password))
|
||||||
|
{
|
||||||
|
err = "invalid credentials";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
auto session = buildSessionUnlocked(*user);
|
||||||
|
if (!session)
|
||||||
|
{
|
||||||
|
err = "invalid user group";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
sessions_[session->token] = *session;
|
||||||
|
const auto* group = findGroupByIdUnlocked(user->value("group_id", ""));
|
||||||
|
return nlohmann::json{{"token", session->token},
|
||||||
|
{"user", userPublicView(*user, group ? *group : nlohmann::json::object())}};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> AuthService::loginPin(const std::string& pin, std::string& err)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (pin.size() != 4 || !std::all_of(pin.begin(), pin.end(), ::isdigit))
|
||||||
|
{
|
||||||
|
err = "pin must be 4 digits";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nlohmann::json* matched = nullptr;
|
||||||
|
for (const auto& user : data_["users"])
|
||||||
|
{
|
||||||
|
if (!user.value("enabled", true))
|
||||||
|
continue;
|
||||||
|
if (!groupAllowsPinUnlocked(user.value("group_id", "")))
|
||||||
|
continue;
|
||||||
|
if (verifyPinUnlocked(user, pin))
|
||||||
|
{
|
||||||
|
matched = &user;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matched)
|
||||||
|
{
|
||||||
|
err = "invalid pin";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto session = buildSessionUnlocked(*matched);
|
||||||
|
if (!session)
|
||||||
|
{
|
||||||
|
err = "invalid user group";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
sessions_[session->token] = *session;
|
||||||
|
const auto* group = findGroupByIdUnlocked(matched->value("group_id", ""));
|
||||||
|
return nlohmann::json{{"token", session->token},
|
||||||
|
{"user", userPublicView(*matched, group ? *group : nlohmann::json::object())}};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AuthService::logout(const std::string& token)
|
||||||
|
{
|
||||||
|
if (token.empty())
|
||||||
|
return false;
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
return sessions_.erase(token) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> AuthService::sessionInfo(const std::string& token) const
|
||||||
|
{
|
||||||
|
if (token.empty())
|
||||||
|
return std::nullopt;
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
const auto it = sessions_.find(token);
|
||||||
|
if (it == sessions_.end())
|
||||||
|
return std::nullopt;
|
||||||
|
const auto* user = findUserByIdUnlocked(it->second.user_id);
|
||||||
|
if (!user)
|
||||||
|
return std::nullopt;
|
||||||
|
const auto* group = findGroupByIdUnlocked(user->value("group_id", ""));
|
||||||
|
return userPublicView(*user, group ? *group : nlohmann::json::object());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AuthService::changePassword(const std::string& token,
|
||||||
|
const std::string& current_password,
|
||||||
|
const std::string& new_password,
|
||||||
|
std::string& err)
|
||||||
|
{
|
||||||
|
if (new_password.size() < 4)
|
||||||
|
{
|
||||||
|
err = "password too short";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
const auto it = sessions_.find(token);
|
||||||
|
if (it == sessions_.end())
|
||||||
|
{
|
||||||
|
err = "not authenticated";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto& user : data_["users"])
|
||||||
|
{
|
||||||
|
if (user.value("id", "") != it->second.user_id)
|
||||||
|
continue;
|
||||||
|
if (!verifyPasswordUnlocked(user, current_password))
|
||||||
|
{
|
||||||
|
err = "current password incorrect";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const std::string salt = CryptoUtil::randomToken(16);
|
||||||
|
user["password_salt"] = salt;
|
||||||
|
user["password_hash"] = CryptoUtil::hashPassword(salt, new_password);
|
||||||
|
saveUnlocked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
err = "user not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> AuthService::changeProfile(const std::string& token,
|
||||||
|
const nlohmann::json& payload,
|
||||||
|
std::string& err)
|
||||||
|
{
|
||||||
|
if (!payload.is_object())
|
||||||
|
{
|
||||||
|
err = "invalid payload";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
const auto it = sessions_.find(token);
|
||||||
|
if (it == sessions_.end())
|
||||||
|
{
|
||||||
|
err = "not authenticated";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto& user : data_["users"])
|
||||||
|
{
|
||||||
|
if (user.value("id", "") != it->second.user_id)
|
||||||
|
continue;
|
||||||
|
if (payload.contains("display_name") && payload["display_name"].is_string())
|
||||||
|
{
|
||||||
|
const std::string name = StringUtil::trimCopy(payload["display_name"].get<std::string>());
|
||||||
|
if (name.empty())
|
||||||
|
{
|
||||||
|
err = "display_name cannot be empty";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
user["display_name"] = name;
|
||||||
|
}
|
||||||
|
saveUnlocked();
|
||||||
|
const auto* group = findGroupByIdUnlocked(user.value("group_id", ""));
|
||||||
|
return userPublicView(user, group ? *group : nlohmann::json::object());
|
||||||
|
}
|
||||||
|
|
||||||
|
err = "user not found";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json AuthService::listGroups() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
nlohmann::json out = nlohmann::json::array();
|
||||||
|
for (const auto& g : data_["groups"])
|
||||||
|
{
|
||||||
|
out.push_back({{"id", g.value("id", "")},
|
||||||
|
{"name", g.value("name", "")},
|
||||||
|
{"allow_pin", g.value("allow_pin", false)},
|
||||||
|
{"permissions", g.value("permissions", nlohmann::json::object())}});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json AuthService::listUsers() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
nlohmann::json out = nlohmann::json::array();
|
||||||
|
for (const auto& u : data_["users"])
|
||||||
|
{
|
||||||
|
const auto* group = findGroupByIdUnlocked(u.value("group_id", ""));
|
||||||
|
out.push_back(userPublicView(u, group ? *group : nlohmann::json::object()));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> AuthService::createUser(const nlohmann::json& payload, std::string& err)
|
||||||
|
{
|
||||||
|
const std::string username = payload.value("username", "");
|
||||||
|
const std::string password = payload.value("password", "");
|
||||||
|
const std::string group_id = payload.value("group_id", "");
|
||||||
|
if (username.empty() || password.empty() || group_id.empty())
|
||||||
|
{
|
||||||
|
err = "username, password and group_id required";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (findUserByUsernameUnlocked(username))
|
||||||
|
{
|
||||||
|
err = "username already exists";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (!findGroupByIdUnlocked(group_id))
|
||||||
|
{
|
||||||
|
err = "unknown group";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string id = "user_" + IdUtil::newId();
|
||||||
|
auto user = makeUser(id, username, password, group_id, payload.value("display_name", username));
|
||||||
|
if (payload.contains("pin") && !payload["pin"].is_null())
|
||||||
|
{
|
||||||
|
const std::string pin = payload.value("pin", "");
|
||||||
|
if (pin.size() != 4 || !std::all_of(pin.begin(), pin.end(), ::isdigit))
|
||||||
|
{
|
||||||
|
err = "pin must be 4 digits";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (!groupAllowsPinUnlocked(group_id))
|
||||||
|
{
|
||||||
|
err = "group does not allow pin";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const std::string pin_salt = CryptoUtil::randomToken(16);
|
||||||
|
user["pin_salt"] = pin_salt;
|
||||||
|
user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
data_["users"].push_back(user);
|
||||||
|
saveUnlocked();
|
||||||
|
const auto* group = findGroupByIdUnlocked(group_id);
|
||||||
|
return userPublicView(user, group ? *group : nlohmann::json::object());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> AuthService::updateUser(const std::string& id,
|
||||||
|
const nlohmann::json& payload,
|
||||||
|
std::string& err)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
for (auto& user : data_["users"])
|
||||||
|
{
|
||||||
|
if (user.value("id", "") != id)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (payload.contains("display_name"))
|
||||||
|
user["display_name"] = payload["display_name"];
|
||||||
|
if (payload.contains("enabled"))
|
||||||
|
user["enabled"] = payload["enabled"];
|
||||||
|
if (payload.contains("group_id"))
|
||||||
|
{
|
||||||
|
const std::string group_id = payload.value("group_id", "");
|
||||||
|
if (!findGroupByIdUnlocked(group_id))
|
||||||
|
{
|
||||||
|
err = "unknown group";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
user["group_id"] = group_id;
|
||||||
|
}
|
||||||
|
if (payload.contains("password") && payload["password"].is_string())
|
||||||
|
{
|
||||||
|
const std::string password = payload["password"];
|
||||||
|
const std::string salt = CryptoUtil::randomToken(16);
|
||||||
|
user["password_salt"] = salt;
|
||||||
|
user["password_hash"] = CryptoUtil::hashPassword(salt, password);
|
||||||
|
}
|
||||||
|
if (payload.contains("pin"))
|
||||||
|
{
|
||||||
|
if (payload["pin"].is_null())
|
||||||
|
{
|
||||||
|
user["pin_salt"] = nullptr;
|
||||||
|
user["pin_hash"] = nullptr;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const std::string pin = payload.value("pin", "");
|
||||||
|
if (pin.size() != 4 || !std::all_of(pin.begin(), pin.end(), ::isdigit))
|
||||||
|
{
|
||||||
|
err = "pin must be 4 digits";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (!groupAllowsPinUnlocked(user.value("group_id", "")))
|
||||||
|
{
|
||||||
|
err = "group does not allow pin";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const std::string pin_salt = CryptoUtil::randomToken(16);
|
||||||
|
user["pin_salt"] = pin_salt;
|
||||||
|
user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveUnlocked();
|
||||||
|
const auto* group = findGroupByIdUnlocked(user.value("group_id", ""));
|
||||||
|
return userPublicView(user, group ? *group : nlohmann::json::object());
|
||||||
|
}
|
||||||
|
|
||||||
|
err = "user not found";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AuthService::deleteUser(const std::string& id, std::string& err)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
auto& users = data_["users"];
|
||||||
|
const auto it = std::remove_if(users.begin(), users.end(), [&](const nlohmann::json& u) {
|
||||||
|
return u.value("id", "") == id;
|
||||||
|
});
|
||||||
|
if (it == users.end())
|
||||||
|
{
|
||||||
|
err = "user not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
users.erase(it, users.end());
|
||||||
|
saveUnlocked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AuthService::authorizeApiRequest(const httplib::Request& req, httplib::Response& res)
|
||||||
|
{
|
||||||
|
if (const char* disabled = std::getenv("LM_AUTH_DISABLED"); disabled && std::string(disabled) == "1")
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const std::string token = extractToken(req);
|
||||||
|
if (token.empty())
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 401, "authentication required");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
const auto it = sessions_.find(token);
|
||||||
|
if (it == sessions_.end())
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 401, "invalid or expired session");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
tls_session_ = &it->second;
|
||||||
|
|
||||||
|
const auto resource = resourceForApiPath(req.path);
|
||||||
|
if (!resource)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 403, "insufficient permissions");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
httplib::Server::HandlerResponse AuthService::preRoute(const httplib::Request& req,
|
||||||
|
httplib::Response& res)
|
||||||
|
{
|
||||||
|
tls_session_ = nullptr;
|
||||||
|
|
||||||
|
if (req.path.rfind("/api/", 0) != 0)
|
||||||
|
return httplib::Server::HandlerResponse::Unhandled;
|
||||||
|
|
||||||
|
if (isPublicApiPath(req.path, req.method))
|
||||||
|
return httplib::Server::HandlerResponse::Unhandled;
|
||||||
|
|
||||||
|
if (!authorizeApiRequest(req, res))
|
||||||
|
{
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
return httplib::Server::HandlerResponse::Handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return httplib::Server::HandlerResponse::Unhandled;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::registerRoutes(httplib::Server& svr)
|
||||||
|
{
|
||||||
|
svr.Post("/api/auth/login", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 400, "invalid json");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
std::optional<nlohmann::json> result;
|
||||||
|
if (body.contains("pin"))
|
||||||
|
result = loginPin(body.value("pin", ""), err);
|
||||||
|
else
|
||||||
|
result = loginPassword(body.value("username", ""), body.value("password", ""), err);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 401, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string token = result->value("token", "");
|
||||||
|
res.set_header("Set-Cookie",
|
||||||
|
std::string(kSessionCookie) + "=" + token + "; Path=/; HttpOnly; SameSite=Lax");
|
||||||
|
res.set_content(result->dump(), "application/json; charset=utf-8");
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/auth/logout", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
const std::string token = extractToken(req);
|
||||||
|
logout(token);
|
||||||
|
res.set_header("Set-Cookie", std::string(kSessionCookie) + "=; Path=/; HttpOnly; Max-Age=0");
|
||||||
|
res.set_content(R"({"ok":true})", "application/json; charset=utf-8");
|
||||||
|
HttpUtil::addCors(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 auto info = sessionInfo(token);
|
||||||
|
if (!info)
|
||||||
|
{
|
||||||
|
res.set_content(R"({"user":null})", "application/json; charset=utf-8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.set_content(nlohmann::json({{"user", *info}}).dump(), "application/json; charset=utf-8");
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Put("/api/auth/password", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 400, "invalid json");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string token = extractToken(req);
|
||||||
|
std::string err;
|
||||||
|
if (!changePassword(token,
|
||||||
|
body.value("current_password", ""),
|
||||||
|
body.value("new_password", ""),
|
||||||
|
err))
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 400, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.set_content(R"({"ok":true})", "application/json; charset=utf-8");
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Put("/api/auth/profile", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 400, "invalid json");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string token = extractToken(req);
|
||||||
|
std::string err;
|
||||||
|
const auto profile = changeProfile(token, body, err);
|
||||||
|
if (!profile)
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 400, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nlohmann::json out = {{"user", *profile}};
|
||||||
|
res.set_content(out.dump(), "application/json; charset=utf-8");
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Get("/api/user_groups", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
nlohmann::json out = {{"groups", listGroups()}};
|
||||||
|
res.set_content(out.dump(), "application/json; charset=utf-8");
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Get("/api/users", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
nlohmann::json out = {{"users", listUsers()}};
|
||||||
|
res.set_content(out.dump(), "application/json; charset=utf-8");
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/users", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 400, "invalid json");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
const auto user = createUser(body, err);
|
||||||
|
if (!user)
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 400, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status = 201;
|
||||||
|
res.set_content(user->dump(), "application/json; charset=utf-8");
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Put(R"(/api/users/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 400, "invalid json");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
const auto user = updateUser(req.matches[1].str(), body, err);
|
||||||
|
if (!user)
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 400, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.set_content(user->dump(), "application/json; charset=utf-8");
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Delete(R"(/api/users/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
std::string err;
|
||||||
|
if (!deleteUser(req.matches[1].str(), err))
|
||||||
|
{
|
||||||
|
HttpUtil::jsonError(res, 400, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.set_content(R"({"ok":true})", "application/json; charset=utf-8");
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
84
src/auth/auth_service.hpp
Normal file
84
src/auth/auth_service.hpp
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <httplib.h>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class Database;
|
||||||
|
|
||||||
|
struct AuthSession
|
||||||
|
{
|
||||||
|
std::string token;
|
||||||
|
std::string user_id;
|
||||||
|
std::string username;
|
||||||
|
std::string group_id;
|
||||||
|
std::string group_name;
|
||||||
|
nlohmann::json permissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AuthService
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit AuthService(Database& db);
|
||||||
|
|
||||||
|
httplib::Server::HandlerResponse preRoute(const httplib::Request& req, httplib::Response& res);
|
||||||
|
|
||||||
|
const AuthSession* currentSession() const;
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> loginPassword(const std::string& username,
|
||||||
|
const std::string& password,
|
||||||
|
std::string& err);
|
||||||
|
std::optional<nlohmann::json> loginPin(const std::string& pin, std::string& err);
|
||||||
|
bool logout(const std::string& token);
|
||||||
|
std::optional<nlohmann::json> sessionInfo(const std::string& token) const;
|
||||||
|
bool changePassword(const std::string& token,
|
||||||
|
const std::string& current_password,
|
||||||
|
const std::string& new_password,
|
||||||
|
std::string& err);
|
||||||
|
std::optional<nlohmann::json> changeProfile(const std::string& token,
|
||||||
|
const nlohmann::json& payload,
|
||||||
|
std::string& err);
|
||||||
|
|
||||||
|
nlohmann::json listGroups() const;
|
||||||
|
nlohmann::json listUsers() const;
|
||||||
|
std::optional<nlohmann::json> createUser(const nlohmann::json& payload, std::string& err);
|
||||||
|
std::optional<nlohmann::json> updateUser(const std::string& id,
|
||||||
|
const nlohmann::json& payload,
|
||||||
|
std::string& err);
|
||||||
|
bool deleteUser(const std::string& id, std::string& err);
|
||||||
|
|
||||||
|
void registerRoutes(httplib::Server& svr);
|
||||||
|
|
||||||
|
private:
|
||||||
|
Database& db_;
|
||||||
|
mutable std::mutex mu_;
|
||||||
|
nlohmann::json data_;
|
||||||
|
std::unordered_map<std::string, AuthSession> sessions_;
|
||||||
|
thread_local static const AuthSession* tls_session_;
|
||||||
|
|
||||||
|
void loadOrSeed();
|
||||||
|
void saveUnlocked();
|
||||||
|
std::string extractToken(const httplib::Request& req) const;
|
||||||
|
std::optional<AuthSession> buildSessionUnlocked(const nlohmann::json& user);
|
||||||
|
bool permissionAllows(const nlohmann::json& perms, const std::string& resource, bool write) const;
|
||||||
|
bool authorizeApiRequest(const httplib::Request& req, httplib::Response& res);
|
||||||
|
static bool isPublicApiPath(const std::string& path, const std::string& method);
|
||||||
|
static std::optional<std::string> resourceForApiPath(const std::string& path);
|
||||||
|
static bool requiresWrite(const std::string& method);
|
||||||
|
static nlohmann::json userPublicView(const nlohmann::json& user, const nlohmann::json& group);
|
||||||
|
const nlohmann::json* findUserByIdUnlocked(const std::string& id) const;
|
||||||
|
const nlohmann::json* findUserByUsernameUnlocked(const std::string& username) const;
|
||||||
|
const nlohmann::json* findGroupByIdUnlocked(const std::string& id) const;
|
||||||
|
bool verifyPasswordUnlocked(const nlohmann::json& user, const std::string& password) const;
|
||||||
|
bool verifyPinUnlocked(const nlohmann::json& user, const std::string& pin) const;
|
||||||
|
bool groupAllowsPinUnlocked(const std::string& group_id) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
@@ -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,9 +1,8 @@
|
|||||||
#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 <chrono>
|
#include <chrono>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
@@ -13,6 +12,12 @@ namespace lm {
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
class MissionCancelled : public std::runtime_error
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
MissionCancelled() : std::runtime_error("mission cancelled") {}
|
||||||
|
};
|
||||||
|
|
||||||
std::string paramValue(const std::string& action_id,
|
std::string paramValue(const std::string& action_id,
|
||||||
const nlohmann::json& params,
|
const nlohmann::json& params,
|
||||||
const std::string& key,
|
const std::string& key,
|
||||||
@@ -35,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();
|
||||||
@@ -52,25 +57,18 @@ MissionQueue::~MissionQueue()
|
|||||||
|
|
||||||
void MissionQueue::load()
|
void MissionQueue::load()
|
||||||
{
|
{
|
||||||
std::lock_guard<std::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();
|
||||||
}
|
}
|
||||||
@@ -78,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()
|
||||||
@@ -106,13 +104,13 @@ void MissionQueue::startWorkerIfNeeded()
|
|||||||
|
|
||||||
nlohmann::json MissionQueue::list() const
|
nlohmann::json MissionQueue::list() const
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
return queue_;
|
return queue_;
|
||||||
}
|
}
|
||||||
|
|
||||||
nlohmann::json MissionQueue::runnerStatus() const
|
nlohmann::json MissionQueue::runnerStatus() const
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
return runner_;
|
return runner_;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +145,7 @@ std::optional<nlohmann::json> MissionQueue::enqueue(const nlohmann::json& payloa
|
|||||||
entry["log"] = nlohmann::json::array();
|
entry["log"] = nlohmann::json::array();
|
||||||
|
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
insertByPriorityUnlocked(entry);
|
insertByPriorityUnlocked(entry);
|
||||||
saveUnlocked();
|
saveUnlocked();
|
||||||
}
|
}
|
||||||
@@ -181,7 +179,7 @@ void MissionQueue::insertByPriorityUnlocked(nlohmann::json& entry)
|
|||||||
|
|
||||||
bool MissionQueue::removeById(const std::string& id, std::string& err)
|
bool MissionQueue::removeById(const std::string& id, std::string& err)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
if (!queue_.is_array())
|
if (!queue_.is_array())
|
||||||
{
|
{
|
||||||
err = "queue unavailable";
|
err = "queue unavailable";
|
||||||
@@ -218,7 +216,7 @@ bool MissionQueue::removeById(const std::string& id, std::string& err)
|
|||||||
bool MissionQueue::clearAll(std::string& err)
|
bool MissionQueue::clearAll(std::string& err)
|
||||||
{
|
{
|
||||||
(void)err;
|
(void)err;
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
nlohmann::json next = nlohmann::json::array();
|
nlohmann::json next = nlohmann::json::array();
|
||||||
for (const auto& item : queue_)
|
for (const auto& item : queue_)
|
||||||
{
|
{
|
||||||
@@ -241,7 +239,7 @@ bool MissionQueue::reorder(const nlohmann::json& ordered_ids, std::string& err)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
if (!queue_.is_array())
|
if (!queue_.is_array())
|
||||||
{
|
{
|
||||||
err = "queue unavailable";
|
err = "queue unavailable";
|
||||||
@@ -285,7 +283,7 @@ bool MissionQueue::reorder(const nlohmann::json& ordered_ids, std::string& err)
|
|||||||
|
|
||||||
bool MissionQueue::pause(std::string& err)
|
bool MissionQueue::pause(std::string& err)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
const std::string state = runner_.value("state", "idle");
|
const std::string state = runner_.value("state", "idle");
|
||||||
if (state != "running")
|
if (state != "running")
|
||||||
{
|
{
|
||||||
@@ -306,7 +304,7 @@ bool MissionQueue::resume(std::string& err)
|
|||||||
(void)err;
|
(void)err;
|
||||||
paused_ = false;
|
paused_ = false;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
runner_["paused"] = false;
|
runner_["paused"] = false;
|
||||||
if (runner_.value("state", "") == "paused")
|
if (runner_.value("state", "") == "paused")
|
||||||
{
|
{
|
||||||
@@ -320,77 +318,139 @@ bool MissionQueue::resume(std::string& err)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool MissionQueue::cancel(std::string& err)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
|
const std::string state = runner_.value("state", "idle");
|
||||||
|
if (state != "running" && state != "paused")
|
||||||
|
{
|
||||||
|
err = "no mission is running";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (cancel_)
|
||||||
|
{
|
||||||
|
err = "cancel already in progress";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
cancel_ = true;
|
||||||
|
paused_ = false;
|
||||||
|
runner_["paused"] = false;
|
||||||
|
runner_["message"] = "Đang hủy mission…";
|
||||||
|
runner_["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
saveUnlocked();
|
||||||
|
wake_ = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void MissionQueue::workerLoop()
|
void MissionQueue::workerLoop()
|
||||||
{
|
{
|
||||||
while (!stop_)
|
while (!stop_)
|
||||||
{
|
{
|
||||||
|
nlohmann::json working;
|
||||||
|
bool run = false;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
processQueueUnlocked();
|
if (!paused_ && queue_.is_array())
|
||||||
|
{
|
||||||
|
for (auto& item : queue_)
|
||||||
|
{
|
||||||
|
if (!item.is_object())
|
||||||
|
continue;
|
||||||
|
if (item.value("status", "") != "pending")
|
||||||
|
continue;
|
||||||
|
item["status"] = "executing";
|
||||||
|
item["started_at"] = IdUtil::nowIso8601();
|
||||||
|
runner_["current_queue_id"] = item.value("id", "");
|
||||||
|
runner_["current_action"] = nullptr;
|
||||||
|
setRunnerState("running", "Đang chạy: " + item.value("mission_name", "Mission"));
|
||||||
|
saveUnlocked();
|
||||||
|
working = item;
|
||||||
|
run = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!run && runner_.value("state", "") == "running")
|
||||||
|
{
|
||||||
|
setRunnerState("idle", "Queue trống");
|
||||||
|
saveUnlocked();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (run)
|
||||||
|
{
|
||||||
|
runMissionActions(working);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
|
const std::string id = working.value("id", "");
|
||||||
|
for (auto& item : queue_)
|
||||||
|
{
|
||||||
|
if (item.is_object() && item.value("id", "") == id)
|
||||||
|
{
|
||||||
|
item = working;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runner_["current_queue_id"] = nullptr;
|
||||||
|
runner_["current_action"] = nullptr;
|
||||||
|
saveUnlocked();
|
||||||
|
}
|
||||||
|
wake_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i < 20 && !wake_; ++i)
|
for (int i = 0; i < 20 && !wake_; ++i)
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
wake_ = false;
|
wake_ = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MissionQueue::processQueueUnlocked()
|
void MissionQueue::runMissionActions(nlohmann::json& entry)
|
||||||
{
|
{
|
||||||
if (!queue_.is_array())
|
cancel_ = false;
|
||||||
return;
|
nlohmann::json log = nlohmann::json::array();
|
||||||
if (paused_)
|
|
||||||
return;
|
|
||||||
|
|
||||||
for (auto& item : queue_)
|
|
||||||
{
|
|
||||||
if (!item.is_object())
|
|
||||||
continue;
|
|
||||||
if (item.value("status", "") != "pending")
|
|
||||||
continue;
|
|
||||||
executeMissionUnlocked(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runner_.value("state", "") == "running")
|
|
||||||
setRunnerState("idle", "Queue trống");
|
|
||||||
}
|
|
||||||
|
|
||||||
void MissionQueue::executeMissionUnlocked(nlohmann::json& entry)
|
|
||||||
{
|
|
||||||
entry["status"] = "executing";
|
|
||||||
entry["started_at"] = IdUtil::nowIso8601();
|
|
||||||
runner_["current_queue_id"] = entry.value("id", "");
|
|
||||||
runner_["current_action"] = nullptr;
|
|
||||||
setRunnerState("running", "Đang chạy: " + entry.value("mission_name", "Mission"));
|
|
||||||
saveUnlocked();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
nlohmann::json log = nlohmann::json::array();
|
|
||||||
const auto& mission = entry["mission"];
|
const auto& mission = entry["mission"];
|
||||||
const auto& parameters = entry["parameters"];
|
const auto& parameters = entry["parameters"];
|
||||||
const auto& actions =
|
const auto& actions =
|
||||||
mission.contains("actions") && mission["actions"].is_array() ? mission["actions"] : nlohmann::json::array();
|
mission.contains("actions") && mission["actions"].is_array() ? mission["actions"] : nlohmann::json::array();
|
||||||
executeActionsUnlocked(actions, parameters, log, 0);
|
executeActionsUnlocked(actions, parameters, log, 0);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
entry["log"] = log;
|
entry["log"] = log;
|
||||||
entry["status"] = "completed";
|
entry["status"] = "completed";
|
||||||
entry["finished_at"] = IdUtil::nowIso8601();
|
entry["finished_at"] = IdUtil::nowIso8601();
|
||||||
setRunnerState("idle", "Hoàn thành: " + entry.value("mission_name", "Mission"));
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
|
setRunnerState("idle", "Hoàn thành: " + entry.value("mission_name", "Mission"));
|
||||||
|
saveUnlocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const MissionCancelled&)
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "warn"}, {"message", "Mission hủy bởi operator"}});
|
||||||
|
entry["log"] = log;
|
||||||
|
entry["status"] = "cancelled";
|
||||||
|
entry["finished_at"] = IdUtil::nowIso8601();
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
|
setRunnerState("idle", "Đã hủy: " + entry.value("mission_name", "Mission"));
|
||||||
|
saveUnlocked();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
entry["status"] = "failed";
|
entry["status"] = "failed";
|
||||||
entry["finished_at"] = IdUtil::nowIso8601();
|
entry["finished_at"] = IdUtil::nowIso8601();
|
||||||
setRunnerState("error", "Lỗi khi chạy: " + entry.value("mission_name", "Mission"));
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
|
setRunnerState("error", "Lỗi khi chạy: " + entry.value("mission_name", "Mission"));
|
||||||
|
saveUnlocked();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
cancel_ = false;
|
||||||
runner_["current_queue_id"] = nullptr;
|
|
||||||
runner_["current_action"] = nullptr;
|
|
||||||
saveUnlocked();
|
|
||||||
wake_ = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
||||||
const nlohmann::json& parameters,
|
const nlohmann::json& parameters,
|
||||||
nlohmann::json& log,
|
nlohmann::json& log,
|
||||||
int loop_depth)
|
int loop_depth)
|
||||||
@@ -402,10 +462,12 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
|||||||
{
|
{
|
||||||
if (!action.is_object())
|
if (!action.is_object())
|
||||||
continue;
|
continue;
|
||||||
while (paused_ && !stop_)
|
while (paused_ && !stop_ && !cancel_)
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
if (stop_)
|
if (stop_)
|
||||||
return;
|
return LoopControl::None;
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
|
|
||||||
const std::string action_id = action.value("id", "");
|
const std::string action_id = action.value("id", "");
|
||||||
const std::string kind = action.value("kind", "action");
|
const std::string kind = action.value("kind", "action");
|
||||||
@@ -414,37 +476,70 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
|||||||
const auto& params = action.contains("params") && action["params"].is_object() ? action["params"]
|
const auto& params = action.contains("params") && action["params"].is_object() ? action["params"]
|
||||||
: nlohmann::json::object();
|
: nlohmann::json::object();
|
||||||
|
|
||||||
runner_["current_action"] = label;
|
{
|
||||||
saveUnlocked();
|
std::lock_guard<std::recursive_mutex> lock(mu_);
|
||||||
|
runner_["current_action"] = label;
|
||||||
|
saveUnlocked();
|
||||||
|
}
|
||||||
|
|
||||||
if (kind == "mission")
|
if (kind == "mission")
|
||||||
{
|
{
|
||||||
const std::string ref_id = action.value("refId", "");
|
const std::string ref_id = action.value("refId", "");
|
||||||
nlohmann::json ref_mission = nlohmann::json::object();
|
nlohmann::json ref_mission = nlohmann::json::object();
|
||||||
// Nested mission snapshot should be resolved by frontend before queueing.
|
|
||||||
(void)ref_id;
|
(void)ref_id;
|
||||||
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Sub-mission: " + label}});
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Sub-mission: " + label}});
|
||||||
if (action.contains("resolved_mission") && action["resolved_mission"].is_object())
|
if (action.contains("resolved_mission") && action["resolved_mission"].is_object())
|
||||||
{
|
{
|
||||||
const auto& nested_actions = action["resolved_mission"]["actions"];
|
const auto& nested_actions = action["resolved_mission"]["actions"];
|
||||||
executeActionsUnlocked(nested_actions, parameters, log, loop_depth);
|
const LoopControl nested = executeActionsUnlocked(nested_actions, parameters, log, loop_depth);
|
||||||
|
if (nested == LoopControl::Break)
|
||||||
|
return LoopControl::Break;
|
||||||
|
if (nested == LoopControl::Continue)
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type == "break")
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Break loop"}});
|
||||||
|
return LoopControl::Break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == "continue")
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Continue loop"}});
|
||||||
|
return LoopControl::Continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (type == "loop")
|
if (type == "loop")
|
||||||
{
|
{
|
||||||
const std::string mode = params.value("mode", "count");
|
const std::string mode = params.value("mode", "count");
|
||||||
const int count = static_cast<int>(paramNumber(params, "count", 1));
|
const int count = static_cast<int>(paramNumber(params, "count", 1));
|
||||||
const auto& children =
|
const auto& children =
|
||||||
action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array();
|
action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array();
|
||||||
const int iterations = mode == "endless" ? 1 : std::max(1, count);
|
const int iterations = mode == "endless" ? 10000 : std::max(1, count);
|
||||||
for (int i = 0; i < iterations; ++i)
|
for (int i = 0; i < iterations && !stop_ && !cancel_; ++i)
|
||||||
{
|
{
|
||||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
if (mode == "endless" && i == 0)
|
||||||
{"level", "info"},
|
{
|
||||||
{"message", "Loop " + std::to_string(i + 1) + "/" + std::to_string(iterations)}});
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
executeActionsUnlocked(children, parameters, log, loop_depth + 1);
|
{"level", "info"},
|
||||||
|
{"message", "Loop endless (simulated, max " + std::to_string(iterations) + ")"}});
|
||||||
|
}
|
||||||
|
else if (mode != "endless")
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "info"},
|
||||||
|
{"message", "Loop " + std::to_string(i + 1) + "/" + std::to_string(iterations)}});
|
||||||
|
}
|
||||||
|
const LoopControl ctrl = executeActionsUnlocked(children, parameters, log, loop_depth + 1);
|
||||||
|
if (ctrl == LoopControl::Break)
|
||||||
|
break;
|
||||||
|
if (ctrl == LoopControl::Continue)
|
||||||
|
continue;
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -454,6 +549,8 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
|||||||
const int ms = static_cast<int>(paramNumber(params, "seconds", 1) * 1000);
|
const int ms = static_cast<int>(paramNumber(params, "seconds", 1) * 1000);
|
||||||
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Wait " + std::to_string(ms) + "ms"}});
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Wait " + std::to_string(ms) + "ms"}});
|
||||||
sleepMs(ms);
|
sleepMs(ms);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,6 +561,8 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
|||||||
{"level", "info"},
|
{"level", "info"},
|
||||||
{"message", label + " → " + (pos.empty() ? "?" : pos)}});
|
{"message", label + " → " + (pos.empty() ? "?" : pos)}});
|
||||||
sleepMs(1200);
|
sleepMs(1200);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +573,8 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
|||||||
{"level", "info"},
|
{"level", "info"},
|
||||||
{"message", label + " → " + (marker.empty() ? "?" : marker)}});
|
{"message", label + " → " + (marker.empty() ? "?" : marker)}});
|
||||||
sleepMs(1200);
|
sleepMs(1200);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,6 +583,8 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
|||||||
const std::string message = params.value("message", "Mission step");
|
const std::string message = params.value("message", "Mission step");
|
||||||
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "user"}, {"message", message}});
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "user"}, {"message", message}});
|
||||||
sleepMs(200);
|
sleepMs(200);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,13 +592,18 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
|||||||
{
|
{
|
||||||
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "warn"}, {"message", "Pause (simulated)"}});
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "warn"}, {"message", "Pause (simulated)"}});
|
||||||
sleepMs(500);
|
sleepMs(500);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.push_back(
|
log.push_back(
|
||||||
{{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}});
|
{{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}});
|
||||||
sleepMs(400);
|
sleepMs(400);
|
||||||
|
if (cancel_)
|
||||||
|
throw MissionCancelled();
|
||||||
}
|
}
|
||||||
|
return LoopControl::None;
|
||||||
}
|
}
|
||||||
|
|
||||||
void MissionQueue::sleepMs(int ms)
|
void MissionQueue::sleepMs(int ms)
|
||||||
@@ -503,9 +611,9 @@ void MissionQueue::sleepMs(int ms)
|
|||||||
if (ms <= 0)
|
if (ms <= 0)
|
||||||
return;
|
return;
|
||||||
const int step = 100;
|
const int step = 100;
|
||||||
for (int elapsed = 0; elapsed < ms && !stop_; elapsed += step)
|
for (int elapsed = 0; elapsed < ms && !stop_ && !cancel_; elapsed += step)
|
||||||
{
|
{
|
||||||
while (paused_ && !stop_)
|
while (paused_ && !stop_ && !cancel_)
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(std::min(step, ms - elapsed)));
|
std::this_thread::sleep_for(std::chrono::milliseconds(std::min(step, ms - elapsed)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -29,10 +30,13 @@ public:
|
|||||||
bool reorder(const nlohmann::json& ordered_ids, std::string& err);
|
bool reorder(const nlohmann::json& ordered_ids, std::string& err);
|
||||||
bool pause(std::string& err);
|
bool pause(std::string& err);
|
||||||
bool resume(std::string& err);
|
bool resume(std::string& err);
|
||||||
|
bool cancel(std::string& err);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::filesystem::path queue_path_;
|
enum class LoopControl { None, Break, Continue };
|
||||||
mutable std::mutex mu_;
|
|
||||||
|
Database& db_;
|
||||||
|
mutable std::recursive_mutex mu_;
|
||||||
nlohmann::json queue_;
|
nlohmann::json queue_;
|
||||||
nlohmann::json runner_;
|
nlohmann::json runner_;
|
||||||
|
|
||||||
@@ -40,18 +44,18 @@ private:
|
|||||||
std::atomic<bool> stop_{false};
|
std::atomic<bool> stop_{false};
|
||||||
std::atomic<bool> wake_{false};
|
std::atomic<bool> wake_{false};
|
||||||
std::atomic<bool> paused_{false};
|
std::atomic<bool> paused_{false};
|
||||||
|
std::atomic<bool> cancel_{false};
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
void saveUnlocked() const;
|
void saveUnlocked() const;
|
||||||
void ensureRunnerDefaults();
|
void ensureRunnerDefaults();
|
||||||
void startWorkerIfNeeded();
|
void startWorkerIfNeeded();
|
||||||
void workerLoop();
|
void workerLoop();
|
||||||
void processQueueUnlocked();
|
void runMissionActions(nlohmann::json& entry);
|
||||||
void executeMissionUnlocked(nlohmann::json& entry);
|
LoopControl executeActionsUnlocked(const nlohmann::json& actions,
|
||||||
void executeActionsUnlocked(const nlohmann::json& actions,
|
const nlohmann::json& parameters,
|
||||||
const nlohmann::json& parameters,
|
nlohmann::json& log,
|
||||||
nlohmann::json& log,
|
int loop_depth);
|
||||||
int loop_depth);
|
|
||||||
void sleepMs(int ms);
|
void sleepMs(int ms);
|
||||||
void setRunnerState(const std::string& state, const std::string& message = "");
|
void setRunnerState(const std::string& state, const std::string& message = "");
|
||||||
void insertByPriorityUnlocked(nlohmann::json& entry);
|
void insertByPriorityUnlocked(nlohmann::json& entry);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
#include "util/id_util.hpp"
|
#include "util/id_util.hpp"
|
||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <ctime>
|
|
||||||
|
|
||||||
namespace lm {
|
namespace lm {
|
||||||
|
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|
||||||
|
|||||||
@@ -55,11 +55,6 @@ nlohmann::json ModbusTriggerService::coilStates() const
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ModbusTriggerService::onCoilRisingEdgeUnlocked(int coil_id)
|
|
||||||
{
|
|
||||||
(void)coil_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ModbusTriggerService::writeCoil(int coil_id, bool value, std::string& err)
|
bool ModbusTriggerService::writeCoil(int coil_id, bool value, std::string& err)
|
||||||
{
|
{
|
||||||
if (!coilIdValid(coil_id))
|
if (!coilIdValid(coil_id))
|
||||||
@@ -72,7 +67,6 @@ bool ModbusTriggerService::writeCoil(int coil_id, bool value, std::string& err)
|
|||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
const bool prev = coils_.count(coil_id) ? coils_.at(coil_id) : false;
|
const bool prev = coils_.count(coil_id) ? coils_.at(coil_id) : false;
|
||||||
coils_[coil_id] = value;
|
coils_[coil_id] = value;
|
||||||
prev_coils_[coil_id] = value;
|
|
||||||
if (!prev && value)
|
if (!prev && value)
|
||||||
{
|
{
|
||||||
const auto trigger = store_.findTriggerByCoil(coil_id);
|
const auto trigger = store_.findTriggerByCoil(coil_id);
|
||||||
|
|||||||
@@ -35,12 +35,10 @@ private:
|
|||||||
|
|
||||||
mutable std::mutex mu_;
|
mutable std::mutex mu_;
|
||||||
std::unordered_map<int, bool> coils_;
|
std::unordered_map<int, bool> coils_;
|
||||||
std::unordered_map<int, bool> prev_coils_;
|
|
||||||
|
|
||||||
std::atomic<bool> stop_{false};
|
std::atomic<bool> stop_{false};
|
||||||
std::thread tcp_thread_;
|
std::thread tcp_thread_;
|
||||||
|
|
||||||
void onCoilRisingEdgeUnlocked(int coil_id);
|
|
||||||
void tcpLoop();
|
void tcpLoop();
|
||||||
void handleTcpClient(int client_fd);
|
void handleTcpClient(int client_fd);
|
||||||
};
|
};
|
||||||
|
|||||||
283
src/robot/robot_runtime.cpp
Normal file
283
src/robot/robot_runtime.cpp
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
#include "robot/robot_runtime.hpp"
|
||||||
|
|
||||||
|
#include "mission/mission_queue.hpp"
|
||||||
|
#include "storage/database.hpp"
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr const char* kDefaultMessage = "Waiting for new missions...";
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
RobotRuntime::RobotRuntime(Database& db, MissionQueue& mission_queue)
|
||||||
|
: db_(db), mission_queue_(mission_queue)
|
||||||
|
{
|
||||||
|
load();
|
||||||
|
ensureDefaultsUnlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RobotRuntime::load()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
state_ = nlohmann::json::object();
|
||||||
|
nlohmann::json parsed;
|
||||||
|
if (db_.getDocument("robot_runtime", parsed) && parsed.is_object())
|
||||||
|
state_ = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RobotRuntime::saveUnlocked() const
|
||||||
|
{
|
||||||
|
db_.setDocument("robot_runtime", state_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RobotRuntime::ensureDefaultsUnlocked()
|
||||||
|
{
|
||||||
|
if (!state_.is_object())
|
||||||
|
state_ = nlohmann::json::object();
|
||||||
|
if (!state_.contains("motion"))
|
||||||
|
state_["motion"] = "paused";
|
||||||
|
if (!state_.contains("health"))
|
||||||
|
state_["health"] = "ok";
|
||||||
|
if (!state_.contains("message"))
|
||||||
|
state_["message"] = kDefaultMessage;
|
||||||
|
if (!state_.contains("error"))
|
||||||
|
state_["error"] = nullptr;
|
||||||
|
if (!state_.contains("battery_percent"))
|
||||||
|
state_["battery_percent"] = 54;
|
||||||
|
if (!state_.contains("battery_charging"))
|
||||||
|
state_["battery_charging"] = false;
|
||||||
|
if (!state_.contains("joystick_engaged"))
|
||||||
|
state_["joystick_engaged"] = false;
|
||||||
|
if (!state_.contains("joystick_speed"))
|
||||||
|
state_["joystick_speed"] = "fast";
|
||||||
|
if (!state_.contains("cmd_linear"))
|
||||||
|
state_["cmd_linear"] = 0.0;
|
||||||
|
if (!state_.contains("cmd_angular"))
|
||||||
|
state_["cmd_angular"] = 0.0;
|
||||||
|
if (!state_.contains("updated_at"))
|
||||||
|
state_["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
if (!state_.contains("active_map_id"))
|
||||||
|
state_["active_map_id"] = nullptr;
|
||||||
|
if (!state_.contains("pose") || !state_["pose"].is_object())
|
||||||
|
state_["pose"] = {{"x", 0.0}, {"y", 0.0}, {"yaw", 0.0}};
|
||||||
|
saveUnlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json RobotRuntime::buildStatusUnlocked() const
|
||||||
|
{
|
||||||
|
const auto runner = mission_queue_.runnerStatus();
|
||||||
|
const auto queue = mission_queue_.list();
|
||||||
|
|
||||||
|
int pending = 0;
|
||||||
|
if (queue.is_array())
|
||||||
|
{
|
||||||
|
for (const auto& item : queue)
|
||||||
|
{
|
||||||
|
if (item.value("status", "") == "pending")
|
||||||
|
++pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string motion = state_.value("motion", "paused");
|
||||||
|
const std::string health = state_.value("health", "ok");
|
||||||
|
const std::string runner_state = runner.value("state", "idle");
|
||||||
|
|
||||||
|
std::string message = state_.value("message", kDefaultMessage);
|
||||||
|
if (health == "ok")
|
||||||
|
{
|
||||||
|
if (!runner.value("message", "").empty())
|
||||||
|
message = runner.value("message", "");
|
||||||
|
else if (runner_state == "idle" && motion == "paused" && pending == 0)
|
||||||
|
message = kDefaultMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json body = {{"motion", motion},
|
||||||
|
{"health", health},
|
||||||
|
{"message", message},
|
||||||
|
{"error", state_.contains("error") ? state_["error"] : nullptr},
|
||||||
|
{"battery_percent", state_.value("battery_percent", 54)},
|
||||||
|
{"battery_charging", state_.value("battery_charging", false)},
|
||||||
|
{"joystick_engaged", state_.value("joystick_engaged", false)},
|
||||||
|
{"joystick_speed", state_.value("joystick_speed", "fast")},
|
||||||
|
{"cmd_linear", state_.value("cmd_linear", 0.0)},
|
||||||
|
{"cmd_angular", state_.value("cmd_angular", 0.0)},
|
||||||
|
{"active_map_id", state_.contains("active_map_id") ? state_["active_map_id"] : nullptr},
|
||||||
|
{"pose", state_.contains("pose") ? state_["pose"] : nlohmann::json::object({{"x", 0.0}, {"y", 0.0}, {"yaw", 0.0}})},
|
||||||
|
{"runner", runner},
|
||||||
|
{"queue_pending", pending},
|
||||||
|
{"updated_at", state_.value("updated_at", "")}};
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json RobotRuntime::status() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
return buildStatusUnlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RobotRuntime::start(std::string& err)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (state_.value("health", "ok") == "error")
|
||||||
|
{
|
||||||
|
err = "cannot start while robot is in error state — reset error first";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state_["motion"] = "running";
|
||||||
|
state_["message"] = "Robot running";
|
||||||
|
state_["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
saveUnlocked();
|
||||||
|
|
||||||
|
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
|
||||||
|
if (runner_state == "paused")
|
||||||
|
return mission_queue_.resume(err);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RobotRuntime::pause(std::string& err)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
state_["motion"] = "paused";
|
||||||
|
state_["message"] = "Robot paused";
|
||||||
|
state_["joystick_engaged"] = false;
|
||||||
|
state_["cmd_linear"] = 0.0;
|
||||||
|
state_["cmd_angular"] = 0.0;
|
||||||
|
state_["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
saveUnlocked();
|
||||||
|
|
||||||
|
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
|
||||||
|
if (runner_state == "running" || runner_state == "paused")
|
||||||
|
return mission_queue_.pause(err);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RobotRuntime::resetError(std::string& err)
|
||||||
|
{
|
||||||
|
(void)err;
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
state_["health"] = "ok";
|
||||||
|
state_["error"] = nullptr;
|
||||||
|
state_["message"] = kDefaultMessage;
|
||||||
|
state_["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
saveUnlocked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RobotRuntime::setCmdVel(double linear, double angular, std::string& err)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (state_.value("health", "ok") == "error")
|
||||||
|
{
|
||||||
|
err = "robot is in error state";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!state_.value("joystick_engaged", false))
|
||||||
|
{
|
||||||
|
err = "joystick is not engaged";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (state_.value("motion", "paused") != "running")
|
||||||
|
{
|
||||||
|
err = "robot motion is paused — press START first";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string speed = state_.value("joystick_speed", "fast");
|
||||||
|
double scale = 1.0;
|
||||||
|
if (speed == "medium")
|
||||||
|
scale = 0.55;
|
||||||
|
else if (speed == "slow")
|
||||||
|
scale = 0.25;
|
||||||
|
|
||||||
|
linear = std::clamp(linear, -1.0, 1.0) * scale;
|
||||||
|
angular = std::clamp(angular, -1.0, 1.0) * scale;
|
||||||
|
|
||||||
|
state_["cmd_linear"] = linear;
|
||||||
|
state_["cmd_angular"] = angular;
|
||||||
|
state_["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
saveUnlocked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RobotRuntime::setJoystick(bool engaged, const std::string& speed, std::string& err)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (engaged && state_.value("health", "ok") == "error")
|
||||||
|
{
|
||||||
|
err = "cannot engage joystick while robot is in error state";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (engaged && state_.value("motion", "paused") != "running")
|
||||||
|
{
|
||||||
|
err = "start the robot before engaging the joystick";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state_["joystick_engaged"] = engaged;
|
||||||
|
if (!speed.empty())
|
||||||
|
{
|
||||||
|
if (speed != "fast" && speed != "medium" && speed != "slow")
|
||||||
|
{
|
||||||
|
err = "speed must be fast, medium, or slow";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state_["joystick_speed"] = speed;
|
||||||
|
}
|
||||||
|
if (!engaged)
|
||||||
|
{
|
||||||
|
state_["cmd_linear"] = 0.0;
|
||||||
|
state_["cmd_angular"] = 0.0;
|
||||||
|
}
|
||||||
|
state_["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
saveUnlocked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RobotRuntime::setActiveMap(const std::string& map_id, std::string& err)
|
||||||
|
{
|
||||||
|
(void)err;
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
state_["active_map_id"] = map_id;
|
||||||
|
state_["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
saveUnlocked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RobotRuntime::clearActiveMapIf(const std::string& map_id)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (state_.value("active_map_id", "") == map_id)
|
||||||
|
{
|
||||||
|
state_["active_map_id"] = nullptr;
|
||||||
|
state_["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
saveUnlocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RobotRuntime::tick()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
const bool running = state_.value("motion", "paused") == "running";
|
||||||
|
const bool joy = state_.value("joystick_engaged", false);
|
||||||
|
double battery = state_.value("battery_percent", 54.0);
|
||||||
|
|
||||||
|
if (running && (joy || std::abs(state_.value("cmd_linear", 0.0)) > 0.01))
|
||||||
|
battery = std::max(0.0, battery - 0.02);
|
||||||
|
else if (state_.value("battery_charging", false))
|
||||||
|
battery = std::min(100.0, battery + 0.05);
|
||||||
|
else if (!running)
|
||||||
|
battery = std::min(100.0, battery + 0.005);
|
||||||
|
|
||||||
|
state_["battery_percent"] = static_cast<int>(std::lround(battery));
|
||||||
|
saveUnlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
40
src/robot/robot_runtime.hpp
Normal file
40
src/robot/robot_runtime.hpp
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class Database;
|
||||||
|
class MissionQueue;
|
||||||
|
|
||||||
|
class RobotRuntime
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit RobotRuntime(Database& db, MissionQueue& mission_queue);
|
||||||
|
|
||||||
|
nlohmann::json status() const;
|
||||||
|
bool start(std::string& err);
|
||||||
|
bool pause(std::string& err);
|
||||||
|
bool resetError(std::string& err);
|
||||||
|
bool setCmdVel(double linear, double angular, std::string& err);
|
||||||
|
bool setJoystick(bool engaged, const std::string& speed, std::string& err);
|
||||||
|
bool setActiveMap(const std::string& map_id, std::string& err);
|
||||||
|
void clearActiveMapIf(const std::string& map_id);
|
||||||
|
void tick();
|
||||||
|
|
||||||
|
private:
|
||||||
|
Database& db_;
|
||||||
|
MissionQueue& mission_queue_;
|
||||||
|
mutable std::mutex mu_;
|
||||||
|
nlohmann::json state_;
|
||||||
|
|
||||||
|
void load();
|
||||||
|
void saveUnlocked() const;
|
||||||
|
void ensureDefaultsUnlocked();
|
||||||
|
nlohmann::json buildStatusUnlocked() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
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
|
||||||
314
src/server/api_media_routes.cpp
Normal file
314
src/server/api_media_routes.cpp
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
#include "server/api_server.hpp"
|
||||||
|
|
||||||
|
#include "util/file_util.hpp"
|
||||||
|
#include "util/http_util.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
||||||
|
{
|
||||||
|
svr.Get("/api/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");
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
const auto created = map_store_.create(body, err);
|
||||||
|
if (!created)
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.status = 201;
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = created->dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Put(R"(/api/maps/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
if (!map_store_.update(id, body, err))
|
||||||
|
return HttpUtil::jsonError(res, 404, err);
|
||||||
|
const auto updated = map_store_.find(id);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Delete(R"(/api/maps/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
std::string err;
|
||||||
|
if (!map_store_.remove(id, err))
|
||||||
|
return HttpUtil::jsonError(res, 404, err);
|
||||||
|
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
|
||||||
@@ -5,25 +5,18 @@
|
|||||||
|
|
||||||
namespace lm {
|
namespace lm {
|
||||||
|
|
||||||
namespace {
|
std::optional<nlohmann::json> ApiServer::enqueueMission(const nlohmann::json& request, std::string& err)
|
||||||
|
|
||||||
nlohmann::json mirError(const std::string& msg)
|
|
||||||
{
|
{
|
||||||
return nlohmann::json{{"error", msg}, {"error_code", 400}};
|
nlohmann::json payload;
|
||||||
|
if (!MissionEnqueue::buildPayload(mission_store_, request, payload, err))
|
||||||
|
return std::nullopt;
|
||||||
|
return mission_queue_.enqueue(payload, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool ApiServer::enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code)
|
bool ApiServer::enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code)
|
||||||
{
|
{
|
||||||
nlohmann::json payload;
|
|
||||||
std::string err;
|
std::string err;
|
||||||
if (!MissionEnqueue::buildPayload(mission_store_, request, payload, err))
|
const auto entry = enqueueMission(request, err);
|
||||||
{
|
|
||||||
HttpUtil::jsonError(res, 400, err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const auto entry = mission_queue_.enqueue(payload, err);
|
|
||||||
if (!entry)
|
if (!entry)
|
||||||
{
|
{
|
||||||
HttpUtil::jsonError(res, 400, err);
|
HttpUtil::jsonError(res, 400, err);
|
||||||
@@ -38,12 +31,18 @@ bool ApiServer::enqueueRequest(const nlohmann::json& request, httplib::Response&
|
|||||||
|
|
||||||
nlohmann::json ApiServer::toMirQueueEntry(const nlohmann::json& entry) const
|
nlohmann::json ApiServer::toMirQueueEntry(const nlohmann::json& entry) const
|
||||||
{
|
{
|
||||||
return nlohmann::json{{"id", entry.value("id", 0)},
|
nlohmann::json out = nlohmann::json::object();
|
||||||
{"mission_id", entry.value("mission_id", "")},
|
if (entry.contains("id"))
|
||||||
{"state", entry.value("status", "pending")},
|
out["id"] = entry["id"];
|
||||||
{"message", entry.value("mission_name", "")},
|
out["mission_id"] = entry.value("mission_id", std::string(""));
|
||||||
{"priority", entry.value("priority", 0)},
|
out["state"] = entry.contains("status") ? entry["status"] : nlohmann::json("pending");
|
||||||
{"robot_id", entry.value("robot_id", "default")}};
|
out["message"] = entry.value("mission_name", std::string(""));
|
||||||
|
if (entry.contains("priority") && entry["priority"].is_number())
|
||||||
|
out["priority"] = entry["priority"];
|
||||||
|
else
|
||||||
|
out["priority"] = 0;
|
||||||
|
out["robot_id"] = entry.value("robot_id", std::string("default"));
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ApiServer::registerMissionRoutes(httplib::Server& svr)
|
void ApiServer::registerMissionRoutes(httplib::Server& svr)
|
||||||
@@ -226,10 +225,14 @@ void ApiServer::registerMirV2Routes(httplib::Server& svr)
|
|||||||
svr.Get("/api/v2.0.0/mission_queue", [this](const httplib::Request&, httplib::Response& res) {
|
svr.Get("/api/v2.0.0/mission_queue", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
HttpUtil::addCors(res);
|
HttpUtil::addCors(res);
|
||||||
nlohmann::json out = nlohmann::json::array();
|
nlohmann::json out = nlohmann::json::array();
|
||||||
for (const auto& item : mission_queue_.list())
|
const nlohmann::json queue = mission_queue_.list();
|
||||||
|
if (queue.is_array())
|
||||||
{
|
{
|
||||||
if (item.is_object())
|
for (const auto& item : queue)
|
||||||
out.push_back(toMirQueueEntry(item));
|
{
|
||||||
|
if (item.is_object())
|
||||||
|
out.push_back(toMirQueueEntry(item));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
res.body = out.dump();
|
res.body = out.dump();
|
||||||
@@ -248,10 +251,14 @@ void ApiServer::registerMirV2Routes(httplib::Server& svr)
|
|||||||
}
|
}
|
||||||
if (!payload.contains("source"))
|
if (!payload.contains("source"))
|
||||||
payload["source"] = "rest_api_v2";
|
payload["source"] = "rest_api_v2";
|
||||||
if (!enqueueRequest(payload, res, 201))
|
std::string err;
|
||||||
return;
|
const auto entry = enqueueMission(payload, err);
|
||||||
nlohmann::json created = nlohmann::json::parse(res.body);
|
if (!entry)
|
||||||
res.body = toMirQueueEntry(created).dump();
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
res.status = 201;
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = toMirQueueEntry(*entry).dump();
|
||||||
});
|
});
|
||||||
|
|
||||||
svr.Delete("/api/v2.0.0/mission_queue", [this](const httplib::Request&, httplib::Response& res) {
|
svr.Delete("/api/v2.0.0/mission_queue", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
|||||||
108
src/server/api_robot_routes.cpp
Normal file
108
src/server/api_robot_routes.cpp
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
#include "server/api_server.hpp"
|
||||||
|
|
||||||
|
#include "util/http_util.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
void ApiServer::registerRobotRoutes(httplib::Server& svr)
|
||||||
|
{
|
||||||
|
svr.Get("/api/robot/status", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
robot_runtime_.tick();
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = robot_runtime_.status().dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/robot/start", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
std::string err;
|
||||||
|
if (!robot_runtime_.start(err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = robot_runtime_.status().dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/robot/pause", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
std::string err;
|
||||||
|
if (!robot_runtime_.pause(err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = robot_runtime_.status().dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/robot/errors/reset", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
std::string err;
|
||||||
|
if (!robot_runtime_.resetError(err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = robot_runtime_.status().dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/robot/cmd_vel", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
const double linear = body.value("linear", body.value("vx", 0.0));
|
||||||
|
const double angular = body.value("angular", body.value("omega", 0.0));
|
||||||
|
std::string err;
|
||||||
|
if (!robot_runtime_.setCmdVel(linear, angular, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = robot_runtime_.status().dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/robot/joystick", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
if (!body.contains("engaged") || !body["engaged"].is_boolean())
|
||||||
|
return HttpUtil::jsonError(res, 400, "engaged (boolean) is required");
|
||||||
|
const std::string speed = body.value("speed", "");
|
||||||
|
std::string err;
|
||||||
|
if (!robot_runtime_.setJoystick(body["engaged"].get<bool>(), speed, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = robot_runtime_.status().dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/robot/active_map", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
const std::string map_id = body.value("map_id", "");
|
||||||
|
if (map_id.empty())
|
||||||
|
return HttpUtil::jsonError(res, 400, "map_id is required");
|
||||||
|
if (!map_store_.find(map_id))
|
||||||
|
return HttpUtil::jsonError(res, 404, "map not found");
|
||||||
|
std::string err;
|
||||||
|
if (!robot_runtime_.setActiveMap(map_id, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = robot_runtime_.status().dump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
@@ -14,12 +14,22 @@ ApiServer::ApiServer(StateRepository& repo,
|
|||||||
MissionQueue& mission_queue,
|
MissionQueue& mission_queue,
|
||||||
MissionStore& mission_store,
|
MissionStore& mission_store,
|
||||||
ModbusTriggerService& modbus,
|
ModbusTriggerService& modbus,
|
||||||
MissionScheduler& scheduler)
|
MissionScheduler& scheduler,
|
||||||
|
RobotRuntime& robot_runtime,
|
||||||
|
MapStore& map_store,
|
||||||
|
SiteStore& site_store,
|
||||||
|
SoundStore& sound_store,
|
||||||
|
DashboardStore& dashboard_store)
|
||||||
: repo_(repo),
|
: repo_(repo),
|
||||||
mission_queue_(mission_queue),
|
mission_queue_(mission_queue),
|
||||||
mission_store_(mission_store),
|
mission_store_(mission_store),
|
||||||
modbus_(modbus),
|
modbus_(modbus),
|
||||||
scheduler_(scheduler)
|
scheduler_(scheduler),
|
||||||
|
robot_runtime_(robot_runtime),
|
||||||
|
map_store_(map_store),
|
||||||
|
site_store_(site_store),
|
||||||
|
sound_store_(sound_store),
|
||||||
|
dashboard_store_(dashboard_store)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,9 +536,21 @@ void ApiServer::registerRoutes(httplib::Server& svr)
|
|||||||
res.body = mission_queue_.runnerStatus().dump();
|
res.body = mission_queue_.runnerStatus().dump();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/mission_queue/cancel", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
std::string err;
|
||||||
|
if (!mission_queue_.cancel(err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = mission_queue_.runnerStatus().dump();
|
||||||
|
});
|
||||||
|
|
||||||
registerMissionRoutes(svr);
|
registerMissionRoutes(svr);
|
||||||
registerIntegrationRoutes(svr);
|
registerIntegrationRoutes(svr);
|
||||||
registerMirV2Routes(svr);
|
registerMirV2Routes(svr);
|
||||||
|
registerRobotRoutes(svr);
|
||||||
|
registerMediaRoutes(svr);
|
||||||
|
registerDashboardRoutes(svr);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace lm
|
} // namespace lm
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
#include "mission/mission_scheduler.hpp"
|
#include "mission/mission_scheduler.hpp"
|
||||||
#include "mission/mission_store.hpp"
|
#include "mission/mission_store.hpp"
|
||||||
#include "mission/modbus_trigger_service.hpp"
|
#include "mission/modbus_trigger_service.hpp"
|
||||||
|
#include "robot/robot_runtime.hpp"
|
||||||
|
#include "storage/dashboard_store.hpp"
|
||||||
|
#include "storage/map_store.hpp"
|
||||||
|
#include "storage/site_store.hpp"
|
||||||
|
#include "storage/sound_store.hpp"
|
||||||
#include "storage/state_repository.hpp"
|
#include "storage/state_repository.hpp"
|
||||||
|
|
||||||
namespace lm {
|
namespace lm {
|
||||||
@@ -14,10 +19,15 @@ class ApiServer
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
ApiServer(StateRepository& repo,
|
ApiServer(StateRepository& repo,
|
||||||
MissionQueue& mission_queue,
|
MissionQueue& mission_queue,
|
||||||
MissionStore& mission_store,
|
MissionStore& mission_store,
|
||||||
ModbusTriggerService& modbus,
|
ModbusTriggerService& modbus,
|
||||||
MissionScheduler& scheduler);
|
MissionScheduler& scheduler,
|
||||||
|
RobotRuntime& robot_runtime,
|
||||||
|
MapStore& map_store,
|
||||||
|
SiteStore& site_store,
|
||||||
|
SoundStore& sound_store,
|
||||||
|
DashboardStore& dashboard_store);
|
||||||
|
|
||||||
void registerRoutes(httplib::Server& svr);
|
void registerRoutes(httplib::Server& svr);
|
||||||
|
|
||||||
@@ -27,12 +37,21 @@ private:
|
|||||||
MissionStore& mission_store_;
|
MissionStore& mission_store_;
|
||||||
ModbusTriggerService& modbus_;
|
ModbusTriggerService& modbus_;
|
||||||
MissionScheduler& scheduler_;
|
MissionScheduler& scheduler_;
|
||||||
|
RobotRuntime& robot_runtime_;
|
||||||
|
MapStore& map_store_;
|
||||||
|
SiteStore& site_store_;
|
||||||
|
SoundStore& sound_store_;
|
||||||
|
DashboardStore& dashboard_store_;
|
||||||
|
|
||||||
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
|
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
|
||||||
|
std::optional<nlohmann::json> enqueueMission(const nlohmann::json& request, std::string& err);
|
||||||
nlohmann::json toMirQueueEntry(const nlohmann::json& entry) const;
|
nlohmann::json toMirQueueEntry(const nlohmann::json& entry) const;
|
||||||
void registerMissionRoutes(httplib::Server& svr);
|
void registerMissionRoutes(httplib::Server& svr);
|
||||||
void registerMirV2Routes(httplib::Server& svr);
|
void registerMirV2Routes(httplib::Server& svr);
|
||||||
void registerIntegrationRoutes(httplib::Server& svr);
|
void registerIntegrationRoutes(httplib::Server& svr);
|
||||||
|
void registerRobotRoutes(httplib::Server& svr);
|
||||||
|
void registerMediaRoutes(httplib::Server& svr);
|
||||||
|
void registerDashboardRoutes(httplib::Server& svr);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace lm
|
} // namespace lm
|
||||||
|
|||||||
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
|
||||||
548
src/storage/database.cpp
Normal file
548
src/storage/database.cpp
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
#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 '',
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
const 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
520
src/storage/map_store.cpp
Normal file
520
src/storage/map_store.cpp
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
#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, 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, 13) != SQLITE_NULL)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
zones = nlohmann::json::parse(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 13)));
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
zones = nlohmann::json::array();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {{"id", textOrNull(0)},
|
||||||
|
{"name", textOrNull(1)},
|
||||||
|
{"description", textOrNull(2)},
|
||||||
|
{"site_id", textOrNull(3)},
|
||||||
|
{"created_by", textOrNull(4)},
|
||||||
|
{"width", realOrNull(5)},
|
||||||
|
{"height", realOrNull(6)},
|
||||||
|
{"resolution", realOrNull(7)},
|
||||||
|
{"origin_x", realOrNull(8)},
|
||||||
|
{"origin_y", realOrNull(9)},
|
||||||
|
{"origin_yaw", realOrNull(10)},
|
||||||
|
{"image_file", textOrNull(11)},
|
||||||
|
{"yaml_file", textOrNull(12)},
|
||||||
|
{"zones", zones},
|
||||||
|
{"created_at", textOrNull(14)},
|
||||||
|
{"updated_at", textOrNull(15)}};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // 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 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, 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)",
|
||||||
|
-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 (payload.contains("width") && payload["width"].is_number())
|
||||||
|
sqlite3_bind_double(stmt, 6, payload["width"].get<double>());
|
||||||
|
else
|
||||||
|
sqlite3_bind_null(stmt, 6);
|
||||||
|
if (payload.contains("height") && payload["height"].is_number())
|
||||||
|
sqlite3_bind_double(stmt, 7, payload["height"].get<double>());
|
||||||
|
else
|
||||||
|
sqlite3_bind_null(stmt, 7);
|
||||||
|
if (payload.contains("resolution") && payload["resolution"].is_number())
|
||||||
|
sqlite3_bind_double(stmt, 8, payload["resolution"].get<double>());
|
||||||
|
else
|
||||||
|
sqlite3_bind_null(stmt, 8);
|
||||||
|
sqlite3_bind_double(stmt, 9, payload.value("origin_x", 0.0));
|
||||||
|
sqlite3_bind_double(stmt, 10, payload.value("origin_y", 0.0));
|
||||||
|
sqlite3_bind_double(stmt, 11, payload.value("origin_yaw", 0.0));
|
||||||
|
sqlite3_bind_null(stmt, 12);
|
||||||
|
sqlite3_bind_null(stmt, 13);
|
||||||
|
const std::string zones_str = zones.dump();
|
||||||
|
sqlite3_bind_text(stmt, 14, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 15, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 16, 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 (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", "created_by", "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, created_by=?5, width=?6, height=?7, "
|
||||||
|
"resolution=?8, origin_x=?9, origin_y=?10, origin_yaw=?11, zones_json=?12, updated_at=?13 "
|
||||||
|
"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);
|
||||||
|
sqlite3_bind_text(stmt, 5, merged.value("created_by", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
if (merged["width"].is_number())
|
||||||
|
sqlite3_bind_double(stmt, 6, merged["width"].get<double>());
|
||||||
|
else
|
||||||
|
sqlite3_bind_null(stmt, 6);
|
||||||
|
if (merged["height"].is_number())
|
||||||
|
sqlite3_bind_double(stmt, 7, merged["height"].get<double>());
|
||||||
|
else
|
||||||
|
sqlite3_bind_null(stmt, 7);
|
||||||
|
if (merged["resolution"].is_number())
|
||||||
|
sqlite3_bind_double(stmt, 8, merged["resolution"].get<double>());
|
||||||
|
else
|
||||||
|
sqlite3_bind_null(stmt, 8);
|
||||||
|
sqlite3_bind_double(stmt, 9, merged.value("origin_x", 0.0));
|
||||||
|
sqlite3_bind_double(stmt, 10, merged.value("origin_y", 0.0));
|
||||||
|
sqlite3_bind_double(stmt, 11, merged.value("origin_yaw", 0.0));
|
||||||
|
sqlite3_bind_text(stmt, 12, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 13, 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
143
src/util/crypto_util.cpp
Normal file
143
src/util/crypto_util.cpp
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
#include "util/crypto_util.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <random>
|
||||||
|
#include <sstream>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr std::uint32_t rotr(std::uint32_t x, std::uint32_t n)
|
||||||
|
{
|
||||||
|
return (x >> n) | (x << (32 - n));
|
||||||
|
}
|
||||||
|
|
||||||
|
void sha256Transform(std::array<std::uint32_t, 8>& state, const std::uint8_t block[64])
|
||||||
|
{
|
||||||
|
static const std::uint32_t k[64] = {
|
||||||
|
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||||
|
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||||
|
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||||
|
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||||
|
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||||
|
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||||
|
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||||
|
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
|
||||||
|
|
||||||
|
std::uint32_t w[64];
|
||||||
|
for (int i = 0; i < 16; ++i)
|
||||||
|
{
|
||||||
|
w[i] = (static_cast<std::uint32_t>(block[i * 4]) << 24) |
|
||||||
|
(static_cast<std::uint32_t>(block[i * 4 + 1]) << 16) |
|
||||||
|
(static_cast<std::uint32_t>(block[i * 4 + 2]) << 8) |
|
||||||
|
static_cast<std::uint32_t>(block[i * 4 + 3]);
|
||||||
|
}
|
||||||
|
for (int i = 16; i < 64; ++i)
|
||||||
|
{
|
||||||
|
const std::uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3);
|
||||||
|
const std::uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10);
|
||||||
|
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t a = state[0];
|
||||||
|
std::uint32_t b = state[1];
|
||||||
|
std::uint32_t c = state[2];
|
||||||
|
std::uint32_t d = state[3];
|
||||||
|
std::uint32_t e = state[4];
|
||||||
|
std::uint32_t f = state[5];
|
||||||
|
std::uint32_t g = state[6];
|
||||||
|
std::uint32_t h = state[7];
|
||||||
|
|
||||||
|
for (int i = 0; i < 64; ++i)
|
||||||
|
{
|
||||||
|
const std::uint32_t S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
||||||
|
const std::uint32_t ch = (e & f) ^ ((~e) & g);
|
||||||
|
const std::uint32_t temp1 = h + S1 + ch + k[i] + w[i];
|
||||||
|
const std::uint32_t S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
||||||
|
const std::uint32_t maj = (a & b) ^ (a & c) ^ (b & c);
|
||||||
|
const std::uint32_t temp2 = S0 + maj;
|
||||||
|
|
||||||
|
h = g;
|
||||||
|
g = f;
|
||||||
|
f = e;
|
||||||
|
e = d + temp1;
|
||||||
|
d = c;
|
||||||
|
c = b;
|
||||||
|
b = a;
|
||||||
|
a = temp1 + temp2;
|
||||||
|
}
|
||||||
|
|
||||||
|
state[0] += a;
|
||||||
|
state[1] += b;
|
||||||
|
state[2] += c;
|
||||||
|
state[3] += d;
|
||||||
|
state[4] += e;
|
||||||
|
state[5] += f;
|
||||||
|
state[6] += g;
|
||||||
|
state[7] += h;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string CryptoUtil::sha256Hex(const std::string& data)
|
||||||
|
{
|
||||||
|
std::array<std::uint32_t, 8> state = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||||
|
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
|
||||||
|
|
||||||
|
const std::uint64_t bit_len = static_cast<std::uint64_t>(data.size()) * 8;
|
||||||
|
std::vector<std::uint8_t> msg(data.begin(), data.end());
|
||||||
|
msg.push_back(0x80);
|
||||||
|
|
||||||
|
while ((msg.size() % 64) != 56)
|
||||||
|
msg.push_back(0x00);
|
||||||
|
|
||||||
|
for (int i = 7; i >= 0; --i)
|
||||||
|
msg.push_back(static_cast<std::uint8_t>((bit_len >> (i * 8)) & 0xff));
|
||||||
|
|
||||||
|
for (std::size_t offset = 0; offset < msg.size(); offset += 64)
|
||||||
|
sha256Transform(state, msg.data() + offset);
|
||||||
|
|
||||||
|
std::ostringstream oss;
|
||||||
|
for (const auto v : state)
|
||||||
|
oss << std::hex << std::setw(8) << std::setfill('0') << v;
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string CryptoUtil::randomToken(std::size_t bytes)
|
||||||
|
{
|
||||||
|
std::array<unsigned char, 64> buf{};
|
||||||
|
const std::size_t n = bytes > buf.size() ? buf.size() : bytes;
|
||||||
|
std::ifstream urandom("/dev/urandom", std::ios::binary);
|
||||||
|
if (urandom)
|
||||||
|
urandom.read(reinterpret_cast<char*>(buf.data()), static_cast<std::streamsize>(n));
|
||||||
|
else
|
||||||
|
{
|
||||||
|
std::random_device rd;
|
||||||
|
std::mt19937 gen(rd());
|
||||||
|
std::uniform_int_distribution<int> dist(0, 255);
|
||||||
|
for (std::size_t i = 0; i < n; ++i)
|
||||||
|
buf[i] = static_cast<unsigned char>(dist(gen));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::ostringstream oss;
|
||||||
|
for (std::size_t i = 0; i < n; ++i)
|
||||||
|
oss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(buf[i]);
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string CryptoUtil::hashPassword(const std::string& salt, const std::string& password)
|
||||||
|
{
|
||||||
|
return sha256Hex(salt + ":" + password);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string CryptoUtil::hashPin(const std::string& salt, const std::string& pin)
|
||||||
|
{
|
||||||
|
return sha256Hex(salt + ":pin:" + pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
16
src/util/crypto_util.hpp
Normal file
16
src/util/crypto_util.hpp
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class CryptoUtil
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static std::string sha256Hex(const std::string& data);
|
||||||
|
static std::string randomToken(std::size_t bytes = 32);
|
||||||
|
static std::string hashPassword(const std::string& salt, const std::string& password);
|
||||||
|
static std::string hashPin(const std::string& salt, const std::string& pin);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
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
|
||||||
Binary file not shown.
39
tests/fixtures/data/missions.json
vendored
Normal file
39
tests/fixtures/data/missions.json
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"widgets": []
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
"Missions"
|
||||||
|
],
|
||||||
|
"missions": [
|
||||||
|
{
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "a1",
|
||||||
|
"kind": "action",
|
||||||
|
"label": "Wait",
|
||||||
|
"params": {
|
||||||
|
"seconds": 3
|
||||||
|
},
|
||||||
|
"type": "wait"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Fixture mission for automated tests",
|
||||||
|
"group": "Missions",
|
||||||
|
"id": "testmission00001",
|
||||||
|
"name": "Test Wait",
|
||||||
|
"updated_at": "2026-06-13T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"robots": [
|
||||||
|
{
|
||||||
|
"id": "default",
|
||||||
|
"name": "Robot test",
|
||||||
|
"online": true,
|
||||||
|
"serial": "T-001"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schedules": [],
|
||||||
|
"triggers": [],
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
5
tests/fixtures/data/state.json
vendored
Normal file
5
tests/fixtures/data/state.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"active_layout_id": "",
|
||||||
|
"layouts": [],
|
||||||
|
"version": 3
|
||||||
|
}
|
||||||
2
tests/requirements.txt
Normal file
2
tests/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pytest>=7.0
|
||||||
|
requests>=2.28
|
||||||
260
tests/test_api_integration.py
Normal file
260
tests/test_api_integration.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"""Integration tests for lidar_manager_web REST API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE = os.environ.get("TEST_BASE_URL", "http://127.0.0.1:18080")
|
||||||
|
TIMEOUT = 10
|
||||||
|
PREFERRED_MISSION_ID = "testmission00001"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_mission_id(api: requests.Session) -> str:
|
||||||
|
env_id = os.environ.get("TEST_MISSION_ID", "").strip()
|
||||||
|
if env_id:
|
||||||
|
return env_id
|
||||||
|
missions = api.get(f"{BASE}/api/missions", timeout=TIMEOUT).json().get("missions", [])
|
||||||
|
ids = [m["id"] for m in missions if isinstance(m, dict) and m.get("id")]
|
||||||
|
if PREFERRED_MISSION_ID in ids:
|
||||||
|
return PREFERRED_MISSION_ID
|
||||||
|
if not ids:
|
||||||
|
pytest.fail("no missions available")
|
||||||
|
return ids[0]
|
||||||
|
|
||||||
|
|
||||||
|
def login_admin(api: requests.Session) -> None:
|
||||||
|
r = api.post(
|
||||||
|
f"{BASE}/api/auth/login",
|
||||||
|
json={"username": "Admin", "password": "admin"},
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json().get("user", {}).get("username") == "Admin"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def api():
|
||||||
|
session = requests.Session()
|
||||||
|
session.headers.update({"Content-Type": "application/json"})
|
||||||
|
deadline = time.time() + TIMEOUT
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
r = session.get(f"{BASE}/api/health", timeout=1)
|
||||||
|
if r.status_code == 200 and r.json().get("ok"):
|
||||||
|
break
|
||||||
|
except requests.RequestException:
|
||||||
|
pass
|
||||||
|
time.sleep(0.2)
|
||||||
|
else:
|
||||||
|
pytest.fail(f"Server not ready at {BASE}")
|
||||||
|
login_admin(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def mission_id(api):
|
||||||
|
return resolve_mission_id(api)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_queue(api: requests.Session) -> None:
|
||||||
|
api.delete(f"{BASE}/api/mission_queue", timeout=TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_runner_state(api: requests.Session, state: str, timeout: float = 8.0) -> dict:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
runner = api.get(f"{BASE}/api/mission_queue", timeout=TIMEOUT).json().get("runner", {})
|
||||||
|
if runner.get("state") == state:
|
||||||
|
return runner
|
||||||
|
time.sleep(0.15)
|
||||||
|
pytest.fail(f"runner did not reach state {state!r} within {timeout}s")
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_mission(api: requests.Session, mission_id: str) -> None:
|
||||||
|
r = api.post(
|
||||||
|
f"{BASE}/api/mission_queue",
|
||||||
|
json={"mission_id": mission_id},
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_health(api):
|
||||||
|
r = api.get(f"{BASE}/api/health", timeout=TIMEOUT)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_me(api):
|
||||||
|
r = api.get(f"{BASE}/api/auth/me", timeout=TIMEOUT)
|
||||||
|
assert r.status_code == 200
|
||||||
|
user = r.json().get("user", {})
|
||||||
|
assert user.get("username") == "Admin"
|
||||||
|
assert user.get("permissions", {}).get("missions") == "write"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_unauthorized_without_session():
|
||||||
|
session = requests.Session()
|
||||||
|
r = session.get(f"{BASE}/api/missions", timeout=TIMEOUT)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_user_read_only_missions():
|
||||||
|
session = requests.Session()
|
||||||
|
login = session.post(
|
||||||
|
f"{BASE}/api/auth/login",
|
||||||
|
json={"username": "User", "password": "user"},
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
listed = session.get(f"{BASE}/api/missions", timeout=TIMEOUT)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
created = session.post(
|
||||||
|
f"{BASE}/api/triggers",
|
||||||
|
json={"name": "deny-trigger", "coil_id": 1009, "mission_id": "testmission00001"},
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
assert created.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_missions_available(api, mission_id):
|
||||||
|
r = api.get(f"{BASE}/api/missions", timeout=TIMEOUT)
|
||||||
|
assert r.status_code == 200
|
||||||
|
ids = {m["id"] for m in r.json().get("missions", [])}
|
||||||
|
assert mission_id in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_mir_v2_enqueue_and_list(api, mission_id):
|
||||||
|
api.delete(f"{BASE}/api/mission_queue", timeout=TIMEOUT)
|
||||||
|
r = api.post(
|
||||||
|
f"{BASE}/api/v2.0.0/mission_queue",
|
||||||
|
json={"mission_id": mission_id, "priority": 3, "robot_id": "default"},
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
body = r.json()
|
||||||
|
assert body["mission_id"] == mission_id
|
||||||
|
assert body["priority"] == 3
|
||||||
|
|
||||||
|
listed = api.get(f"{BASE}/api/v2.0.0/mission_queue", timeout=TIMEOUT)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert any(item.get("mission_id") == mission_id for item in listed.json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_pause_continue(api, mission_id):
|
||||||
|
clear_queue(api)
|
||||||
|
enqueue_mission(api, mission_id)
|
||||||
|
wait_runner_state(api, "running", timeout=5)
|
||||||
|
|
||||||
|
r = api.post(f"{BASE}/api/mission_queue/pause", timeout=TIMEOUT)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json().get("state") == "paused"
|
||||||
|
|
||||||
|
r = api.post(f"{BASE}/api/mission_queue/continue", timeout=TIMEOUT)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json().get("state") != "paused"
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_cancel_rejects_when_idle(api):
|
||||||
|
clear_queue(api)
|
||||||
|
r = api.post(f"{BASE}/api/mission_queue/cancel", timeout=TIMEOUT)
|
||||||
|
assert r.status_code == 400
|
||||||
|
body = r.json()
|
||||||
|
assert "error" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_cancel_stops_running_mission(api, mission_id):
|
||||||
|
clear_queue(api)
|
||||||
|
enqueue_mission(api, mission_id)
|
||||||
|
wait_runner_state(api, "running", timeout=5)
|
||||||
|
|
||||||
|
r = api.post(f"{BASE}/api/mission_queue/cancel", timeout=TIMEOUT)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json().get("message")
|
||||||
|
|
||||||
|
wait_runner_state(api, "idle", timeout=8)
|
||||||
|
data = api.get(f"{BASE}/api/mission_queue", timeout=TIMEOUT).json()
|
||||||
|
assert data["runner"]["state"] == "idle"
|
||||||
|
|
||||||
|
cancelled = [item for item in data.get("queue", []) if item.get("status") == "cancelled"]
|
||||||
|
assert cancelled, "expected a cancelled queue entry"
|
||||||
|
assert cancelled[0].get("mission_id") == mission_id
|
||||||
|
log = cancelled[0].get("log") or []
|
||||||
|
assert any(entry.get("message") == "Mission hủy bởi operator" for entry in log if isinstance(entry, dict))
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_cancel_rejects_while_cancelling(api, mission_id):
|
||||||
|
clear_queue(api)
|
||||||
|
enqueue_mission(api, mission_id)
|
||||||
|
wait_runner_state(api, "running", timeout=5)
|
||||||
|
|
||||||
|
first = api.post(f"{BASE}/api/mission_queue/cancel", timeout=TIMEOUT)
|
||||||
|
assert first.status_code == 200
|
||||||
|
|
||||||
|
second = api.post(f"{BASE}/api/mission_queue/cancel", timeout=TIMEOUT)
|
||||||
|
assert second.status_code == 400
|
||||||
|
|
||||||
|
wait_runner_state(api, "idle", timeout=8)
|
||||||
|
|
||||||
|
|
||||||
|
def test_modbus_trigger_flow(api, mission_id):
|
||||||
|
trig = api.post(
|
||||||
|
f"{BASE}/api/triggers",
|
||||||
|
json={"name": "pytest-trigger", "coil_id": 1005, "mission_id": mission_id},
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
assert trig.status_code == 201
|
||||||
|
trig_id = trig.json()["id"]
|
||||||
|
|
||||||
|
before = len(api.get(f"{BASE}/api/mission_queue", timeout=TIMEOUT).json())
|
||||||
|
fire = api.post(f"{BASE}/api/modbus/coils/1005/trigger", timeout=TIMEOUT)
|
||||||
|
assert fire.status_code == 200
|
||||||
|
|
||||||
|
after = api.get(f"{BASE}/api/mission_queue", timeout=TIMEOUT).json()
|
||||||
|
assert len(after) >= before
|
||||||
|
|
||||||
|
deleted = api.delete(f"{BASE}/api/triggers/{trig_id}", timeout=TIMEOUT)
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_schedule_asap(api, mission_id):
|
||||||
|
r = api.post(
|
||||||
|
f"{BASE}/api/fleet/schedules",
|
||||||
|
json={
|
||||||
|
"name": "pytest-schedule",
|
||||||
|
"mission_id": mission_id,
|
||||||
|
"start_mode": "asap",
|
||||||
|
"priority": 0,
|
||||||
|
"robot_id": "default",
|
||||||
|
},
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
sched_id = r.json()["id"]
|
||||||
|
|
||||||
|
deleted = api.delete(f"{BASE}/api/fleet/schedules/{sched_id}", timeout=TIMEOUT)
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_lidar_crud(api):
|
||||||
|
created = api.post(
|
||||||
|
f"{BASE}/api/lidars",
|
||||||
|
json={"name": "pytest-lidar", "ip": "10.99.0.1", "port": 2112},
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
lidar_id = created.json()["id"]
|
||||||
|
|
||||||
|
updated = api.put(
|
||||||
|
f"{BASE}/api/lidars/{lidar_id}",
|
||||||
|
json={"name": "pytest-lidar", "ip": "10.99.0.2", "port": 2112},
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
|
||||||
|
deleted = api.delete(f"{BASE}/api/lidars/{lidar_id}", timeout=TIMEOUT)
|
||||||
|
assert deleted.status_code == 204
|
||||||
71
tests/test_mission_enqueue.cpp
Normal file
71
tests/test_mission_enqueue.cpp
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#include "mission/mission_enqueue.hpp"
|
||||||
|
#include "mission/mission_store.hpp"
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::filesystem::path fixtureMissionsPath()
|
||||||
|
{
|
||||||
|
return std::filesystem::path(TEST_FIXTURE_DIR) / "missions.json";
|
||||||
|
}
|
||||||
|
|
||||||
|
lm::MissionStore makeStore()
|
||||||
|
{
|
||||||
|
const auto dir = std::filesystem::temp_directory_path() / "lm_test_enqueue";
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
const auto path = dir / "missions.json";
|
||||||
|
std::filesystem::copy_file(fixtureMissionsPath(), path, std::filesystem::copy_options::overwrite_existing);
|
||||||
|
return lm::MissionStore(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(MissionEnqueue, NormalizeParametersFromMirArray)
|
||||||
|
{
|
||||||
|
const nlohmann::json params = nlohmann::json::array({{{"id", "pos"}, {"value", "A1"}},
|
||||||
|
{{"key", "speed"}, {"value", 0.5}}});
|
||||||
|
const nlohmann::json out = lm::MissionEnqueue::normalizeParameters(params);
|
||||||
|
EXPECT_TRUE(out.is_object());
|
||||||
|
EXPECT_EQ(out["pos"], "A1");
|
||||||
|
EXPECT_EQ(out["speed"], 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(MissionEnqueue, NormalizeParametersObjectPassthrough)
|
||||||
|
{
|
||||||
|
const nlohmann::json params = {{"x", 1}};
|
||||||
|
const nlohmann::json out = lm::MissionEnqueue::normalizeParameters(params);
|
||||||
|
EXPECT_EQ(out, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(MissionEnqueue, BuildPayloadFromMissionId)
|
||||||
|
{
|
||||||
|
lm::MissionStore store = makeStore();
|
||||||
|
nlohmann::json payload;
|
||||||
|
std::string err;
|
||||||
|
const nlohmann::json request = {{"mission_id", "testmission00001"}, {"priority", 3}, {"robot_id", "default"}};
|
||||||
|
ASSERT_TRUE(lm::MissionEnqueue::buildPayload(store, request, payload, err)) << err;
|
||||||
|
EXPECT_EQ(payload["mission"]["id"], "testmission00001");
|
||||||
|
EXPECT_EQ(payload["priority"], 3);
|
||||||
|
EXPECT_EQ(payload["robot_id"], "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(MissionEnqueue, BuildPayloadMissingMissionFails)
|
||||||
|
{
|
||||||
|
lm::MissionStore store = makeStore();
|
||||||
|
nlohmann::json payload;
|
||||||
|
std::string err;
|
||||||
|
const nlohmann::json request = {{"mission_id", "does_not_exist"}};
|
||||||
|
EXPECT_FALSE(lm::MissionEnqueue::buildPayload(store, request, payload, err));
|
||||||
|
EXPECT_FALSE(err.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(MissionEnqueue, BuildPayloadRequiresMissionOrId)
|
||||||
|
{
|
||||||
|
lm::MissionStore store = makeStore();
|
||||||
|
nlohmann::json payload;
|
||||||
|
std::string err;
|
||||||
|
EXPECT_FALSE(lm::MissionEnqueue::buildPayload(store, nlohmann::json::object(), payload, err));
|
||||||
|
}
|
||||||
114
tests/test_mission_store.cpp
Normal file
114
tests/test_mission_store.cpp
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#include "mission/mission_store.hpp"
|
||||||
|
#include "storage/database.hpp"
|
||||||
|
#include "util/file_util.hpp"
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
class MissionStoreTest : public ::testing::Test
|
||||||
|
{
|
||||||
|
protected:
|
||||||
|
void SetUp() override
|
||||||
|
{
|
||||||
|
static std::atomic<unsigned> seq{0};
|
||||||
|
dir_ = std::filesystem::temp_directory_path()
|
||||||
|
/ ("lm_test_" + std::to_string(getpid()) + "_"
|
||||||
|
+ std::to_string(seq.fetch_add(1)));
|
||||||
|
std::filesystem::create_directories(dir_);
|
||||||
|
db_ = std::make_unique<lm::Database>(dir_);
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(db_->init(err)) << err;
|
||||||
|
|
||||||
|
const auto fixture = std::filesystem::path(TEST_FIXTURE_DIR) / "missions.json";
|
||||||
|
const auto raw = lm::FileUtil::readBinary(fixture);
|
||||||
|
auto doc = nlohmann::json::parse(raw);
|
||||||
|
ASSERT_TRUE(db_->setDocument("missions", doc));
|
||||||
|
store_ = std::make_unique<lm::MissionStore>(*db_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TearDown() override
|
||||||
|
{
|
||||||
|
store_.reset();
|
||||||
|
db_.reset();
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::remove_all(dir_, ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::path dir_;
|
||||||
|
std::unique_ptr<lm::Database> db_;
|
||||||
|
std::unique_ptr<lm::MissionStore> store_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_F(MissionStoreTest, FindMissionFromFixture)
|
||||||
|
{
|
||||||
|
const auto mission = store_->findMission("testmission00001");
|
||||||
|
ASSERT_TRUE(mission.has_value());
|
||||||
|
EXPECT_EQ((*mission)["name"], "Test Wait");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(MissionStoreTest, AddTriggerValidCoil)
|
||||||
|
{
|
||||||
|
std::string err;
|
||||||
|
const auto trigger = store_->addTrigger(
|
||||||
|
{{"name", "PLC line 1"}, {"coil_id", 1001}, {"mission_id", "testmission00001"}}, err);
|
||||||
|
ASSERT_TRUE(trigger.has_value()) << err;
|
||||||
|
EXPECT_EQ((*trigger)["coil_id"], 1001);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(MissionStoreTest, AddTriggerRejectsInvalidCoil)
|
||||||
|
{
|
||||||
|
std::string err;
|
||||||
|
const auto trigger =
|
||||||
|
store_->addTrigger({{"name", "bad"}, {"coil_id", 999}, {"mission_id", "testmission00001"}}, err);
|
||||||
|
EXPECT_FALSE(trigger.has_value());
|
||||||
|
EXPECT_FALSE(err.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(MissionStoreTest, AddTriggerRejectsDuplicateCoil)
|
||||||
|
{
|
||||||
|
std::string err;
|
||||||
|
ASSERT_TRUE(store_->addTrigger(
|
||||||
|
{{"name", "first"}, {"coil_id", 1002}, {"mission_id", "testmission00001"}}, err)
|
||||||
|
.has_value())
|
||||||
|
<< err;
|
||||||
|
const auto dup = store_->addTrigger(
|
||||||
|
{{"name", "second"}, {"coil_id", 1002}, {"mission_id", "testmission00001"}}, err);
|
||||||
|
EXPECT_FALSE(dup.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(MissionStoreTest, DeleteTrigger)
|
||||||
|
{
|
||||||
|
std::string err;
|
||||||
|
const auto trigger = store_->addTrigger(
|
||||||
|
{{"name", "tmp"}, {"coil_id", 1003}, {"mission_id", "testmission00001"}}, err);
|
||||||
|
ASSERT_TRUE(trigger.has_value()) << err;
|
||||||
|
const std::string id = (*trigger)["id"].get<std::string>();
|
||||||
|
EXPECT_TRUE(store_->deleteTrigger(id, err)) << err;
|
||||||
|
EXPECT_FALSE(store_->findTriggerByCoil(1003).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(MissionStoreTest, AddScheduleAsap)
|
||||||
|
{
|
||||||
|
std::string err;
|
||||||
|
const auto schedule = store_->addSchedule(
|
||||||
|
{{"name", "Morning run"}, {"mission_id", "testmission00001"}, {"priority", 5}, {"start_mode", "asap"}},
|
||||||
|
err);
|
||||||
|
ASSERT_TRUE(schedule.has_value()) << err;
|
||||||
|
EXPECT_EQ((*schedule)["priority"], 5);
|
||||||
|
EXPECT_EQ((*schedule)["start_mode"], "asap");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(MissionStoreTest, AddScheduleUnknownMissionFails)
|
||||||
|
{
|
||||||
|
std::string err;
|
||||||
|
const auto schedule =
|
||||||
|
store_->addSchedule({{"name", "bad"}, {"mission_id", "missing"}, {"start_mode", "asap"}}, err);
|
||||||
|
EXPECT_FALSE(schedule.has_value());
|
||||||
|
}
|
||||||
63
tests/test_sensor_validator.cpp
Normal file
63
tests/test_sensor_validator.cpp
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#include "validation/sensor_validator.hpp"
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
TEST(SensorValidator, LidarRequiresNameIpPort)
|
||||||
|
{
|
||||||
|
std::string err;
|
||||||
|
EXPECT_FALSE(lm::SensorValidator::validateLidarPayload(nlohmann::json::object(), err));
|
||||||
|
EXPECT_FALSE(err.empty());
|
||||||
|
|
||||||
|
err.clear();
|
||||||
|
EXPECT_TRUE(lm::SensorValidator::validateLidarPayload(
|
||||||
|
{{"name", "front"}, {"ip", "192.168.1.10"}, {"port", 2112}}, err))
|
||||||
|
<< err;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SensorValidator, LidarPortRange)
|
||||||
|
{
|
||||||
|
std::string err;
|
||||||
|
EXPECT_FALSE(lm::SensorValidator::validateLidarPayload(
|
||||||
|
{{"name", "front"}, {"ip", "192.168.1.10"}, {"port", 70000}}, err));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SensorValidator, ImuRequiresFrameAndTopic)
|
||||||
|
{
|
||||||
|
std::string err;
|
||||||
|
EXPECT_FALSE(lm::SensorValidator::validateImuPayload({{"name", "imu1"}}, err));
|
||||||
|
|
||||||
|
err.clear();
|
||||||
|
EXPECT_TRUE(lm::SensorValidator::validateImuPayload(
|
||||||
|
{{"name", "imu1"}, {"frame_id", "imu_link"}, {"topic", "/imu/data"}}, err))
|
||||||
|
<< err;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SensorValidator, ImuInvalidSource)
|
||||||
|
{
|
||||||
|
std::string err;
|
||||||
|
EXPECT_FALSE(lm::SensorValidator::validateImuPayload(
|
||||||
|
{{"name", "imu1"},
|
||||||
|
{"frame_id", "imu_link"},
|
||||||
|
{"topic", "/imu/data"},
|
||||||
|
{"source", "invalid_source"}},
|
||||||
|
err));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SensorValidator, LidarTripletDuplicateDetection)
|
||||||
|
{
|
||||||
|
const nlohmann::json state = {
|
||||||
|
{"lidars",
|
||||||
|
nlohmann::json::array({{{"id", "l1"}, {"name", "front"}, {"ip", "10.0.0.1"}, {"port", 2112}}})}};
|
||||||
|
EXPECT_TRUE(lm::SensorValidator::lidarTripletExists(state, "front", "10.0.0.1", 2112));
|
||||||
|
const std::string exclude = "l1";
|
||||||
|
EXPECT_FALSE(lm::SensorValidator::lidarTripletExists(state, "front", "10.0.0.1", 2112, &exclude));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SensorValidator, ImuFrameDuplicateDetection)
|
||||||
|
{
|
||||||
|
const nlohmann::json state = {
|
||||||
|
{"imus", nlohmann::json::array({{{"id", "i1"}, {"frame_id", "base_imu"}}})}};
|
||||||
|
EXPECT_TRUE(lm::SensorValidator::imuFrameExists(state, "base_imu"));
|
||||||
|
const std::string exclude = "i1";
|
||||||
|
EXPECT_FALSE(lm::SensorValidator::imuFrameExists(state, "base_imu", &exclude));
|
||||||
|
}
|
||||||
137
www/app.js
137
www/app.js
@@ -1,14 +1,18 @@
|
|||||||
const el = (id) => document.getElementById(id);
|
const el = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
|
|
||||||
const statusEl = el("status");
|
const statusEl = el("status");
|
||||||
const listEl = el("lidarList");
|
const listEl = el("lidarList");
|
||||||
const lidarFormHintEl = el("lidarFormHint");
|
const lidarFormHintEl = el("lidarFormHint");
|
||||||
const pageTitleEl = document.querySelector(".pageTitle");
|
|
||||||
const navItemEls = Array.from(document.querySelectorAll(".navItem[data-page]"));
|
|
||||||
const pageOverviewEl = el("pageOverview");
|
const pageOverviewEl = el("pageOverview");
|
||||||
const pageConfigEl = el("pageConfig");
|
const pageConfigEl = el("pageConfig");
|
||||||
|
const pageMapsEl = el("pageMaps");
|
||||||
const pageMissionsEl = el("pageMissions");
|
const pageMissionsEl = el("pageMissions");
|
||||||
const pageIntegrationsEl = el("pageIntegrations");
|
const pageIntegrationsEl = el("pageIntegrations");
|
||||||
|
const pageSoundsEl = el("pageSounds");
|
||||||
|
const pageMonitoringEl = el("pageMonitoring");
|
||||||
|
const pageHelpEl = el("pageHelp");
|
||||||
const contentEl = document.querySelector(".content");
|
const contentEl = document.querySelector(".content");
|
||||||
const contentRightEl = el("contentRight");
|
const contentRightEl = el("contentRight");
|
||||||
const overviewBackendEl = el("overviewBackend");
|
const overviewBackendEl = el("overviewBackend");
|
||||||
@@ -121,41 +125,43 @@ const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function setActivePage(page) {
|
function setActivePage(page) {
|
||||||
const valid = ["dashboard", "config", "missions", "integrations"];
|
const valid = ["dashboard", "config", "maps", "missions", "sounds", "integrations", "monitoring", "help"];
|
||||||
let p = valid.includes(page) ? page : "config";
|
let p = valid.includes(page) ? page : "missions";
|
||||||
|
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
||||||
|
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
||||||
|
p = fallback || "dashboard";
|
||||||
|
}
|
||||||
if (page === "overview") p = "dashboard";
|
if (page === "overview") p = "dashboard";
|
||||||
navItemEls.forEach((a) => {
|
|
||||||
const on = (a.dataset.page || "") === p;
|
|
||||||
a.classList.toggle("active", on);
|
|
||||||
if (on) a.setAttribute("aria-current", "page");
|
|
||||||
else a.removeAttribute("aria-current");
|
|
||||||
});
|
|
||||||
const titles = {
|
|
||||||
dashboard: "Dashboard",
|
|
||||||
config: "Cấu Hình",
|
|
||||||
missions: "Missions",
|
|
||||||
integrations: "Tích hợp",
|
|
||||||
};
|
|
||||||
if (pageTitleEl) pageTitleEl.textContent = titles[p] || "Cấu Hình";
|
|
||||||
if (pageOverviewEl) pageOverviewEl.hidden = p !== "dashboard";
|
if (pageOverviewEl) pageOverviewEl.hidden = p !== "dashboard";
|
||||||
if (pageConfigEl) pageConfigEl.hidden = p !== "config";
|
if (pageConfigEl) pageConfigEl.hidden = p !== "config";
|
||||||
|
if (pageMapsEl) pageMapsEl.hidden = p !== "maps";
|
||||||
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
||||||
|
if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds";
|
||||||
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
||||||
|
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
||||||
|
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
|
||||||
if (configSplitterEl) configSplitterEl.hidden = p !== "config";
|
if (configSplitterEl) configSplitterEl.hidden = p !== "config";
|
||||||
if (contentRightEl) contentRightEl.hidden = p !== "config";
|
if (contentRightEl) contentRightEl.hidden = p !== "config";
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
contentEl.classList.toggle("content--dashboard", p === "dashboard");
|
contentEl.classList.toggle("content--dashboard", p === "dashboard");
|
||||||
contentEl.classList.toggle("content--config", p === "config");
|
contentEl.classList.toggle("content--config", p === "config");
|
||||||
|
contentEl.classList.toggle("content--maps", p === "maps");
|
||||||
contentEl.classList.toggle("content--missions", p === "missions");
|
contentEl.classList.toggle("content--missions", p === "missions");
|
||||||
|
contentEl.classList.toggle("content--sounds", p === "sounds");
|
||||||
contentEl.classList.toggle("content--integrations", p === "integrations");
|
contentEl.classList.toggle("content--integrations", p === "integrations");
|
||||||
|
contentEl.classList.toggle("content--monitoring", p === "monitoring");
|
||||||
|
contentEl.classList.toggle("content--help", p === "help");
|
||||||
}
|
}
|
||||||
if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config";
|
|
||||||
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
||||||
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
|
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
|
||||||
|
if (p === "maps" && window.MapsApp) window.MapsApp.onPageShow();
|
||||||
|
if (p === "sounds" && window.SoundsApp) window.SoundsApp.onPageShow();
|
||||||
|
else if (window.SoundsApp?.onPageHide) window.SoundsApp.onPageHide();
|
||||||
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
||||||
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
|
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
|
||||||
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
|
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
|
||||||
else if (window.IntegrationsApp?.onPageHide) window.IntegrationsApp.onPageHide();
|
else if (window.IntegrationsApp?.onPageHide) window.IntegrationsApp.onPageHide();
|
||||||
|
window.NavApp?.syncFromPage?.(p);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("activePage", p);
|
localStorage.setItem("activePage", p);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -164,25 +170,12 @@ function setActivePage(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initNavigation() {
|
function initNavigation() {
|
||||||
navItemEls.forEach((a) => {
|
if (window.NavApp?.init) window.NavApp.init();
|
||||||
a.addEventListener("click", (evt) => {
|
else setActivePage("missions");
|
||||||
evt.preventDefault();
|
|
||||||
setActivePage(a.dataset.page || "config");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Restore last page, default to config (màn hình chính).
|
|
||||||
let initial = "config";
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem("activePage");
|
|
||||||
if (saved === "dashboard" || saved === "overview" || saved === "config" || saved === "missions" || saved === "integrations") {
|
|
||||||
initial = saved === "overview" ? "dashboard" : saved;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
setActivePage(initial);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.LmApp = { setActivePage };
|
||||||
|
|
||||||
function setLeftPaneWidth(px) {
|
function setLeftPaneWidth(px) {
|
||||||
const v = Math.round(clamp(Number(px), 320, 720));
|
const v = Math.round(clamp(Number(px), 320, 720));
|
||||||
document.documentElement.style.setProperty("--leftPaneW", `${v}px`);
|
document.documentElement.style.setProperty("--leftPaneW", `${v}px`);
|
||||||
@@ -562,6 +555,7 @@ function setStatus(msg) {
|
|||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
...opts,
|
...opts,
|
||||||
});
|
});
|
||||||
@@ -633,7 +627,7 @@ function findDuplicateImuFrame(frameId, excludeId = null) {
|
|||||||
function clearCanvasSelection() {
|
function clearCanvasSelection() {
|
||||||
state.selectedId = null;
|
state.selectedId = null;
|
||||||
state.selectedImuId = null;
|
state.selectedImuId = null;
|
||||||
selectedText.textContent = "none";
|
selectedText.textContent = t("common.none");
|
||||||
setSelectedRelText();
|
setSelectedRelText();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1701,7 +1695,10 @@ function updateLayoutActiveHint() {
|
|||||||
if (!layoutActiveHintEl) return;
|
if (!layoutActiveHintEl) return;
|
||||||
const name = state.activeLayoutName || "—";
|
const name = state.activeLayoutName || "—";
|
||||||
const dirty = state.layoutDirty ? " • chưa lưu" : "";
|
const dirty = state.layoutDirty ? " • chưa lưu" : "";
|
||||||
layoutActiveHintEl.textContent = `Đang chỉnh: ${name}${dirty}`;
|
layoutActiveHintEl.textContent = t("config.layout.editingHint", {
|
||||||
|
name,
|
||||||
|
dirty: dirty ? t("config.layout.unsavedDirty") : "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLayoutSelect() {
|
function renderLayoutSelect() {
|
||||||
@@ -1787,7 +1784,7 @@ async function deleteActiveLayoutFromUI() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const name = state.activeLayoutName || state.activeLayoutId;
|
const name = state.activeLayoutName || state.activeLayoutId;
|
||||||
if (!window.confirm(`Xóa layout «${name}»? Hành động không hoàn tác.`)) return;
|
if (!window.confirm(t("config.layout.deleteConfirm", { name }))) return;
|
||||||
await api(`/api/layouts/${state.activeLayoutId}`, { method: "DELETE" });
|
await api(`/api/layouts/${state.activeLayoutId}`, { method: "DELETE" });
|
||||||
state.viewInitialized = false;
|
state.viewInitialized = false;
|
||||||
await loadAll();
|
await loadAll();
|
||||||
@@ -2142,7 +2139,7 @@ function setSelectedRelText() {
|
|||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
if (!state.lidars.length) {
|
if (!state.lidars.length) {
|
||||||
listEl.innerHTML = `<div class="item"><div class="itemName">Chưa có LiDAR</div><div class="itemMeta">Hãy thêm LiDAR ở form phía trên.</div></div>`;
|
listEl.innerHTML = `<div class="item"><div class="itemName">${t("config.lidar.empty")}</div><div class="itemMeta">${t("config.lidar.emptyHint")}</div></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2248,7 +2245,7 @@ function updateImuItemPoseUI(id) {
|
|||||||
function renderImuList() {
|
function renderImuList() {
|
||||||
if (!imuListEl) return;
|
if (!imuListEl) return;
|
||||||
if (!state.imus.length) {
|
if (!state.imus.length) {
|
||||||
imuListEl.innerHTML = `<div class="item"><div class="itemName">Chưa có IMU</div><div class="itemMeta">Thêm IMU ở form phía trên.</div></div>`;
|
imuListEl.innerHTML = `<div class="item"><div class="itemName">${t("config.imu.empty")}</div><div class="itemMeta">${t("config.imu.emptyHint")}</div></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3121,7 +3118,7 @@ async function loadAll() {
|
|||||||
state.selectedImuId = null;
|
state.selectedImuId = null;
|
||||||
}
|
}
|
||||||
if (!state.selectedId && !state.selectedImuId) {
|
if (!state.selectedId && !state.selectedImuId) {
|
||||||
selectedText.textContent = "none";
|
selectedText.textContent = t("common.none");
|
||||||
}
|
}
|
||||||
setSelectedRelText();
|
setSelectedRelText();
|
||||||
renderList();
|
renderList();
|
||||||
@@ -3134,7 +3131,10 @@ async function loadAll() {
|
|||||||
overviewActiveModelEl.textContent = state.layout?.robot?.model || "diff";
|
overviewActiveModelEl.textContent = state.layout?.robot?.model || "diff";
|
||||||
}
|
}
|
||||||
if (overviewActiveSensorsEl) {
|
if (overviewActiveSensorsEl) {
|
||||||
overviewActiveSensorsEl.textContent = `${state.lidars.length} LiDAR • ${state.imus.length} IMU`;
|
overviewActiveSensorsEl.textContent = t("dashboard.system.sensorCount", {
|
||||||
|
lidars: state.lidars.length,
|
||||||
|
imus: state.imus.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (!state.viewInitialized) {
|
if (!state.viewInitialized) {
|
||||||
fitViewToWorld();
|
fitViewToWorld();
|
||||||
@@ -3150,7 +3150,7 @@ async function loadAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
el("refreshBtn").addEventListener("click", async () => {
|
el("refreshBtn")?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
state.viewInitialized = false;
|
state.viewInitialized = false;
|
||||||
await loadAll();
|
await loadAll();
|
||||||
@@ -3359,7 +3359,8 @@ function initRobotModelPanelCollapse() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initLayoutManagerEvents();
|
initLayoutManagerEvents();
|
||||||
initNavigation();
|
if (window.AuthApp?.isReady()) initNavigation();
|
||||||
|
else window.addEventListener("lm:auth-ready", () => initNavigation(), { once: true });
|
||||||
initSplitPane();
|
initSplitPane();
|
||||||
initLidarForm();
|
initLidarForm();
|
||||||
initMotorWheelsEvents();
|
initMotorWheelsEvents();
|
||||||
@@ -3392,7 +3393,7 @@ window.addEventListener("keyup", (evt) => {
|
|||||||
if (evt.key === "Shift") canvasWrap.classList.remove("shift-pan");
|
if (evt.key === "Shift") canvasWrap.classList.remove("shift-pan");
|
||||||
});
|
});
|
||||||
|
|
||||||
saveLayoutBtn.addEventListener("click", async () => {
|
saveLayoutBtn?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await saveCurrentLayout();
|
await saveCurrentLayout();
|
||||||
setStatus(`Đã lưu layout «${state.activeLayoutName || ""}»`);
|
setStatus(`Đã lưu layout «${state.activeLayoutName || ""}»`);
|
||||||
@@ -3402,21 +3403,37 @@ saveLayoutBtn.addEventListener("click", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
const boot = async () => {
|
||||||
await api("/api/health");
|
try {
|
||||||
await loadMotorCatalog();
|
await api("/api/health");
|
||||||
await loadAll();
|
await loadMotorCatalog();
|
||||||
selectedText.textContent = "none";
|
await loadAll();
|
||||||
selectedRelText.textContent = "—";
|
selectedText.textContent = t("common.none");
|
||||||
setStatus("Sẵn sàng");
|
selectedRelText.textContent = "—";
|
||||||
} catch (e) {
|
setStatus(t("app.status.ready"));
|
||||||
const msg = String(e.message || e);
|
} catch (e) {
|
||||||
if (overviewBackendEl) overviewBackendEl.textContent = `Lỗi: ${msg}`;
|
const msg = String(e.message || e);
|
||||||
if (msg.includes("stack") || msg.includes("Maximum call")) {
|
if (overviewBackendEl) overviewBackendEl.textContent = t("common.error", { msg });
|
||||||
setStatus(`Lỗi JavaScript: ${msg}`);
|
if (msg.includes("stack") || msg.includes("Maximum call")) {
|
||||||
} else {
|
setStatus(`${t("app.status.jsError")}: ${msg}`);
|
||||||
setStatus(`Không kết nối được backend: ${msg}`);
|
} else {
|
||||||
|
setStatus(`${t("app.status.backendError")}: ${msg}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
if (window.AuthApp?.isReady()) await boot();
|
||||||
|
else window.AuthApp?.whenReady(() => { boot(); });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
window.addEventListener("lm:locale-change", () => {
|
||||||
|
if (typeof renderList === "function") renderList();
|
||||||
|
if (typeof renderImuList === "function") renderImuList();
|
||||||
|
if (typeof renderLayoutSelect === "function") renderLayoutSelect();
|
||||||
|
if (typeof renderLayoutSelect === "function") renderLayoutSelect();
|
||||||
|
if (typeof updateLayoutActiveHint === "function") updateLayoutActiveHint();
|
||||||
|
if (typeof renderMotorWheels === "function") renderMotorWheels();
|
||||||
|
if (typeof renderBicycleMotorWheels === "function") renderBicycleMotorWheels();
|
||||||
|
if (typeof updateOverview === "function") updateOverview();
|
||||||
|
window.I18n?.applyDOM?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
414
www/auth.js
Normal file
414
www/auth.js
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
(() => {
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const loginScreenEl = el("loginScreen");
|
||||||
|
const shellEl = document.querySelector(".shell");
|
||||||
|
const loginFormEl = el("loginForm");
|
||||||
|
const loginPanelPasswordEl = el("loginPanelPassword");
|
||||||
|
const loginPanelPinEl = el("loginPanelPin");
|
||||||
|
const loginKeypadEl = el("loginKeypad");
|
||||||
|
const loginPinHiddenEl = el("loginPin");
|
||||||
|
const loginErrorEl = el("loginError");
|
||||||
|
const loginPinErrorEl = el("loginPinError");
|
||||||
|
const loginTabPasswordEl = el("loginTabPassword");
|
||||||
|
const loginTabPinEl = el("loginTabPin");
|
||||||
|
const userMenuBtnEl = el("mirUserBtn");
|
||||||
|
const userMenuPanelEl = el("mirUserPanel");
|
||||||
|
const changePasswordDialogEl = el("changePasswordDialog");
|
||||||
|
const changePasswordFormEl = el("changePasswordForm");
|
||||||
|
const changePasswordErrorEl = el("changePasswordError");
|
||||||
|
|
||||||
|
let currentUser = null;
|
||||||
|
let ready = false;
|
||||||
|
let loginMode = "password";
|
||||||
|
let pinDigits = [];
|
||||||
|
let pinSubmitting = false;
|
||||||
|
|
||||||
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
|
|
||||||
|
async function apiJson(path, opts = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = (data && data.error) || text || res.statusText;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg, mode = loginMode) {
|
||||||
|
const target = mode === "pin" ? loginPinErrorEl : loginErrorEl;
|
||||||
|
const other = mode === "pin" ? loginErrorEl : loginPinErrorEl;
|
||||||
|
if (other) {
|
||||||
|
other.textContent = "";
|
||||||
|
other.setAttribute("hidden", "");
|
||||||
|
}
|
||||||
|
if (!target) return;
|
||||||
|
target.textContent = msg || "";
|
||||||
|
if (msg) target.removeAttribute("hidden");
|
||||||
|
else target.setAttribute("hidden", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPinCells() {
|
||||||
|
document.querySelectorAll(".loginPinCell").forEach((cell, index) => {
|
||||||
|
cell.classList.toggle("filled", index < pinDigits.length);
|
||||||
|
cell.classList.toggle("active", index === pinDigits.length && pinDigits.length < 4);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPin() {
|
||||||
|
pinDigits = [];
|
||||||
|
pinSubmitting = false;
|
||||||
|
if (loginPinHiddenEl) loginPinHiddenEl.value = "";
|
||||||
|
renderPinCells();
|
||||||
|
showError("", "pin");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPinFromKeypad() {
|
||||||
|
if (pinSubmitting || pinDigits.length !== 4) return;
|
||||||
|
pinSubmitting = true;
|
||||||
|
setLoginLoading(true);
|
||||||
|
showError("", "pin");
|
||||||
|
try {
|
||||||
|
await loginPin(pinDigits.join(""));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String(e.message || "");
|
||||||
|
if (msg.includes("invalid pin") || msg.includes("401")) {
|
||||||
|
showError(t("login.error.invalidPin"), "pin");
|
||||||
|
} else {
|
||||||
|
showError(msg || t("login.error.invalidPinShort"), "pin");
|
||||||
|
}
|
||||||
|
resetPin();
|
||||||
|
setLoginLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPinDigit(digit) {
|
||||||
|
if (pinSubmitting || pinDigits.length >= 4) return;
|
||||||
|
pinDigits.push(digit);
|
||||||
|
if (loginPinHiddenEl) loginPinHiddenEl.value = pinDigits.join("");
|
||||||
|
renderPinCells();
|
||||||
|
if (pinDigits.length === 4) submitPinFromKeypad();
|
||||||
|
}
|
||||||
|
|
||||||
|
function backspacePin() {
|
||||||
|
if (pinSubmitting) return;
|
||||||
|
pinDigits.pop();
|
||||||
|
if (loginPinHiddenEl) loginPinHiddenEl.value = pinDigits.join("");
|
||||||
|
renderPinCells();
|
||||||
|
showError("", "pin");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoginLoading(loading) {
|
||||||
|
loginScreenEl?.classList.toggle("is-loading", loading);
|
||||||
|
document.querySelectorAll(".loginSubmitLabel").forEach((label) => {
|
||||||
|
label.textContent = loading ? t("login.submitting") : t("login.submit");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoginMode(mode) {
|
||||||
|
loginMode = mode;
|
||||||
|
const pin = mode === "pin";
|
||||||
|
loginPanelPasswordEl?.toggleAttribute("hidden", pin);
|
||||||
|
loginPanelPinEl?.toggleAttribute("hidden", !pin);
|
||||||
|
loginTabPasswordEl?.classList.toggle("active", !pin);
|
||||||
|
loginTabPinEl?.classList.toggle("active", pin);
|
||||||
|
loginTabPasswordEl?.setAttribute("aria-selected", pin ? "false" : "true");
|
||||||
|
loginTabPinEl?.setAttribute("aria-selected", pin ? "true" : "false");
|
||||||
|
showError("", "password");
|
||||||
|
showError("", "pin");
|
||||||
|
if (pin) {
|
||||||
|
resetPin();
|
||||||
|
renderPinCells();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionLevel(resource) {
|
||||||
|
const perms = currentUser?.permissions || {};
|
||||||
|
return perms[resource] || "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDistributor() {
|
||||||
|
return currentUser?.group_id === "group_distributors";
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAccessPage(page) {
|
||||||
|
if (page === "config") return isDistributor();
|
||||||
|
|
||||||
|
const map = {
|
||||||
|
dashboard: "dashboard",
|
||||||
|
maps: "maps",
|
||||||
|
missions: "missions",
|
||||||
|
sounds: "integrations",
|
||||||
|
integrations: "integrations",
|
||||||
|
};
|
||||||
|
const resource = map[page];
|
||||||
|
if (!resource) return true;
|
||||||
|
return permissionLevel(resource) !== "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function canWrite(resource) {
|
||||||
|
return permissionLevel(resource) === "write";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNavPermissions() {
|
||||||
|
if (window.NavApp?.applyPermissions) {
|
||||||
|
window.NavApp.applyPermissions();
|
||||||
|
}
|
||||||
|
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-integrations", !canWrite("integrations"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUserMenu() {
|
||||||
|
if (!currentUser) return;
|
||||||
|
if (window.TopbarApp?.updateUserMenu) {
|
||||||
|
window.TopbarApp.updateUserMenu(currentUser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (userMenuBtnEl) {
|
||||||
|
const label = (currentUser.group_name || "USER").toUpperCase();
|
||||||
|
userMenuBtnEl.title = `${currentUser.display_name || currentUser.username} (${currentUser.group_name || ""})`;
|
||||||
|
const labelEl = el("mirUserLabel");
|
||||||
|
if (labelEl) labelEl.textContent = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockApp() {
|
||||||
|
setLoginLoading(false);
|
||||||
|
document.body.classList.remove("auth-logged-out");
|
||||||
|
if (loginScreenEl) {
|
||||||
|
loginScreenEl.setAttribute("hidden", "");
|
||||||
|
loginScreenEl.style.display = "none";
|
||||||
|
}
|
||||||
|
if (shellEl) {
|
||||||
|
shellEl.classList.remove("auth-locked");
|
||||||
|
shellEl.style.display = "";
|
||||||
|
}
|
||||||
|
applyNavPermissions();
|
||||||
|
ready = true;
|
||||||
|
window.dispatchEvent(new CustomEvent("lm:auth-ready", { detail: { user: currentUser } }));
|
||||||
|
updateUserMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockApp() {
|
||||||
|
ready = false;
|
||||||
|
currentUser = null;
|
||||||
|
document.body.classList.add("auth-logged-out");
|
||||||
|
window.TopbarApp?.hideJoystickOverlay?.();
|
||||||
|
if (shellEl) shellEl.classList.add("auth-locked");
|
||||||
|
if (loginScreenEl) {
|
||||||
|
loginScreenEl.removeAttribute("hidden");
|
||||||
|
loginScreenEl.style.display = "";
|
||||||
|
}
|
||||||
|
showError("", "password");
|
||||||
|
showError("", "pin");
|
||||||
|
resetPin();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryRestoreSession() {
|
||||||
|
try {
|
||||||
|
const data = await apiJson("/api/auth/me");
|
||||||
|
if (!data?.user) {
|
||||||
|
lockApp();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
currentUser = data.user;
|
||||||
|
unlockApp();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
lockApp();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginPassword(username, password) {
|
||||||
|
showError("", "password");
|
||||||
|
const data = await apiJson("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
currentUser = data.user;
|
||||||
|
unlockApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginPin(pin) {
|
||||||
|
showError("", "pin");
|
||||||
|
const data = await apiJson("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ pin }),
|
||||||
|
});
|
||||||
|
currentUser = data.user;
|
||||||
|
unlockApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await window.TopbarApp?.disengageJoystick?.();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
window.TopbarApp?.hideJoystickOverlay?.();
|
||||||
|
try {
|
||||||
|
await apiJson("/api/auth/logout", { method: "POST", body: "{}" });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
currentUser = null;
|
||||||
|
ready = false;
|
||||||
|
lockApp();
|
||||||
|
userMenuPanelEl?.setAttribute("hidden", "");
|
||||||
|
window.dispatchEvent(new Event("lm:auth-logout"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
const display_name = el("mirProfileDisplayName")?.value?.trim() || "";
|
||||||
|
if (!display_name) throw new Error(t("auth.profile.displayNameRequired"));
|
||||||
|
const data = await apiJson("/api/auth/profile", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ display_name }),
|
||||||
|
});
|
||||||
|
currentUser = data.user;
|
||||||
|
updateUserMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
loginTabPasswordEl?.addEventListener("click", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setLoginMode("password");
|
||||||
|
});
|
||||||
|
loginTabPinEl?.addEventListener("click", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setLoginMode("pin");
|
||||||
|
});
|
||||||
|
|
||||||
|
loginFormEl?.addEventListener("submit", async (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const username = el("loginUsername")?.value?.trim() || "";
|
||||||
|
const password = el("loginPasswordInput")?.value || "";
|
||||||
|
if (!username || !password) {
|
||||||
|
showError(t("login.error.missingCredentials"), "password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoginLoading(true);
|
||||||
|
showError("", "password");
|
||||||
|
try {
|
||||||
|
await loginPassword(username, password);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String(e.message || "");
|
||||||
|
if (msg.includes("credentials") || msg.includes("401")) {
|
||||||
|
showError(t("login.error.badCredentials"), "password");
|
||||||
|
} else if (msg.includes("fetch") || msg.includes("Failed")) {
|
||||||
|
showError(t("login.error.serverUnreachable"), "password");
|
||||||
|
} else {
|
||||||
|
showError(msg || t("login.error.failed"), "password");
|
||||||
|
}
|
||||||
|
setLoginLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loginKeypadEl?.addEventListener("click", (evt) => {
|
||||||
|
const btn = evt.target.closest("[data-key]");
|
||||||
|
if (!btn || pinSubmitting) return;
|
||||||
|
const key = btn.getAttribute("data-key");
|
||||||
|
if (key === "back") {
|
||||||
|
backspacePin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/^[0-9]$/.test(key)) appendPinDigit(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (evt) => {
|
||||||
|
if (loginMode !== "pin" || pinSubmitting) return;
|
||||||
|
if (/^[0-9]$/.test(evt.key)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
appendPinDigit(evt.key);
|
||||||
|
} else if (evt.key === "Backspace") {
|
||||||
|
evt.preventDefault();
|
||||||
|
backspacePin();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
el("mirUserSignOutBtn")?.addEventListener("click", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
logout();
|
||||||
|
});
|
||||||
|
|
||||||
|
el("mirUserChangePasswordBtn")?.addEventListener("click", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
userMenuPanelEl?.setAttribute("hidden", "");
|
||||||
|
changePasswordErrorEl && (changePasswordErrorEl.textContent = "");
|
||||||
|
changePasswordDialogEl?.showModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
el("mirProfileSaveBtn")?.addEventListener("click", async (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
try {
|
||||||
|
await saveProfile();
|
||||||
|
userMenuPanelEl?.setAttribute("hidden", "");
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || t("auth.profile.saveFailed"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
changePasswordFormEl?.addEventListener("submit", async (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const current = el("changePasswordCurrent")?.value || "";
|
||||||
|
const next = el("changePasswordNew")?.value || "";
|
||||||
|
const confirm = el("changePasswordConfirm")?.value || "";
|
||||||
|
if (next !== confirm) {
|
||||||
|
if (changePasswordErrorEl) changePasswordErrorEl.textContent = t("auth.changePassword.mismatch");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiJson("/api/auth/password", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ current_password: current, new_password: next }),
|
||||||
|
});
|
||||||
|
changePasswordDialogEl?.close();
|
||||||
|
changePasswordFormEl.reset();
|
||||||
|
} catch (e) {
|
||||||
|
if (changePasswordErrorEl) changePasswordErrorEl.textContent = e.message || t("auth.changePassword.failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.AuthApp = {
|
||||||
|
isReady: () => ready,
|
||||||
|
getUser: () => currentUser,
|
||||||
|
canAccessPage,
|
||||||
|
canWrite,
|
||||||
|
logout,
|
||||||
|
whenReady(fn) {
|
||||||
|
if (ready) fn(currentUser);
|
||||||
|
else window.addEventListener("lm:auth-ready", () => fn(currentUser), { once: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
window.addEventListener("lm:locale-change", () => {
|
||||||
|
const loading = loginScreenEl?.classList.contains("is-loading");
|
||||||
|
setLoginLoading(loading);
|
||||||
|
});
|
||||||
|
setLoginMode("password");
|
||||||
|
shellEl?.classList.add("auth-locked");
|
||||||
|
if (window.location.search) {
|
||||||
|
try {
|
||||||
|
history.replaceState({}, "", window.location.pathname);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tryRestoreSession();
|
||||||
|
})();
|
||||||
1361
www/dashboard.js
1361
www/dashboard.js
File diff suppressed because it is too large
Load Diff
1329
www/i18n.js
Normal file
1329
www/i18n.js
Normal file
File diff suppressed because it is too large
Load Diff
1512
www/index.html
1512
www/index.html
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
const COIL_MAX = 2000;
|
const COIL_MAX = 2000;
|
||||||
|
|
||||||
const el = (id) => document.getElementById(id);
|
const el = (id) => document.getElementById(id);
|
||||||
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
const triggerListEl = el("integrationTriggerList");
|
const triggerListEl = el("integrationTriggerList");
|
||||||
const triggerEmptyEl = el("integrationTriggerEmpty");
|
const triggerEmptyEl = el("integrationTriggerEmpty");
|
||||||
const coilGridEl = el("integrationCoilGrid");
|
const coilGridEl = el("integrationCoilGrid");
|
||||||
@@ -40,7 +41,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function apiJson(url, opts = {}) {
|
async function apiJson(url, opts = {}) {
|
||||||
const res = await fetch(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();
|
const text = await res.text();
|
||||||
let data = null;
|
let data = null;
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +73,7 @@
|
|||||||
const data = await apiJson("/api/fleet/robots");
|
const data = await apiJson("/api/fleet/robots");
|
||||||
store.robots = Array.isArray(data) ? data : [];
|
store.robots = Array.isArray(data) ? data : [];
|
||||||
} catch {
|
} catch {
|
||||||
store.robots = [{ id: "default", name: "Robot chính" }];
|
store.robots = [{ id: "default", name: t("integrations.defaultRobot") }];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +97,7 @@
|
|||||||
if (!store.missions.length) {
|
if (!store.missions.length) {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
opt.value = "";
|
opt.value = "";
|
||||||
opt.textContent = "— Chưa có mission —";
|
opt.textContent = t("integrations.noMissions");
|
||||||
selectEl.appendChild(opt);
|
selectEl.appendChild(opt);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -119,7 +123,7 @@
|
|||||||
if (!store.robots.length) {
|
if (!store.robots.length) {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
opt.value = "default";
|
opt.value = "default";
|
||||||
opt.textContent = "Robot chính";
|
opt.textContent = t("integrations.defaultRobot");
|
||||||
opt.selected = selected === "default";
|
opt.selected = selected === "default";
|
||||||
selectEl.appendChild(opt);
|
selectEl.appendChild(opt);
|
||||||
}
|
}
|
||||||
@@ -130,24 +134,24 @@
|
|||||||
triggerListEl.innerHTML = "";
|
triggerListEl.innerHTML = "";
|
||||||
if (triggerEmptyEl) triggerEmptyEl.hidden = store.triggers.length > 0;
|
if (triggerEmptyEl) triggerEmptyEl.hidden = store.triggers.length > 0;
|
||||||
|
|
||||||
store.triggers.forEach((t) => {
|
store.triggers.forEach((trigger) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "missionListItem integrationRow";
|
row.className = "missionListItem integrationRow";
|
||||||
const coil = t.coil_id;
|
const coil = trigger.coil_id;
|
||||||
const on = store.coils[String(coil)] === true;
|
const on = store.coils[String(coil)] === true;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div>
|
<div>
|
||||||
<div class="missionListItemTitle">${escapeHtml(t.name)}</div>
|
<div class="missionListItemTitle">${escapeHtml(trigger.name)}</div>
|
||||||
<div class="missionListItemMeta">
|
<div class="missionListItemMeta">
|
||||||
Coil <span class="mono">${coil}</span>
|
Coil <span class="mono">${coil}</span>
|
||||||
→ ${escapeHtml(missionName(t.mission_id))}
|
→ ${escapeHtml(missionName(trigger.mission_id))}
|
||||||
· ${t.enabled === false ? "Tắt" : "Bật"}
|
· ${trigger.enabled === false ? t("common.disabled") : t("common.enabled")}
|
||||||
· coil hiện tại: <span class="mono">${on ? "ON" : "OFF"}</span>
|
· ${t("integrations.coilState", { state: on ? "ON" : "OFF" })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="missionListItemActions">
|
<div class="missionListItemActions">
|
||||||
<button type="button" class="btn subtle" data-fire-coil="${coil}">Kích hoạt</button>
|
<button type="button" class="btn subtle" data-fire-coil="${coil}">${t("integrations.fireTrigger")}</button>
|
||||||
<button type="button" class="btn subtle danger" data-delete-trigger="${escapeHtml(t.id)}">Xóa</button>
|
<button type="button" class="btn subtle danger" data-delete-trigger="${escapeHtml(trigger.id)}">${t("common.delete")}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
triggerListEl.appendChild(row);
|
triggerListEl.appendChild(row);
|
||||||
});
|
});
|
||||||
@@ -157,10 +161,10 @@
|
|||||||
if (!coilGridEl) return;
|
if (!coilGridEl) return;
|
||||||
const assigned = new Map(store.triggers.map((t) => [t.coil_id, t]));
|
const assigned = new Map(store.triggers.map((t) => [t.coil_id, t]));
|
||||||
const chips = [];
|
const chips = [];
|
||||||
assigned.forEach((t, coilId) => {
|
assigned.forEach((trigger, coilId) => {
|
||||||
const on = store.coils[String(coilId)] === true;
|
const on = store.coils[String(coilId)] === true;
|
||||||
chips.push(
|
chips.push(
|
||||||
`<button type="button" class="integrationCoilChip${on ? " on" : ""}" data-fire-coil="${coilId}" title="${escapeHtml(t.name)}">
|
`<button type="button" class="integrationCoilChip${on ? " on" : ""}" data-fire-coil="${coilId}" title="${escapeHtml(trigger.name)}">
|
||||||
${coilId}
|
${coilId}
|
||||||
</button>`
|
</button>`
|
||||||
);
|
);
|
||||||
@@ -168,11 +172,11 @@
|
|||||||
coilGridEl.innerHTML =
|
coilGridEl.innerHTML =
|
||||||
chips.length > 0
|
chips.length > 0
|
||||||
? chips.join("")
|
? chips.join("")
|
||||||
: `<span class="mutedNote">Chưa gán coil. Thêm trigger bên trên (1001–2000).</span>`;
|
: `<span class="mutedNote">${t("integrations.coilsEmpty")}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatScheduleTime(s) {
|
function formatScheduleTime(s) {
|
||||||
if (!s.start_at) return s.start_mode === "scheduled" ? "—" : "Ngay (asap)";
|
if (!s.start_at) return s.start_mode === "scheduled" ? "—" : t("integrations.dialog.schedule.asap");
|
||||||
try {
|
try {
|
||||||
return new Date(s.start_at).toLocaleString("vi-VN");
|
return new Date(s.start_at).toLocaleString("vi-VN");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -201,7 +205,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="missionListItemActions">
|
<div class="missionListItemActions">
|
||||||
<button type="button" class="btn subtle" data-run-schedule="${escapeHtml(s.id)}">Chạy ngay</button>
|
<button type="button" class="btn subtle" data-run-schedule="${escapeHtml(s.id)}">${t("integrations.schedule.runNow")}</button>
|
||||||
<button type="button" class="btn subtle danger" data-delete-schedule="${escapeHtml(s.id)}">Xóa</button>
|
<button type="button" class="btn subtle danger" data-delete-schedule="${escapeHtml(s.id)}">Xóa</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
scheduleListEl.appendChild(row);
|
scheduleListEl.appendChild(row);
|
||||||
@@ -313,7 +317,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteTrigger(id) {
|
async function deleteTrigger(id) {
|
||||||
if (!confirm("Xóa trigger Modbus này?")) return;
|
if (!confirm(t("integrations.confirm.deleteTrigger"))) return;
|
||||||
try {
|
try {
|
||||||
await apiJson(`/api/triggers/${id}`, { method: "DELETE" });
|
await apiJson(`/api/triggers/${id}`, { method: "DELETE" });
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
@@ -323,7 +327,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSchedule(id) {
|
async function deleteSchedule(id) {
|
||||||
if (!confirm("Xóa lịch fleet này?")) return;
|
if (!confirm(t("integrations.confirm.deleteSchedule"))) return;
|
||||||
try {
|
try {
|
||||||
await apiJson(`/api/fleet/schedules/${id}`, { method: "DELETE" });
|
await apiJson(`/api/fleet/schedules/${id}`, { method: "DELETE" });
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
@@ -440,5 +444,15 @@
|
|||||||
refreshAll,
|
refreshAll,
|
||||||
};
|
};
|
||||||
|
|
||||||
init();
|
function boot() {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
window.addEventListener("lm:locale-change", () => {
|
||||||
|
renderTriggers();
|
||||||
|
renderCoilGrid();
|
||||||
|
renderSchedules();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window.AuthApp?.isReady()) boot();
|
||||||
|
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||||
})();
|
})();
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
2454
www/map-editor.js
Normal file
2454
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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user