Compare commits

..

28 Commits

Author SHA1 Message Date
d70a4e45af Bỏ kiểu bo tròn
Some checks are pending
Test / test (push) Waiting to run
2026-06-21 10:04:12 +02:00
199f8c0537 4.7.2 delete maps
Some checks failed
Test / test (push) Has been cancelled
2026-06-21 09:18:14 +02:00
7a850937b0 fix origin
Some checks failed
Test / test (push) Has been cancelled
2026-06-21 09:09:19 +02:00
064c9b5758 update draw line and shape
Some checks failed
Test / test (push) Has been cancelled
2026-06-21 06:31:18 +02:00
365a15c32a update full objects type
Some checks failed
Test / test (push) Has been cancelled
2026-06-20 11:43:48 +02:00
90e8e9d252 Xong phần map viewer
Some checks failed
Test / test (push) Has been cancelled
2026-06-20 09:18:19 +02:00
819323f8c8 add function map viewer
Some checks failed
Test / test (push) Has been cancelled
2026-06-20 10:53:49 +07:00
a6cf06d7eb Add phần create map by upload
Some checks failed
Test / test (push) Has been cancelled
2026-06-19 11:52:21 +07:00
098e1b2b69 Chuyển lưu trữ dữ liệu sang data base
Some checks failed
Test / test (push) Has been cancelled
2026-06-17 11:16:30 +07:00
4054d81aaf add function create dashboard
Some checks failed
Test / test (push) Has been cancelled
2026-06-17 10:19:14 +07:00
a2e87aeb29 Add function Language
Some checks failed
Test / test (push) Has been cancelled
2026-06-16 16:44:04 +07:00
1156e1ab29 add top bar 2026-06-16 11:17:28 +07:00
9aee5f4100 update function login
Some checks failed
Test / test (push) Has been cancelled
2026-06-16 09:57:55 +07:00
6fa15b69e7 Tạo tài liệu 2026-06-15 12:10:51 +07:00
4b372100eb update mission cancel
Some checks failed
Test / test (push) Has been cancelled
2026-06-15 10:30:00 +07:00
6cc51a35c4 ignore data folder 2026-06-15 09:36:45 +07:00
1716351016 fix bug kéo thả của loop
Some checks failed
Test / test (push) Has been cancelled
2026-06-13 14:20:23 +07:00
9776e29d7d final TESTING
Some checks failed
Test / test (push) Has been cancelled
2026-06-13 14:11:42 +07:00
c05b1d5f5c Clean and Test làn 3
Some checks failed
Test / test (push) Has been cancelled
2026-06-13 14:04:56 +07:00
fbc0c11be2 Test lần 2
Some checks failed
Test / test (push) Has been cancelled
2026-06-13 13:58:38 +07:00
d6f22132ce Test lần 1 oke
Some checks failed
Test / test (push) Has been cancelled
2026-06-13 13:49:42 +07:00
695a942a5d create TESTING 2026-06-13 13:46:53 +07:00
1a8bddb037 API mission 2026-06-13 13:35:00 +07:00
6f6d925fdd chức năng dashboard 2026-06-13 13:20:57 +07:00
c116b30bea excuting misstion from queue 2026-06-13 12:21:29 +07:00
7c505e919c save mission 2026-06-13 12:06:48 +07:00
10f4c36c23 create mission 2026-06-13 11:38:02 +07:00
853acefac1 layout source from main 2026-06-13 10:49:41 +07:00
131 changed files with 29843 additions and 2264 deletions

33
.github/workflows/test.yml vendored Normal file
View 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
View File

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

View File

@@ -27,11 +27,45 @@ 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/util/file_util.cpp
src/util/string_util.cpp
src/util/id_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_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/validation/sensor_validator.cpp
src/server/static_file_server.cpp
src/server/api_server.cpp
src/mission/mission_queue.cpp
src/mission/mission_store.cpp
src/mission/mission_enqueue.cpp
src/mission/modbus_trigger_service.cpp
src/mission/mission_scheduler.cpp
src/robot/robot_runtime.cpp
src/server/api_mission_routes.cpp
src/server/api_robot_routes.cpp
src/server/api_media_routes.cpp
src/server/api_dashboard_routes.cpp
) )
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads) target_link_libraries(lidar_manager_web PRIVATE Threads::Threads SQLite::SQLite3)
target_include_directories(lidar_manager_web PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/src"
)
target_include_directories(lidar_manager_web SYSTEM PRIVATE target_include_directories(lidar_manager_web SYSTEM PRIVATE
"${cpp_httplib_SOURCE_DIR}" "${cpp_httplib_SOURCE_DIR}"
@@ -41,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()

View File

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

Binary file not shown.

BIN
RBS.db-shm Normal file

Binary file not shown.

BIN
RBS.db-wal Normal file

Binary file not shown.

View File

@@ -1,22 +1,24 @@
# LiDAR Manager Web (Test3) # Robot App Web (RBS)
Chức năng: Chức năng:
- Đăng ký danh sách cảm biến LiDAR (tên, ip, port) - Đăng ký danh sách cảm biến LiDAR (tên, ip, port)
- Đăng ký IMU (tên, frame_id, topic, nguồn) và pose trên robot - Đăng ký IMU (tên, frame_id, topic, nguồn) và pose trên robot
- Kéo thả icon LiDAR/IMU trên canvas để set vị trí (robot frame) - Kéo thả icon LiDAR/IMU trên canvas để set vị trí (robot frame)
- Nhiều layout — mỗi layout lưu tại `data/models/{id}.json`; catalog trong `data/state.json` - Nhiều layout — mỗi layout lưu profile trong SQLite (`layout_profiles`); catalog trong document `state`
- Database SQLite: `data/RBS.db` (WAL mode). Thư mục media: `data/maps/`, `data/sounds/`, `data/recordings/`
## Build ## Build
```bash ```bash
cd /home/robotics/RD/Test3 cd /home/robotics/RD/RBS
# Ubuntu/Debian: sudo apt install libsqlite3-dev
cmake -S . -B build cmake -S . -B build
cmake --build build -j cmake --build build -j
``` ```
## Run ## Run
Chạy mặc định port 8080, phục vụ static từ `www/`, dữ liệu `data/state.json`: Chạy mặc định port 8080, phục vụ static từ `www/`, dữ liệu SQLite tại `data/RBS.db`:
```bash ```bash
./build/lidar_manager_web ./build/lidar_manager_web
@@ -25,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`.

View File

@@ -0,0 +1 @@
---

View 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

File diff suppressed because one or more lines are too long

BIN
data/Denso_1/Denso_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
data/Denso_1/Denso_1.xloc Normal file

Binary file not shown.

View File

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

0
data/maps/.gitkeep Normal file
View File

View File

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

View File

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

0
data/recordings/.gitkeep Normal file
View File

0
data/sounds/.gitkeep Normal file
View File

View File

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

View File

@@ -1,7 +1,9 @@
name: rbs
services: services:
lidar-manager: lidar-manager:
build: . build: .
image: lidar-manager-web:test3 image: lidar-manager-web:RBS
container_name: lidar-manager-limited container_name: lidar-manager-limited
ports: ports:
- "8080:8080" - "8080:8080"

BIN
docs/Reference guide.pdf Normal file

Binary file not shown.

446
docs/Reference_guide.md Normal file
View 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 19, 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 **10012000**)
- 9.4. Action commands (coil **16**)
---
## 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.12)
- 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 10012000)
Rising edge coil → enqueue mission đã gắn `mission_id`.
### Action commands (coil 16)
| 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 10012000 | **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*.

Binary file not shown.

43
scripts/README.md Normal file
View 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
View 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

View File

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

View File

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

View File

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

14
src/app/app_state.hpp Normal file
View File

@@ -0,0 +1,14 @@
#pragma once
#include <filesystem>
#include <nlohmann/json.hpp>
namespace lm {
struct AppState
{
std::filesystem::path data_path;
nlohmann::json state;
};
} // namespace lm

View File

@@ -0,0 +1,118 @@
#include "app/lidar_manager_app.hpp"
#include "auth/auth_service.hpp"
#include "mission/mission_enqueue.hpp"
#include "mission/mission_queue.hpp"
#include "mission/mission_scheduler.hpp"
#include "mission/mission_store.hpp"
#include "mission/modbus_trigger_service.hpp"
#include "robot/robot_runtime.hpp"
#include "server/api_server.hpp"
#include "server/static_file_server.hpp"
#include "storage/dashboard_store.hpp"
#include "storage/database.hpp"
#include "storage/map_store.hpp"
#include "storage/site_store.hpp"
#include "storage/sound_store.hpp"
#include "storage/state_repository.hpp"
#include <httplib.h>
#include <cstdio>
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,
std::filesystem::path www_root,
std::filesystem::path data_path)
: port_(port), www_root_(std::move(www_root)), data_path_(std::move(data_path))
{
}
int LidarManagerApp::run()
{
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();
MissionQueue mission_queue(database);
MissionStore mission_store(database);
RobotRuntime robot_runtime(database, mission_queue);
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 {
nlohmann::json payload;
if (!MissionEnqueue::buildPayload(mission_store, request, payload, err))
return false;
return static_cast<bool>(mission_queue.enqueue(payload, err));
};
ModbusTriggerService modbus(mission_store, enqueue_fn, 5502);
MissionScheduler scheduler(mission_store, enqueue_fn);
AuthService auth(database);
httplib::Server svr;
svr.set_pre_routing_handler([&auth](const httplib::Request& req, httplib::Response& res) {
return auth.preRoute(req, res);
});
ApiServer api(repo,
mission_queue,
mission_store,
modbus,
scheduler,
robot_runtime,
map_store,
site_store,
sound_store,
dashboard_store);
api.registerRoutes(svr);
auth.registerRoutes(svr);
StaticFileServer::mount(svr, www_root_);
std::fprintf(stderr,
"lidar_manager_web listening on http://0.0.0.0:%d (www=%s, db=%s, maps=%s, sounds=%s)\n",
port_,
www_root_.string().c_str(),
database.dbPath().string().c_str(),
database.mapsDir().string().c_str(),
database.soundsDir().string().c_str());
std::fprintf(stderr, "MiR REST API: http://0.0.0.0:%d/api/v2.0.0/mission_queue\n", port_);
std::fprintf(stderr, "Modbus TCP triggers: port 5502 (coils 1001-2000)\n");
svr.listen("0.0.0.0", port_);
return 0;
}
} // namespace lm

View File

@@ -0,0 +1,20 @@
#pragma once
#include <filesystem>
namespace lm {
class LidarManagerApp
{
public:
LidarManagerApp(int port, std::filesystem::path www_root, std::filesystem::path data_path);
int run();
private:
int port_;
std::filesystem::path www_root_;
std::filesystem::path data_path_;
};
} // namespace lm

901
src/auth/auth_service.cpp Normal file
View File

@@ -0,0 +1,901 @@
#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_);
}
bool AuthService::canDeleteMap(const nlohmann::json& map, const AuthSession& session)
{
if (map.contains("created_by_group") && map["created_by_group"].is_string())
{
const std::string map_group = map["created_by_group"].get<std::string>();
if (!map_group.empty())
return map_group == session.group_id;
}
return true;
}
std::string AuthService::extractToken(const httplib::Request& req) const
{
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

85
src/auth/auth_service.hpp Normal file
View File

@@ -0,0 +1,85 @@
#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);
static const AuthSession* activeSession() { return tls_session_; }
static bool canDeleteMap(const nlohmann::json& map, const AuthSession& session);
std::optional<nlohmann::json> loginPassword(const std::string& username,
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

View File

@@ -0,0 +1,105 @@
#include "domain/layout_profile.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
namespace lm {
nlohmann::json LayoutProfile::make(const std::string& name,
const nlohmann::json& layout,
const nlohmann::json& lidars,
const nlohmann::json& imus)
{
const std::string ts = IdUtil::nowIso8601();
return nlohmann::json{{"id", IdUtil::newId()},
{"name", name},
{"created_at", ts},
{"updated_at", ts},
{"layout", layout},
{"lidars", lidars},
{"imus", imus}};
}
void LayoutProfile::touch(nlohmann::json& profile)
{
profile["updated_at"] = IdUtil::nowIso8601();
}
std::string LayoutProfile::modelFromLayout(const nlohmann::json& layout)
{
if (layout.is_object() && layout.contains("robot") && layout["robot"].is_object() &&
layout["robot"].contains("model") && layout["robot"]["model"].is_string())
return layout["robot"]["model"].get<std::string>();
return "diff";
}
nlohmann::json LayoutProfile::catalogEntryFromProfile(const nlohmann::json& profile)
{
const nlohmann::json& layout = profile.contains("layout") ? profile["layout"] : nlohmann::json::object();
const size_t lidar_count =
profile.contains("lidars") && profile["lidars"].is_array() ? profile["lidars"].size() : 0;
const size_t imu_count =
profile.contains("imus") && profile["imus"].is_array() ? profile["imus"].size() : 0;
return nlohmann::json{{"id", profile["id"]},
{"name", profile["name"]},
{"model", modelFromLayout(layout)},
{"created_at", profile.value("created_at", "")},
{"updated_at", profile.value("updated_at", "")},
{"lidar_count", lidar_count},
{"imu_count", imu_count}};
}
std::optional<size_t> LayoutProfile::findIndex(const nlohmann::json& state, const std::string& id)
{
if (!state.contains("layouts") || !state["layouts"].is_array())
return std::nullopt;
const auto& layouts = state["layouts"];
for (size_t i = 0; i < layouts.size(); i++)
{
const auto& p = layouts[i];
if (p.is_object() && p.contains("id") && p["id"].is_string() && p["id"].get<std::string>() == id)
return i;
}
return std::nullopt;
}
std::optional<size_t> LayoutProfile::findActiveIndex(nlohmann::json& state)
{
if (!state.contains("active_layout_id") || !state["active_layout_id"].is_string())
return std::nullopt;
return findIndex(state, state["active_layout_id"].get<std::string>());
}
bool LayoutProfile::nameExists(const nlohmann::json& state,
const std::string& name,
const std::string* exclude_id)
{
if (!state.contains("layouts") || !state["layouts"].is_array())
return false;
const std::string n = StringUtil::trimCopy(name);
for (const auto& p : state["layouts"])
{
if (!p.is_object() || !p.contains("name"))
continue;
if (exclude_id && p.contains("id") && p["id"].get<std::string>() == *exclude_id)
continue;
if (StringUtil::trimCopy(p["name"].get<std::string>()) == n)
return true;
}
return false;
}
nlohmann::json LayoutProfile::buildCatalog(const nlohmann::json& state)
{
nlohmann::json catalog = nlohmann::json::array();
if (!state.contains("layouts") || !state["layouts"].is_array())
return catalog;
for (const auto& p : state["layouts"])
{
if (!p.is_object() || !p.contains("id") || !p.contains("name"))
continue;
catalog.push_back(p);
}
return catalog;
}
} // namespace lm

View File

@@ -0,0 +1,32 @@
#pragma once
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
namespace lm {
class LayoutProfile
{
public:
static nlohmann::json make(const std::string& name,
const nlohmann::json& layout,
const nlohmann::json& lidars,
const nlohmann::json& imus = nlohmann::json::array());
static void touch(nlohmann::json& profile);
static std::string modelFromLayout(const nlohmann::json& layout);
static nlohmann::json catalogEntryFromProfile(const nlohmann::json& profile);
static std::optional<size_t> findIndex(const nlohmann::json& state, const std::string& id);
static std::optional<size_t> findActiveIndex(nlohmann::json& state);
static bool nameExists(const nlohmann::json& state,
const std::string& name,
const std::string* exclude_id = nullptr);
static nlohmann::json buildCatalog(const nlohmann::json& state);
};
} // namespace lm

View File

@@ -0,0 +1,310 @@
#include "domain/layout_schema.hpp"
namespace lm {
nlohmann::json LayoutSchema::defaultBicycleWheels()
{
return nlohmann::json::array(
{nlohmann::json{{"id", "rear"},
{"role", "drive"},
{"x_m", 0},
{"y_m", 0},
{"joint_name", "rear_wheel_joint"},
{"motor",
{{"vendor", "moons"},
{"model", "m2dc10a"},
{"gear_ratio", 20},
{"invert", false}}}},
nlohmann::json{{"id", "front"},
{"role", "steer"},
{"x_m", 1.2},
{"y_m", 0},
{"joint_name", "front_steer_joint"},
{"motor",
{{"vendor", "moons"},
{"model", "m2dc10a"},
{"gear_ratio", 20},
{"invert", false}}}}});
}
nlohmann::json LayoutSchema::defaultDiffWheels()
{
return nlohmann::json::array(
{nlohmann::json{{"id", "left"},
{"side", "left"},
{"joint_name", "wheel_left_joint"},
{"y_m", 0.5},
{"motor",
{{"vendor", "moons"},
{"model", "m2dc10a"},
{"gear_ratio", 20},
{"invert", false}}}},
nlohmann::json{{"id", "right"},
{"side", "right"},
{"joint_name", "wheel_right_joint"},
{"y_m", -0.5},
{"motor",
{{"vendor", "moons"},
{"model", "m2dc10a"},
{"gear_ratio", 20},
{"invert", false}}}}});
}
nlohmann::json LayoutSchema::defaultLayoutObject()
{
return nlohmann::json{
{"robot",
{{"x", 400},
{"y", 300},
{"yaw_deg", 0},
{"frame_id", "base_footprint"},
{"model", "diff"},
{"diff",
{{"wheel_separation_m", 1.0},
{"wheel_radius_m", 0.3},
{"wheel_separation_multiplier", 1.0},
{"wheel_radius_multiplier", 1.0},
{"display", {{"scale_m_per_px", 0.005}, {"b_px", 200}, {"d_px", 120}}},
{"limits",
{{"cmd_vel_timeout_s", 0.25},
{"linear",
{{"max_velocity", 1.0},
{"min_velocity", -0.5},
{"max_acceleration", 0.8},
{"min_acceleration", -0.4}}},
{"angular",
{{"max_velocity", 1.7}, {"max_acceleration", 1.5}}}},
{"wheels", defaultDiffWheels()}}}},
{"footprint",
nlohmann::json::array({nlohmann::json{{"x", 120}, {"y", 80}},
nlohmann::json{{"x", 120}, {"y", -80}},
nlohmann::json{{"x", -90}, {"y", -80}},
nlohmann::json{{"x", -90}, {"y", 80}}})}}},
{"map", {{"width", 800}, {"height", 600}}},
{"lidarPositions", nlohmann::json::object()},
{"lidarPoses", nlohmann::json::object()},
{"lidarPosesFrame", "robot"},
{"imuPoses", nlohmann::json::object()},
{"imuPosesFrame", "robot"}};
}
void LayoutSchema::ensure(nlohmann::json& layout)
{
if (!layout.is_object())
layout = nlohmann::json::object();
if (!layout.contains("robot") || !layout["robot"].is_object())
layout["robot"] = nlohmann::json::object();
if (!layout.contains("map") || !layout["map"].is_object())
layout["map"] = nlohmann::json::object();
if (!layout.contains("lidarPositions") || !layout["lidarPositions"].is_object())
layout["lidarPositions"] = nlohmann::json::object();
if (!layout.contains("lidarPoses") || !layout["lidarPoses"].is_object())
layout["lidarPoses"] = nlohmann::json::object();
if (!layout.contains("lidarPosesFrame"))
layout["lidarPosesFrame"] = "robot";
if (!layout.contains("imuPoses") || !layout["imuPoses"].is_object())
layout["imuPoses"] = nlohmann::json::object();
if (!layout.contains("imuPosesFrame"))
layout["imuPosesFrame"] = "robot";
auto& robot = layout["robot"];
if (!robot.contains("x"))
robot["x"] = 400;
if (!robot.contains("y"))
robot["y"] = 300;
if (!robot.contains("yaw_deg"))
robot["yaw_deg"] = 0;
if (!robot.contains("frame_id"))
robot["frame_id"] = "base_footprint";
if (!robot.contains("model"))
robot["model"] = "diff";
if (!robot.contains("diff") || !robot["diff"].is_object())
robot["diff"] = nlohmann::json::object();
auto& diff = robot["diff"];
const double default_scale = 0.005;
if (!diff.contains("display") || !diff["display"].is_object())
diff["display"] = nlohmann::json::object();
auto& display = diff["display"];
if (!display.contains("scale_m_per_px"))
display["scale_m_per_px"] = default_scale;
const double scale = display["scale_m_per_px"].get<double>();
if (!diff.contains("wheel_separation_m"))
{
if (diff.contains("b"))
diff["wheel_separation_m"] = diff["b"].get<double>() * scale;
else
diff["wheel_separation_m"] = 1.0;
}
if (!diff.contains("wheel_radius_m"))
{
if (diff.contains("d"))
diff["wheel_radius_m"] = diff["d"].get<double>() * scale * 0.5;
else
diff["wheel_radius_m"] = 0.3;
}
if (!diff.contains("wheel_separation_multiplier"))
diff["wheel_separation_multiplier"] = 1.0;
if (!diff.contains("wheel_radius_multiplier"))
diff["wheel_radius_multiplier"] = 1.0;
const double b_mult = diff["wheel_separation_multiplier"].get<double>();
const double r_mult = diff["wheel_radius_multiplier"].get<double>();
const double sep_m = diff["wheel_separation_m"].get<double>();
const double rad_m = diff["wheel_radius_m"].get<double>();
display["b_px"] = sep_m * b_mult / scale;
display["d_px"] = 2.0 * rad_m * r_mult / scale;
diff["b"] = display["b_px"];
diff["d"] = display["d_px"];
if (!diff.contains("limits") || !diff["limits"].is_object())
diff["limits"] = nlohmann::json::object();
auto& limits = diff["limits"];
if (!limits.contains("cmd_vel_timeout_s"))
limits["cmd_vel_timeout_s"] = 0.25;
if (!limits.contains("linear") || !limits["linear"].is_object())
limits["linear"] = nlohmann::json::object();
auto& linear = limits["linear"];
if (!linear.contains("max_velocity"))
linear["max_velocity"] = 1.0;
if (!linear.contains("min_velocity"))
linear["min_velocity"] = -0.5;
if (!linear.contains("max_acceleration"))
linear["max_acceleration"] = 0.8;
if (!linear.contains("min_acceleration"))
linear["min_acceleration"] = -0.4;
if (!limits.contains("angular") || !limits["angular"].is_object())
limits["angular"] = nlohmann::json::object();
auto& angular = limits["angular"];
if (!angular.contains("max_velocity"))
angular["max_velocity"] = 1.7;
if (!angular.contains("max_acceleration"))
angular["max_acceleration"] = 1.5;
const double half_sep = sep_m / 2.0;
if (!diff.contains("wheels") || !diff["wheels"].is_array() || diff["wheels"].empty())
{
diff["wheels"] = nlohmann::json::array(
{nlohmann::json{{"id", "left"},
{"side", "left"},
{"joint_name", "wheel_left_joint"},
{"y_m", half_sep},
{"motor",
nlohmann::json{{"vendor", "moons"},
{"model", "m2dc10a"},
{"gear_ratio", 20},
{"invert", false}}}},
nlohmann::json{{"id", "right"},
{"side", "right"},
{"joint_name", "wheel_right_joint"},
{"y_m", -half_sep},
{"motor",
nlohmann::json{{"vendor", "moons"},
{"model", "m2dc10a"},
{"gear_ratio", 20},
{"invert", false}}}}});
}
else
{
for (auto& w : diff["wheels"])
{
if (!w.is_object())
continue;
if (!w.contains("id"))
w["id"] = "left";
if (!w.contains("side"))
w["side"] = (w["id"].get<std::string>() == "right") ? "right" : "left";
if (!w.contains("joint_name"))
{
w["joint_name"] = (w["side"].get<std::string>() == "right") ? "wheel_right_joint"
: "wheel_left_joint";
}
if (!w.contains("y_m"))
{
w["y_m"] = (w["side"].get<std::string>() == "right") ? -half_sep : half_sep;
}
if (!w.contains("motor") || !w["motor"].is_object())
w["motor"] = nlohmann::json::object();
auto& motor = w["motor"];
if (!motor.contains("vendor"))
motor["vendor"] = "custom";
if (!motor.contains("model"))
motor["model"] = "custom";
if (!motor.contains("gear_ratio"))
motor["gear_ratio"] = 20;
if (!motor.contains("invert"))
motor["invert"] = false;
}
}
if (!robot.contains("bicycle") || !robot["bicycle"].is_object())
robot["bicycle"] = nlohmann::json::object();
auto& bicycle = robot["bicycle"];
if (!bicycle.contains("display") || !bicycle["display"].is_object())
bicycle["display"] = nlohmann::json::object();
auto& bdisplay = bicycle["display"];
if (!bdisplay.contains("scale_m_per_px"))
bdisplay["scale_m_per_px"] = default_scale;
const double bscale = bdisplay["scale_m_per_px"].get<double>();
if (!bicycle.contains("wheelbase_m"))
bicycle["wheelbase_m"] = 1.2;
if (!bicycle.contains("wheel_radius_m"))
bicycle["wheel_radius_m"] = 0.15;
const double L_m = bicycle["wheelbase_m"].get<double>();
const double b_rad = bicycle["wheel_radius_m"].get<double>();
bdisplay["L_px"] = L_m / bscale;
bdisplay["r_px"] = 2.0 * b_rad / bscale;
if (!bicycle.contains("steer") || !bicycle["steer"].is_object())
bicycle["steer"] = nlohmann::json::object();
auto& steer = bicycle["steer"];
if (!steer.contains("max_angle_deg"))
steer["max_angle_deg"] = 35;
if (!steer.contains("preview_deg"))
steer["preview_deg"] = 15;
if (!steer.contains("joint_name"))
steer["joint_name"] = "front_steer_joint";
if (!bicycle.contains("drive") || !bicycle["drive"].is_object())
bicycle["drive"] = nlohmann::json::object();
if (!bicycle["drive"].contains("joint_name"))
bicycle["drive"]["joint_name"] = "rear_wheel_joint";
if (!bicycle.contains("limits") || !bicycle["limits"].is_object())
bicycle["limits"] = nlohmann::json::object();
auto& blimits = bicycle["limits"];
if (!blimits.contains("cmd_vel_timeout_s"))
blimits["cmd_vel_timeout_s"] = 0.25;
if (!blimits.contains("linear") || !blimits["linear"].is_object())
blimits["linear"] = nlohmann::json::object();
auto& blinear = blimits["linear"];
if (!blinear.contains("max_velocity"))
blinear["max_velocity"] = 1.0;
if (!blinear.contains("max_acceleration"))
blinear["max_acceleration"] = 0.8;
if (!bicycle.contains("wheels") || !bicycle["wheels"].is_array() || bicycle["wheels"].empty())
bicycle["wheels"] = defaultBicycleWheels();
if (!robot.contains("footprint") || !robot["footprint"].is_array())
{
robot["footprint"] =
nlohmann::json::array({nlohmann::json{{"x", 120}, {"y", 80}},
nlohmann::json{{"x", 120}, {"y", -80}},
nlohmann::json{{"x", -90}, {"y", -80}},
nlohmann::json{{"x", -90}, {"y", 80}}});
}
if (!robot.contains("footprint_shape"))
robot["footprint_shape"] = "custom";
if (!robot.contains("footprint_params") || !robot["footprint_params"].is_object())
{
robot["footprint_params"] = nlohmann::json{{"length_m", 1.4},
{"width_m", 1.1},
{"radius_m", 0.55},
{"sides", 6},
{"segments", 32}};
}
auto& map = layout["map"];
if (!map.contains("width"))
map["width"] = 800;
if (!map.contains("height"))
map["height"] = 600;
}
} // namespace lm

View File

@@ -0,0 +1,18 @@
#pragma once
#include <nlohmann/json.hpp>
namespace lm {
class LayoutSchema
{
public:
static nlohmann::json defaultLayoutObject();
static void ensure(nlohmann::json& layout);
private:
static nlohmann::json defaultBicycleWheels();
static nlohmann::json defaultDiffWheels();
};
} // namespace lm

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
#include "mission/mission_enqueue.hpp"
namespace lm {
nlohmann::json MissionEnqueue::normalizeParameters(const nlohmann::json& parameters)
{
if (parameters.is_object())
return parameters;
if (!parameters.is_array())
return nlohmann::json::object();
nlohmann::json out = nlohmann::json::object();
for (const auto& item : parameters)
{
if (!item.is_object())
continue;
if (item.contains("id") && item.contains("value"))
out[item["id"].get<std::string>()] = item["value"];
else if (item.contains("key") && item.contains("value"))
out[item["key"].get<std::string>()] = item["value"];
}
return out;
}
bool MissionEnqueue::buildPayload(const MissionStore& store,
const nlohmann::json& request,
nlohmann::json& payload,
std::string& err)
{
if (!request.is_object())
{
err = "request must be an object";
return false;
}
nlohmann::json mission;
if (request.contains("mission") && request["mission"].is_object())
{
mission = request["mission"];
}
else if (request.contains("mission_id") && request["mission_id"].is_string())
{
const auto found = store.findMission(request["mission_id"].get<std::string>());
if (!found)
{
err = "mission not found";
return false;
}
mission = *found;
}
else
{
err = "mission or mission_id is required";
return false;
}
payload = nlohmann::json::object();
payload["mission"] = mission;
payload["parameters"] = request.contains("parameters") ? normalizeParameters(request["parameters"])
: nlohmann::json::object();
if (request.contains("priority") && request["priority"].is_number())
payload["priority"] = request["priority"];
if (request.contains("robot_id") && request["robot_id"].is_string())
payload["robot_id"] = request["robot_id"];
if (request.contains("source") && request["source"].is_string())
payload["source"] = request["source"];
else if (request.contains("message") && request["message"].is_string())
payload["source"] = request["message"].get<std::string>();
return true;
}
} // namespace lm

View File

@@ -0,0 +1,22 @@
#pragma once
#include "mission/mission_store.hpp"
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
namespace lm {
class MissionEnqueue
{
public:
static nlohmann::json normalizeParameters(const nlohmann::json& parameters);
static bool buildPayload(const MissionStore& store,
const nlohmann::json& request,
nlohmann::json& payload,
std::string& err);
};
} // namespace lm

View File

@@ -0,0 +1,629 @@
#include "mission/mission_queue.hpp"
#include "storage/database.hpp"
#include "util/id_util.hpp"
#include <chrono>
#include <stdexcept>
#include <thread>
#include <unordered_set>
namespace lm {
namespace {
class MissionCancelled : public std::runtime_error
{
public:
MissionCancelled() : std::runtime_error("mission cancelled") {}
};
std::string paramValue(const std::string& action_id,
const nlohmann::json& params,
const std::string& key,
const nlohmann::json& parameters)
{
const std::string lookup = action_id + ":" + key;
if (parameters.is_object() && parameters.contains(lookup))
return parameters[lookup].get<std::string>();
if (params.contains(key) && params[key].is_string())
return params[key].get<std::string>();
return "";
}
double paramNumber(const nlohmann::json& params, const std::string& key, double fallback)
{
if (params.contains(key) && params[key].is_number())
return params[key].get<double>();
return fallback;
}
} // namespace
MissionQueue::MissionQueue(Database& db) : db_(db)
{
load();
ensureRunnerDefaults();
startWorkerIfNeeded();
}
MissionQueue::~MissionQueue()
{
stop_ = true;
wake_ = true;
if (worker_.joinable())
worker_.join();
}
void MissionQueue::load()
{
std::lock_guard<std::recursive_mutex> lock(mu_);
queue_ = nlohmann::json::array();
runner_ = nlohmann::json::object();
nlohmann::json parsed;
if (!db_.getDocument("mission_queue", parsed))
return;
if (parsed.is_object())
{
if (parsed.contains("queue") && parsed["queue"].is_array())
queue_ = parsed["queue"];
if (parsed.contains("runner") && parsed["runner"].is_object())
runner_ = parsed["runner"];
}
ensureRunnerDefaults();
}
void MissionQueue::saveUnlocked() const
{
const nlohmann::json payload = {{"queue", queue_}, {"runner", runner_}};
db_.setDocument("mission_queue", payload);
}
void MissionQueue::ensureRunnerDefaults()
{
if (!runner_.is_object())
runner_ = nlohmann::json::object();
if (!runner_.contains("state"))
runner_["state"] = "idle";
if (!runner_.contains("message"))
runner_["message"] = "";
if (!runner_.contains("current_queue_id"))
runner_["current_queue_id"] = nullptr;
if (!runner_.contains("current_action"))
runner_["current_action"] = nullptr;
if (!runner_.contains("paused"))
runner_["paused"] = false;
}
void MissionQueue::startWorkerIfNeeded()
{
if (worker_.joinable())
return;
worker_ = std::thread([this] { workerLoop(); });
}
nlohmann::json MissionQueue::list() const
{
std::lock_guard<std::recursive_mutex> lock(mu_);
return queue_;
}
nlohmann::json MissionQueue::runnerStatus() const
{
std::lock_guard<std::recursive_mutex> lock(mu_);
return runner_;
}
std::optional<nlohmann::json> MissionQueue::enqueue(const nlohmann::json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be an object";
return std::nullopt;
}
if (!payload.contains("mission") || !payload["mission"].is_object())
{
err = "mission is required";
return std::nullopt;
}
nlohmann::json entry = nlohmann::json::object();
entry["id"] = IdUtil::newId();
entry["mission_id"] = payload["mission"].value("id", "");
entry["mission_name"] = payload["mission"].value("name", "Mission");
entry["mission_group"] = payload["mission"].value("group", "Missions");
entry["mission"] = payload["mission"];
entry["parameters"] = payload.contains("parameters") && payload["parameters"].is_object() ? payload["parameters"]
: nlohmann::json::object();
entry["priority"] = payload.contains("priority") && payload["priority"].is_number() ? payload["priority"].get<int>() : 0;
entry["robot_id"] = payload.value("robot_id", "default");
entry["source"] = payload.value("source", "ui");
entry["status"] = "pending";
entry["created_at"] = IdUtil::nowIso8601();
entry["started_at"] = nullptr;
entry["finished_at"] = nullptr;
entry["log"] = nlohmann::json::array();
{
std::lock_guard<std::recursive_mutex> lock(mu_);
insertByPriorityUnlocked(entry);
saveUnlocked();
}
wake_ = true;
return entry;
}
void MissionQueue::insertByPriorityUnlocked(nlohmann::json& entry)
{
const int priority = entry.value("priority", 0);
size_t insert_at = queue_.size();
for (size_t i = 0; i < queue_.size(); ++i)
{
if (!queue_[i].is_object())
continue;
if (queue_[i].value("status", "") != "pending")
continue;
const int existing = queue_[i].value("priority", 0);
if (priority > existing)
{
insert_at = i;
break;
}
}
if (insert_at >= queue_.size())
queue_.push_back(entry);
else
queue_.insert(queue_.begin() + static_cast<nlohmann::json::difference_type>(insert_at), entry);
}
bool MissionQueue::removeById(const std::string& id, std::string& err)
{
std::lock_guard<std::recursive_mutex> lock(mu_);
if (!queue_.is_array())
{
err = "queue unavailable";
return false;
}
const auto before = queue_.size();
nlohmann::json next = nlohmann::json::array();
for (const auto& item : queue_)
{
if (!item.is_object())
continue;
if (item.value("id", "") == id)
{
const std::string status = item.value("status", "");
if (status == "executing")
{
err = "cannot remove executing mission";
return false;
}
continue;
}
next.push_back(item);
}
if (next.size() == before)
{
err = "queue item not found";
return false;
}
queue_ = std::move(next);
saveUnlocked();
return true;
}
bool MissionQueue::clearAll(std::string& err)
{
(void)err;
std::lock_guard<std::recursive_mutex> lock(mu_);
nlohmann::json next = nlohmann::json::array();
for (const auto& item : queue_)
{
if (!item.is_object())
continue;
if (item.value("status", "") == "executing")
next.push_back(item);
}
queue_ = std::move(next);
setRunnerState(next.empty() ? "idle" : "running", next.empty() ? "" : "Đang thực thi mission");
saveUnlocked();
return true;
}
bool MissionQueue::reorder(const nlohmann::json& ordered_ids, std::string& err)
{
if (!ordered_ids.is_array())
{
err = "ordered_ids must be an array";
return false;
}
std::lock_guard<std::recursive_mutex> lock(mu_);
if (!queue_.is_array())
{
err = "queue unavailable";
return false;
}
nlohmann::json by_id = nlohmann::json::object();
for (auto& item : queue_)
{
if (item.is_object())
by_id[item.value("id", "")] = item;
}
nlohmann::json next = nlohmann::json::array();
std::unordered_set<std::string> seen;
for (const auto& id_json : ordered_ids)
{
if (!id_json.is_string())
continue;
const std::string id = id_json.get<std::string>();
if (!by_id.contains(id))
continue;
next.push_back(by_id[id]);
seen.insert(id);
}
for (const auto& item : queue_)
{
if (!item.is_object())
continue;
const std::string id = item.value("id", "");
if (seen.count(id))
continue;
next.push_back(item);
}
queue_ = std::move(next);
saveUnlocked();
return true;
}
bool MissionQueue::pause(std::string& err)
{
std::lock_guard<std::recursive_mutex> lock(mu_);
const std::string state = runner_.value("state", "idle");
if (state != "running")
{
err = "no mission is running";
return false;
}
paused_ = true;
runner_["paused"] = true;
runner_["state"] = "paused";
runner_["message"] = "Mission tạm dừng";
runner_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
bool MissionQueue::resume(std::string& err)
{
(void)err;
paused_ = false;
{
std::lock_guard<std::recursive_mutex> lock(mu_);
runner_["paused"] = false;
if (runner_.value("state", "") == "paused")
{
runner_["state"] = "running";
runner_["message"] = "Tiếp tục mission";
runner_["updated_at"] = IdUtil::nowIso8601();
}
saveUnlocked();
}
wake_ = 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()
{
while (!stop_)
{
nlohmann::json working;
bool run = false;
{
std::lock_guard<std::recursive_mutex> lock(mu_);
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)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
wake_ = false;
}
}
void MissionQueue::runMissionActions(nlohmann::json& entry)
{
cancel_ = false;
nlohmann::json log = nlohmann::json::array();
try
{
const auto& mission = entry["mission"];
const auto& parameters = entry["parameters"];
const auto& actions =
mission.contains("actions") && mission["actions"].is_array() ? mission["actions"] : nlohmann::json::array();
executeActionsUnlocked(actions, parameters, log, 0);
if (cancel_)
throw MissionCancelled();
entry["log"] = log;
entry["status"] = "completed";
entry["finished_at"] = IdUtil::nowIso8601();
{
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 (...)
{
entry["status"] = "failed";
entry["finished_at"] = IdUtil::nowIso8601();
{
std::lock_guard<std::recursive_mutex> lock(mu_);
setRunnerState("error", "Lỗi khi chạy: " + entry.value("mission_name", "Mission"));
saveUnlocked();
}
}
cancel_ = false;
}
MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
const nlohmann::json& parameters,
nlohmann::json& log,
int loop_depth)
{
if (loop_depth > 8)
throw std::runtime_error("loop depth exceeded");
for (const auto& action : actions)
{
if (!action.is_object())
continue;
while (paused_ && !stop_ && !cancel_)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (stop_)
return LoopControl::None;
if (cancel_)
throw MissionCancelled();
const std::string action_id = action.value("id", "");
const std::string kind = action.value("kind", "action");
const std::string type = action.value("type", "");
const std::string label = action.value("label", type);
const auto& params = action.contains("params") && action["params"].is_object() ? action["params"]
: nlohmann::json::object();
{
std::lock_guard<std::recursive_mutex> lock(mu_);
runner_["current_action"] = label;
saveUnlocked();
}
if (kind == "mission")
{
const std::string ref_id = action.value("refId", "");
nlohmann::json ref_mission = nlohmann::json::object();
(void)ref_id;
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Sub-mission: " + label}});
if (action.contains("resolved_mission") && action["resolved_mission"].is_object())
{
const auto& nested_actions = action["resolved_mission"]["actions"];
const LoopControl nested = executeActionsUnlocked(nested_actions, parameters, log, loop_depth);
if (nested == LoopControl::Break)
return LoopControl::Break;
if (nested == LoopControl::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")
{
const std::string mode = params.value("mode", "count");
const int count = static_cast<int>(paramNumber(params, "count", 1));
const auto& children =
action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array();
const int iterations = mode == "endless" ? 10000 : std::max(1, count);
for (int i = 0; i < iterations && !stop_ && !cancel_; ++i)
{
if (mode == "endless" && i == 0)
{
log.push_back({{"ts", IdUtil::nowIso8601()},
{"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;
}
if (type == "wait")
{
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"}});
sleepMs(ms);
if (cancel_)
throw MissionCancelled();
continue;
}
if (type == "move_to_position" || type == "adjust_localization" || type == "pick_cart" || type == "drop_cart")
{
const std::string pos = paramValue(action_id, params, "position", parameters);
log.push_back({{"ts", IdUtil::nowIso8601()},
{"level", "info"},
{"message", label + "" + (pos.empty() ? "?" : pos)}});
sleepMs(1200);
if (cancel_)
throw MissionCancelled();
continue;
}
if (type == "move_to_marker")
{
const std::string marker = paramValue(action_id, params, "marker", parameters);
log.push_back({{"ts", IdUtil::nowIso8601()},
{"level", "info"},
{"message", label + "" + (marker.empty() ? "?" : marker)}});
sleepMs(1200);
if (cancel_)
throw MissionCancelled();
continue;
}
if (type == "user_log")
{
const std::string message = params.value("message", "Mission step");
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "user"}, {"message", message}});
sleepMs(200);
if (cancel_)
throw MissionCancelled();
continue;
}
if (type == "pause")
{
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "warn"}, {"message", "Pause (simulated)"}});
sleepMs(500);
if (cancel_)
throw MissionCancelled();
continue;
}
log.push_back(
{{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}});
sleepMs(400);
if (cancel_)
throw MissionCancelled();
}
return LoopControl::None;
}
void MissionQueue::sleepMs(int ms)
{
if (ms <= 0)
return;
const int step = 100;
for (int elapsed = 0; elapsed < ms && !stop_ && !cancel_; elapsed += step)
{
while (paused_ && !stop_ && !cancel_)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::this_thread::sleep_for(std::chrono::milliseconds(std::min(step, ms - elapsed)));
}
}
void MissionQueue::setRunnerState(const std::string& state, const std::string& message)
{
runner_["state"] = state;
runner_["message"] = message;
runner_["updated_at"] = IdUtil::nowIso8601();
}
} // namespace lm

View File

@@ -0,0 +1,64 @@
#pragma once
#include <nlohmann/json.hpp>
#include <atomic>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
namespace lm {
class Database;
class MissionQueue
{
public:
explicit MissionQueue(Database& db);
~MissionQueue();
MissionQueue(const MissionQueue&) = delete;
MissionQueue& operator=(const MissionQueue&) = delete;
nlohmann::json list() const;
nlohmann::json runnerStatus() const;
std::optional<nlohmann::json> enqueue(const nlohmann::json& payload, std::string& err);
bool removeById(const std::string& id, std::string& err);
bool clearAll(std::string& err);
bool reorder(const nlohmann::json& ordered_ids, std::string& err);
bool pause(std::string& err);
bool resume(std::string& err);
bool cancel(std::string& err);
private:
enum class LoopControl { None, Break, Continue };
Database& db_;
mutable std::recursive_mutex mu_;
nlohmann::json queue_;
nlohmann::json runner_;
std::thread worker_;
std::atomic<bool> stop_{false};
std::atomic<bool> wake_{false};
std::atomic<bool> paused_{false};
std::atomic<bool> cancel_{false};
void load();
void saveUnlocked() const;
void ensureRunnerDefaults();
void startWorkerIfNeeded();
void workerLoop();
void runMissionActions(nlohmann::json& entry);
LoopControl executeActionsUnlocked(const nlohmann::json& actions,
const nlohmann::json& parameters,
nlohmann::json& log,
int loop_depth);
void sleepMs(int ms);
void setRunnerState(const std::string& state, const std::string& message = "");
void insertByPriorityUnlocked(nlohmann::json& entry);
};
} // namespace lm

View File

@@ -0,0 +1,96 @@
#include "mission/mission_scheduler.hpp"
#include "util/id_util.hpp"
#include <chrono>
namespace lm {
MissionScheduler::MissionScheduler(MissionStore& store, EnqueueFn enqueue_fn)
: store_(store), enqueue_fn_(std::move(enqueue_fn))
{
worker_ = std::thread([this] { workerLoop(); });
}
MissionScheduler::~MissionScheduler()
{
stop_ = true;
if (worker_.joinable())
worker_.join();
}
bool MissionScheduler::queueSchedule(const nlohmann::json& schedule, std::string& err)
{
nlohmann::json req = {{"mission_id", schedule.value("mission_id", "")},
{"priority", schedule.value("priority", 0)},
{"robot_id", schedule.value("robot_id", "default")},
{"source", "fleet:" + schedule.value("name", "schedule")}};
if (!enqueue_fn_(req, err))
return false;
markQueued(schedule.value("id", ""));
return true;
}
void MissionScheduler::markQueued(const std::string& id)
{
if (id.empty())
return;
nlohmann::json patch = {{"last_queued_at", IdUtil::nowIso8601()}};
std::string err;
store_.updateSchedule(id, patch, err);
}
bool MissionScheduler::runScheduleNow(const std::string& id, std::string& err)
{
const auto schedule = store_.findSchedule(id);
if (!schedule)
{
err = "schedule not found";
return false;
}
if (!schedule->value("enabled", true))
{
err = "schedule is disabled";
return false;
}
return queueSchedule(*schedule, err);
}
void MissionScheduler::workerLoop()
{
while (!stop_)
{
const auto schedules = store_.listSchedules();
const std::string now = IdUtil::nowIso8601();
for (const auto& schedule : schedules)
{
if (!schedule.is_object() || !schedule.value("enabled", true))
continue;
const std::string mode = schedule.value("start_mode", "asap");
if (mode == "asap")
{
if (schedule.contains("last_queued_at") && !schedule["last_queued_at"].is_null())
continue;
std::string err;
queueSchedule(schedule, err);
continue;
}
if (mode == "scheduled")
{
if (!schedule.contains("start_at") || schedule["start_at"].is_null())
continue;
const std::string start_at = schedule["start_at"].get<std::string>();
if (start_at > now)
continue;
if (schedule.contains("last_queued_at") && !schedule["last_queued_at"].is_null())
continue;
std::string err;
queueSchedule(schedule, err);
}
}
for (int i = 0; i < 20 && !stop_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
} // namespace lm

View File

@@ -0,0 +1,38 @@
#pragma once
#include "mission/mission_store.hpp"
#include <atomic>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
namespace lm {
class MissionScheduler
{
public:
using EnqueueFn = std::function<bool(const nlohmann::json& request, std::string& err)>;
MissionScheduler(MissionStore& store, EnqueueFn enqueue_fn);
~MissionScheduler();
MissionScheduler(const MissionScheduler&) = delete;
MissionScheduler& operator=(const MissionScheduler&) = delete;
bool runScheduleNow(const std::string& id, std::string& err);
private:
MissionStore& store_;
EnqueueFn enqueue_fn_;
std::atomic<bool> stop_{false};
std::thread worker_;
void workerLoop();
bool queueSchedule(const nlohmann::json& schedule, std::string& err);
void markQueued(const std::string& id);
};
} // namespace lm

View File

@@ -0,0 +1,335 @@
#include "mission/mission_store.hpp"
#include "storage/database.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
namespace lm {
namespace {
constexpr int kMinCoilId = 1001;
constexpr int kMaxCoilId = 2000;
bool coilIdValid(int coil_id)
{
return coil_id >= kMinCoilId && coil_id <= kMaxCoilId;
}
} // namespace
MissionStore::MissionStore(Database& db) : db_(db)
{
load();
}
void MissionStore::load()
{
std::lock_guard<std::mutex> lock(mu_);
data_ = nlohmann::json::object();
const bool existed = db_.getDocument("missions", data_);
ensureSchemaUnlocked();
if (!existed)
saveUnlocked();
}
void MissionStore::ensureSchemaUnlocked()
{
if (!data_.contains("version"))
data_["version"] = 1;
if (!data_.contains("missions") || !data_["missions"].is_array())
data_["missions"] = nlohmann::json::array();
if (!data_.contains("triggers") || !data_["triggers"].is_array())
data_["triggers"] = nlohmann::json::array();
if (!data_.contains("schedules") || !data_["schedules"].is_array())
data_["schedules"] = nlohmann::json::array();
if (!data_.contains("groups") || !data_["groups"].is_array())
data_["groups"] = nlohmann::json::array({"Missions", "Move", "Logic", "I/O", "Cart", "Misc"});
if (!data_.contains("robots") || !data_["robots"].is_array())
{
data_["robots"] = nlohmann::json::array({{{"id", "default"},
{"name", "Robot chính"},
{"serial", "PX-001"},
{"online", true}}});
}
if (!data_.contains("dashboard") || !data_["dashboard"].is_object())
data_["dashboard"] = nlohmann::json::object({{"widgets", nlohmann::json::array()}});
}
void MissionStore::saveUnlocked() const
{
db_.setDocument("missions", data_);
}
nlohmann::json MissionStore::snapshot() const
{
std::lock_guard<std::mutex> lock(mu_);
return data_;
}
bool MissionStore::replace(const nlohmann::json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be an object";
return false;
}
std::lock_guard<std::mutex> lock(mu_);
if (payload.contains("missions") && payload["missions"].is_array())
data_["missions"] = payload["missions"];
if (payload.contains("groups") && payload["groups"].is_array())
data_["groups"] = payload["groups"];
if (payload.contains("triggers") && payload["triggers"].is_array())
data_["triggers"] = payload["triggers"];
if (payload.contains("schedules") && payload["schedules"].is_array())
data_["schedules"] = payload["schedules"];
ensureSchemaUnlocked();
saveUnlocked();
return true;
}
nlohmann::json MissionStore::listMissions() const
{
std::lock_guard<std::mutex> lock(mu_);
return data_["missions"];
}
std::optional<nlohmann::json> MissionStore::findMission(const std::string& id) const
{
std::lock_guard<std::mutex> lock(mu_);
if (!data_.contains("missions") || !data_["missions"].is_array())
return std::nullopt;
for (const auto& m : data_["missions"])
{
if (m.is_object() && m.value("id", "") == id)
return m;
}
return std::nullopt;
}
nlohmann::json MissionStore::listTriggers() const
{
std::lock_guard<std::mutex> lock(mu_);
return data_["triggers"];
}
std::optional<nlohmann::json> MissionStore::addTrigger(const nlohmann::json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be an object";
return std::nullopt;
}
if (!payload.contains("name") || !payload["name"].is_string())
{
err = "name is required";
return std::nullopt;
}
if (!payload.contains("coil_id") || !payload["coil_id"].is_number_integer())
{
err = "coil_id is required";
return std::nullopt;
}
if (!payload.contains("mission_id") || !payload["mission_id"].is_string())
{
err = "mission_id is required";
return std::nullopt;
}
const int coil_id = payload["coil_id"].get<int>();
if (!coilIdValid(coil_id))
{
err = "coil_id must be between 1001 and 2000";
return std::nullopt;
}
const std::string mission_id = payload["mission_id"].get<std::string>();
if (!findMission(mission_id))
{
err = "mission not found";
return std::nullopt;
}
std::lock_guard<std::mutex> lock(mu_);
for (const auto& t : data_["triggers"])
{
if (t.is_object() && t.value("coil_id", 0) == coil_id)
{
err = "coil_id already assigned";
return std::nullopt;
}
}
nlohmann::json trigger = {{"id", IdUtil::newId()},
{"name", StringUtil::trimCopy(payload["name"].get<std::string>())},
{"coil_id", coil_id},
{"mission_id", mission_id},
{"enabled", !payload.contains("enabled") || payload["enabled"].get<bool>()},
{"created_at", IdUtil::nowIso8601()}};
data_["triggers"].push_back(trigger);
saveUnlocked();
return trigger;
}
bool MissionStore::deleteTrigger(const std::string& id, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (!data_["triggers"].is_array())
{
err = "triggers unavailable";
return false;
}
const auto before = data_["triggers"].size();
nlohmann::json next = nlohmann::json::array();
for (const auto& t : data_["triggers"])
{
if (t.is_object() && t.value("id", "") == id)
continue;
next.push_back(t);
}
if (next.size() == before)
{
err = "trigger not found";
return false;
}
data_["triggers"] = std::move(next);
saveUnlocked();
return true;
}
std::optional<nlohmann::json> MissionStore::findTriggerByCoil(int coil_id) const
{
std::lock_guard<std::mutex> lock(mu_);
if (!data_["triggers"].is_array())
return std::nullopt;
for (const auto& t : data_["triggers"])
{
if (!t.is_object())
continue;
if (t.value("coil_id", 0) == coil_id && t.value("enabled", true))
return t;
}
return std::nullopt;
}
nlohmann::json MissionStore::listSchedules() const
{
std::lock_guard<std::mutex> lock(mu_);
return data_["schedules"];
}
std::optional<nlohmann::json> MissionStore::addSchedule(const nlohmann::json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be an object";
return std::nullopt;
}
if (!payload.contains("name") || !payload["name"].is_string())
{
err = "name is required";
return std::nullopt;
}
if (!payload.contains("mission_id") || !payload["mission_id"].is_string())
{
err = "mission_id is required";
return std::nullopt;
}
const std::string mission_id = payload["mission_id"].get<std::string>();
if (!findMission(mission_id))
{
err = "mission not found";
return std::nullopt;
}
const std::string start_mode =
payload.contains("start_mode") && payload["start_mode"].is_string() ? payload["start_mode"].get<std::string>()
: "asap";
if (start_mode != "asap" && start_mode != "scheduled")
{
err = "start_mode must be asap or scheduled";
return std::nullopt;
}
nlohmann::json schedule = {{"id", IdUtil::newId()},
{"name", StringUtil::trimCopy(payload["name"].get<std::string>())},
{"mission_id", mission_id},
{"robot_id", payload.value("robot_id", "default")},
{"priority", payload.contains("priority") && payload["priority"].is_number()
? payload["priority"].get<int>()
: 0},
{"start_mode", start_mode},
{"start_at", payload.contains("start_at") ? payload["start_at"] : nullptr},
{"enabled", !payload.contains("enabled") || payload["enabled"].get<bool>()},
{"last_queued_at", nullptr},
{"created_at", IdUtil::nowIso8601()}};
std::lock_guard<std::mutex> lock(mu_);
data_["schedules"].push_back(schedule);
saveUnlocked();
return schedule;
}
bool MissionStore::updateSchedule(const std::string& id, const nlohmann::json& payload, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (!data_["schedules"].is_array())
{
err = "schedules unavailable";
return false;
}
for (auto& s : data_["schedules"])
{
if (!s.is_object() || s.value("id", "") != id)
continue;
if (payload.contains("enabled") && payload["enabled"].is_boolean())
s["enabled"] = payload["enabled"];
if (payload.contains("priority") && payload["priority"].is_number())
s["priority"] = payload["priority"];
if (payload.contains("start_at"))
s["start_at"] = payload["start_at"];
if (payload.contains("start_mode") && payload["start_mode"].is_string())
s["start_mode"] = payload["start_mode"];
saveUnlocked();
return true;
}
err = "schedule not found";
return false;
}
bool MissionStore::deleteSchedule(const std::string& id, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
const auto before = data_["schedules"].size();
nlohmann::json next = nlohmann::json::array();
for (const auto& s : data_["schedules"])
{
if (s.is_object() && s.value("id", "") == id)
continue;
next.push_back(s);
}
if (next.size() == before)
{
err = "schedule not found";
return false;
}
data_["schedules"] = std::move(next);
saveUnlocked();
return true;
}
std::optional<nlohmann::json> MissionStore::findSchedule(const std::string& id) const
{
std::lock_guard<std::mutex> lock(mu_);
for (const auto& s : data_["schedules"])
{
if (s.is_object() && s.value("id", "") == id)
return s;
}
return std::nullopt;
}
nlohmann::json MissionStore::listRobots() const
{
std::lock_guard<std::mutex> lock(mu_);
return data_["robots"];
}
} // namespace lm

View File

@@ -0,0 +1,47 @@
#pragma once
#include <nlohmann/json.hpp>
#include <mutex>
#include <optional>
#include <string>
namespace lm {
class Database;
class MissionStore
{
public:
explicit MissionStore(Database& db);
nlohmann::json snapshot() const;
bool replace(const nlohmann::json& payload, std::string& err);
nlohmann::json listMissions() const;
std::optional<nlohmann::json> findMission(const std::string& id) const;
nlohmann::json listTriggers() const;
std::optional<nlohmann::json> addTrigger(const nlohmann::json& payload, std::string& err);
bool deleteTrigger(const std::string& id, std::string& err);
std::optional<nlohmann::json> findTriggerByCoil(int coil_id) const;
nlohmann::json listSchedules() const;
std::optional<nlohmann::json> addSchedule(const nlohmann::json& payload, std::string& err);
bool updateSchedule(const std::string& id, const nlohmann::json& payload, std::string& err);
bool deleteSchedule(const std::string& id, std::string& err);
std::optional<nlohmann::json> findSchedule(const std::string& id) const;
nlohmann::json listRobots() const;
private:
Database& db_;
mutable std::mutex mu_;
nlohmann::json data_;
void load();
void saveUnlocked() const;
void ensureSchemaUnlocked();
};
} // namespace lm

View File

@@ -0,0 +1,150 @@
#include "mission/modbus_trigger_service.hpp"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstring>
#include <vector>
namespace lm {
namespace {
constexpr int kMinCoilId = 1001;
constexpr int kMaxCoilId = 2000;
bool coilIdValid(int coil_id)
{
return coil_id >= kMinCoilId && coil_id <= kMaxCoilId;
}
int modbusAddressToCoilId(uint16_t address)
{
return static_cast<int>(address);
}
} // namespace
ModbusTriggerService::ModbusTriggerService(MissionStore& store, EnqueueFn enqueue_fn, int tcp_port)
: store_(store), enqueue_fn_(std::move(enqueue_fn)), tcp_port_(tcp_port)
{
tcp_thread_ = std::thread([this] { tcpLoop(); });
}
ModbusTriggerService::~ModbusTriggerService()
{
stop_ = true;
if (tcp_thread_.joinable())
tcp_thread_.join();
}
nlohmann::json ModbusTriggerService::coilStates() const
{
std::lock_guard<std::mutex> lock(mu_);
nlohmann::json out = nlohmann::json::array();
for (int coil = kMinCoilId; coil <= kMaxCoilId; ++coil)
{
const bool value = coils_.count(coil) ? coils_.at(coil) : false;
if (value || store_.findTriggerByCoil(coil))
{
out.push_back({{"coil_id", coil}, {"value", value}});
}
}
return out;
}
bool ModbusTriggerService::writeCoil(int coil_id, bool value, std::string& err)
{
if (!coilIdValid(coil_id))
{
err = "coil_id must be between 1001 and 2000";
return false;
}
std::optional<std::string> mission_id;
{
std::lock_guard<std::mutex> lock(mu_);
const bool prev = coils_.count(coil_id) ? coils_.at(coil_id) : false;
coils_[coil_id] = value;
if (!prev && value)
{
const auto trigger = store_.findTriggerByCoil(coil_id);
if (trigger)
mission_id = trigger->value("mission_id", "");
}
}
if (mission_id)
{
nlohmann::json req = {{"mission_id", *mission_id}, {"source", "modbus:" + std::to_string(coil_id)}};
return enqueue_fn_(req, err);
}
return true;
}
bool ModbusTriggerService::fireCoil(int coil_id, std::string& err)
{
return writeCoil(coil_id, true, err);
}
void ModbusTriggerService::handleTcpClient(int client_fd)
{
std::vector<uint8_t> buffer(260);
const ssize_t n = recv(client_fd, buffer.data(), buffer.size(), 0);
if (n < 12)
return;
const uint8_t function = buffer[7];
if (function == 0x05 && n >= 12)
{
const uint16_t address = (static_cast<uint16_t>(buffer[8]) << 8) | buffer[9];
const uint16_t value = (static_cast<uint16_t>(buffer[10]) << 8) | buffer[11];
const int coil_id = modbusAddressToCoilId(address);
std::string err;
writeCoil(coil_id, value == 0xFF00, err);
send(client_fd, buffer.data(), static_cast<size_t>(n), 0);
}
}
void ModbusTriggerService::tcpLoop()
{
const int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0)
return;
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(static_cast<uint16_t>(tcp_port_));
if (bind(server_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0)
{
close(server_fd);
return;
}
if (listen(server_fd, 8) < 0)
{
close(server_fd);
return;
}
while (!stop_)
{
fd_set fds;
FD_ZERO(&fds);
FD_SET(server_fd, &fds);
timeval tv{0, 200000};
if (select(server_fd + 1, &fds, nullptr, nullptr, &tv) <= 0)
continue;
const int client = accept(server_fd, nullptr, nullptr);
if (client < 0)
continue;
handleTcpClient(client);
close(client);
}
close(server_fd);
}
} // namespace lm

View File

@@ -0,0 +1,46 @@
#pragma once
#include "mission/mission_queue.hpp"
#include "mission/mission_store.hpp"
#include <atomic>
#include <functional>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <unordered_map>
namespace lm {
class ModbusTriggerService
{
public:
using EnqueueFn = std::function<bool(const nlohmann::json& request, std::string& err)>;
ModbusTriggerService(MissionStore& store, EnqueueFn enqueue_fn, int tcp_port = 5502);
~ModbusTriggerService();
ModbusTriggerService(const ModbusTriggerService&) = delete;
ModbusTriggerService& operator=(const ModbusTriggerService&) = delete;
nlohmann::json coilStates() const;
bool writeCoil(int coil_id, bool value, std::string& err);
bool fireCoil(int coil_id, std::string& err);
private:
MissionStore& store_;
EnqueueFn enqueue_fn_;
int tcp_port_;
mutable std::mutex mu_;
std::unordered_map<int, bool> coils_;
std::atomic<bool> stop_{false};
std::thread tcp_thread_;
void tcpLoop();
void handleTcpClient(int client_fd);
};
} // namespace lm

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,285 @@
#include "server/api_server.hpp"
#include "mission/mission_enqueue.hpp"
#include "util/http_util.hpp"
namespace lm {
std::optional<nlohmann::json> ApiServer::enqueueMission(const nlohmann::json& request, std::string& err)
{
nlohmann::json payload;
if (!MissionEnqueue::buildPayload(mission_store_, request, payload, err))
return std::nullopt;
return mission_queue_.enqueue(payload, err);
}
bool ApiServer::enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code)
{
std::string err;
const auto entry = enqueueMission(request, err);
if (!entry)
{
HttpUtil::jsonError(res, 400, err);
return false;
}
HttpUtil::addCors(res);
res.status = status_code;
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = entry->dump();
return true;
}
nlohmann::json ApiServer::toMirQueueEntry(const nlohmann::json& entry) const
{
nlohmann::json out = nlohmann::json::object();
if (entry.contains("id"))
out["id"] = entry["id"];
out["mission_id"] = entry.value("mission_id", std::string(""));
out["state"] = entry.contains("status") ? entry["status"] : nlohmann::json("pending");
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)
{
svr.Get("/api/missions", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = mission_store_.snapshot().dump();
});
svr.Put("/api/missions", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
if (!mission_store_.replace(payload, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = mission_store_.snapshot().dump();
});
}
void ApiServer::registerIntegrationRoutes(httplib::Server& svr)
{
svr.Get("/api/triggers", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = mission_store_.listTriggers().dump();
});
svr.Post("/api/triggers", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
const auto trigger = mission_store_.addTrigger(payload, err);
if (!trigger)
return HttpUtil::jsonError(res, 400, err);
res.status = 201;
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = trigger->dump();
});
svr.Delete(R"(/api/triggers/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!mission_store_.deleteTrigger(req.matches[1].str(), err))
return HttpUtil::jsonError(res, 400, err);
res.status = 204;
});
svr.Get("/api/modbus/coils", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = modbus_.coilStates().dump();
});
svr.Put(R"(/api/modbus/coils/([0-9]+))", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body.empty() ? "{}" : req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
const int coil_id = std::stoi(req.matches[1].str());
const bool value = !payload.contains("value") || payload["value"].get<bool>();
std::string err;
if (!modbus_.writeCoil(coil_id, value, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"coil_id", coil_id}, {"value", value}}).dump();
});
svr.Post(R"(/api/modbus/coils/([0-9]+)/trigger)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const int coil_id = std::stoi(req.matches[1].str());
std::string err;
if (!modbus_.fireCoil(coil_id, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"ok", true}, {"coil_id", coil_id}}).dump();
});
svr.Get("/api/fleet/robots", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = mission_store_.listRobots().dump();
});
svr.Get("/api/fleet/schedules", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = mission_store_.listSchedules().dump();
});
svr.Post("/api/fleet/schedules", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
const auto schedule = mission_store_.addSchedule(payload, err);
if (!schedule)
return HttpUtil::jsonError(res, 400, err);
if (schedule->value("start_mode", "asap") == "asap" && schedule->value("enabled", true))
scheduler_.runScheduleNow(schedule->value("id", ""), err);
res.status = 201;
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = schedule->dump();
});
svr.Put(R"(/api/fleet/schedules/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
if (!mission_store_.updateSchedule(req.matches[1].str(), payload, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"ok", true}}).dump();
});
svr.Delete(R"(/api/fleet/schedules/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!mission_store_.deleteSchedule(req.matches[1].str(), err))
return HttpUtil::jsonError(res, 400, err);
res.status = 204;
});
svr.Post(R"(/api/fleet/schedules/([0-9a-fA-F]+)/run)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!scheduler_.runScheduleNow(req.matches[1].str(), err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"ok", true}}).dump();
});
}
void ApiServer::registerMirV2Routes(httplib::Server& svr)
{
svr.Get("/api/v2.0.0/missions", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = mission_store_.listMissions().dump();
});
svr.Get("/api/v2.0.0/mission_queue", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json out = nlohmann::json::array();
const nlohmann::json queue = mission_queue_.list();
if (queue.is_array())
{
for (const auto& item : queue)
{
if (item.is_object())
out.push_back(toMirQueueEntry(item));
}
}
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = out.dump();
});
svr.Post("/api/v2.0.0/mission_queue", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
if (!payload.contains("source"))
payload["source"] = "rest_api_v2";
std::string err;
const auto entry = enqueueMission(payload, err);
if (!entry)
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) {
HttpUtil::addCors(res);
std::string err;
if (!mission_queue_.clearAll(err))
return HttpUtil::jsonError(res, 400, err);
res.status = 204;
});
svr.Get("/api/v2.0.0/status", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
const auto runner = mission_queue_.runnerStatus();
nlohmann::json body = {{"state_id", runner.value("state", "idle") == "running" ? 3
: runner.value("state", "") == "paused" ? 4
: 1},
{"state_text", runner.value("state", "idle")},
{"message", runner.value("message", "")}};
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = body.dump();
});
}
} // namespace lm

View File

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

556
src/server/api_server.cpp Normal file
View File

@@ -0,0 +1,556 @@
#include "server/api_server.hpp"
#include "domain/layout_profile.hpp"
#include "domain/layout_schema.hpp"
#include "mission/mission_enqueue.hpp"
#include "util/http_util.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
#include "validation/sensor_validator.hpp"
namespace lm {
ApiServer::ApiServer(StateRepository& repo,
MissionQueue& mission_queue,
MissionStore& mission_store,
ModbusTriggerService& modbus,
MissionScheduler& scheduler,
RobotRuntime& robot_runtime,
MapStore& map_store,
SiteStore& site_store,
SoundStore& sound_store,
DashboardStore& dashboard_store)
: repo_(repo),
mission_queue_(mission_queue),
mission_store_(mission_store),
modbus_(modbus),
scheduler_(scheduler),
robot_runtime_(robot_runtime),
map_store_(map_store),
site_store_(site_store),
sound_store_(sound_store),
dashboard_store_(dashboard_store)
{
}
void ApiServer::registerRoutes(httplib::Server& svr)
{
svr.Options(R"(/api/(.*))", [](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.status = 204;
});
svr.Get("/api/health", [](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"ok", true}}).dump();
});
svr.Get("/api/state", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
repo_.ensureSchema();
std::string active_name;
const auto idx = LayoutProfile::findActiveIndex(repo_.app().state);
if (idx)
active_name = repo_.app().state["layouts"][*idx]["name"].get<std::string>();
const nlohmann::json response = {{"version", repo_.app().state.value("version", 3)},
{"active_layout_id", repo_.app().state["active_layout_id"]},
{"active_layout_name", active_name},
{"layouts", LayoutProfile::buildCatalog(repo_.app().state)},
{"layout", repo_.app().state["layout"]},
{"lidars", repo_.app().state["lidars"]},
{"imus", repo_.app().state.contains("imus") ? repo_.app().state["imus"] : nlohmann::json::array()}};
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = response.dump();
});
svr.Get("/api/layouts", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
repo_.ensureSchema();
const nlohmann::json response = {{"active_layout_id", repo_.app().state["active_layout_id"]},
{"layouts", LayoutProfile::buildCatalog(repo_.app().state)}};
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = response.dump();
});
svr.Post("/api/layouts", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
repo_.ensureSchema();
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
if (!payload.is_object() || !payload.contains("name") || !payload["name"].is_string())
return HttpUtil::jsonError(res, 400, "name is required");
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
if (name.empty())
return HttpUtil::jsonError(res, 400, "name is required");
if (LayoutProfile::nameExists(repo_.app().state, name))
return HttpUtil::jsonError(res, 409, "layout name already exists");
const bool clone = payload.contains("clone") && payload["clone"].is_boolean() && payload["clone"].get<bool>();
nlohmann::json layout = LayoutSchema::defaultLayoutObject();
nlohmann::json lidars = nlohmann::json::array();
nlohmann::json imus = nlohmann::json::array();
if (clone)
{
layout = repo_.app().state["layout"];
lidars = repo_.app().state["lidars"];
imus = repo_.app().state.contains("imus") && repo_.app().state["imus"].is_array() ? repo_.app().state["imus"] : nlohmann::json::array();
}
nlohmann::json profile = LayoutProfile::make(name, layout, lidars, imus);
LayoutSchema::ensure(profile["layout"]);
if (!repo_.saveProfile(profile))
return HttpUtil::jsonError(res, 500, "failed to save layout file");
repo_.app().state["layouts"].push_back(LayoutProfile::catalogEntryFromProfile(profile));
repo_.app().state["active_layout_id"] = profile["id"].get<std::string>();
repo_.reloadActiveCache();
repo_.save();
res.status = 201;
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = profile.dump();
});
svr.Post(R"(/api/layouts/([0-9a-fA-F]+)/activate)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1].str();
repo_.ensureSchema();
if (!LayoutProfile::findIndex(repo_.app().state, id))
return HttpUtil::jsonError(res, 404, "layout not found");
repo_.app().state["active_layout_id"] = id;
repo_.reloadActiveCache();
repo_.save();
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"ok", true}, {"active_layout_id", id}}).dump();
});
svr.Delete(R"(/api/layouts/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1].str();
repo_.ensureSchema();
if (!repo_.app().state.contains("layouts") || !repo_.app().state["layouts"].is_array())
return HttpUtil::jsonError(res, 404, "layout not found");
if (repo_.app().state["layouts"].size() <= 1)
return HttpUtil::jsonError(res, 400, "cannot delete the last layout");
const auto idx = LayoutProfile::findIndex(repo_.app().state, id);
if (!idx)
return HttpUtil::jsonError(res, 404, "layout not found");
const bool was_active =
repo_.app().state.contains("active_layout_id") && repo_.app().state["active_layout_id"].get<std::string>() == id;
repo_.deleteProfile(id);
repo_.app().state["layouts"].erase(repo_.app().state["layouts"].begin() + static_cast<nlohmann::json::difference_type>(*idx));
if (was_active)
repo_.app().state["active_layout_id"] = repo_.app().state["layouts"][0]["id"].get<std::string>();
repo_.reloadActiveCache();
repo_.save();
res.status = 204;
});
svr.Get("/api/lidars", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = repo_.app().state["lidars"].dump();
});
svr.Post("/api/lidars", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
if (!SensorValidator::validateLidarPayload(payload, err))
return HttpUtil::jsonError(res, 400, err);
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
const std::string ip = StringUtil::trimCopy(payload["ip"].get<std::string>());
const int port = payload["port"].get<int>();
if (SensorValidator::lidarTripletExists(repo_.app().state, name, ip, port))
return HttpUtil::jsonError(res, 409, "lidar with same name, ip and port already exists");
nlohmann::json lidar = {
{"id", IdUtil::newId()},
{"name", name},
{"ip", ip},
{"port", port},
};
repo_.app().state["lidars"].push_back(lidar);
if (!repo_.saveAppState())
return HttpUtil::jsonError(res, 500, "failed to save layout");
res.status = 201;
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = lidar.dump();
});
svr.Put(R"(/api/lidars/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1].str();
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
if (!SensorValidator::validateLidarPayload(payload, err))
return HttpUtil::jsonError(res, 400, err);
auto idx = SensorValidator::findLidarIndex(repo_.app().state, id);
if (!idx)
return HttpUtil::jsonError(res, 404, "lidar not found");
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
const std::string ip = StringUtil::trimCopy(payload["ip"].get<std::string>());
const int port = payload["port"].get<int>();
if (SensorValidator::lidarTripletExists(repo_.app().state, name, ip, port, &id))
return HttpUtil::jsonError(res, 409, "lidar with same name, ip and port already exists");
auto& lidar = repo_.app().state["lidars"][*idx];
lidar["name"] = name;
lidar["ip"] = ip;
lidar["port"] = port;
if (!repo_.saveAppState())
return HttpUtil::jsonError(res, 500, "failed to save layout");
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = lidar.dump();
});
svr.Delete(R"(/api/lidars/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1].str();
auto idx = SensorValidator::findLidarIndex(repo_.app().state, id);
if (!idx)
return HttpUtil::jsonError(res, 404, "lidar not found");
repo_.app().state["lidars"].erase(repo_.app().state["lidars"].begin() + static_cast<nlohmann::json::difference_type>(*idx));
// Also remove pose entry if present
if (repo_.app().state.contains("layout") && repo_.app().state["layout"].is_object())
{
if (repo_.app().state["layout"].contains("lidarPoses") && repo_.app().state["layout"]["lidarPoses"].is_object())
repo_.app().state["layout"]["lidarPoses"].erase(id);
if (repo_.app().state["layout"].contains("lidarPositions") && repo_.app().state["layout"]["lidarPositions"].is_object())
repo_.app().state["layout"]["lidarPositions"].erase(id);
}
if (!repo_.saveAppState())
return HttpUtil::jsonError(res, 500, "failed to save layout");
res.status = 204;
});
svr.Get("/api/layout", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = repo_.app().state["layout"].dump();
});
svr.Put("/api/layout", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
if (!payload.is_object())
return HttpUtil::jsonError(res, 400, "layout must be an object");
repo_.app().state["layout"] = payload;
if (!repo_.saveAppState())
return HttpUtil::jsonError(res, 500, "failed to save layout");
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = repo_.app().state["layout"].dump();
});
svr.Put(R"(/api/layouts/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1].str();
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
if (!payload.is_object())
return HttpUtil::jsonError(res, 400, "payload must be an object");
repo_.ensureSchema();
const auto idx = LayoutProfile::findIndex(repo_.app().state, id);
if (!idx)
return HttpUtil::jsonError(res, 404, "layout not found");
auto loaded = repo_.loadProfileById(id);
if (!loaded)
return HttpUtil::jsonError(res, 404, "layout file not found");
nlohmann::json profile = *loaded;
if (payload.contains("name") && payload["name"].is_string())
{
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
if (name.empty())
return HttpUtil::jsonError(res, 400, "name is required");
if (LayoutProfile::nameExists(repo_.app().state, name, &id))
return HttpUtil::jsonError(res, 409, "layout name already exists");
profile["name"] = name;
}
if (payload.contains("layout") && payload["layout"].is_object())
profile["layout"] = payload["layout"];
if (payload.contains("lidars") && payload["lidars"].is_array())
profile["lidars"] = payload["lidars"];
if (payload.contains("imus") && payload["imus"].is_array())
profile["imus"] = payload["imus"];
if (!profile.contains("imus") || !profile["imus"].is_array())
profile["imus"] = nlohmann::json::array();
LayoutSchema::ensure(profile["layout"]);
LayoutProfile::touch(profile);
if (!repo_.saveProfile(profile))
return HttpUtil::jsonError(res, 500, "failed to save layout file");
repo_.app().state["layouts"][*idx] = LayoutProfile::catalogEntryFromProfile(profile);
const bool is_active =
repo_.app().state.contains("active_layout_id") && repo_.app().state["active_layout_id"].get<std::string>() == id;
if (is_active)
{
repo_.app().state["layout"] = profile["layout"];
repo_.app().state["lidars"] = profile["lidars"];
repo_.app().state["imus"] = profile["imus"];
}
repo_.save();
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = profile.dump();
});
svr.Get("/api/imus", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
repo_.ensureSchema();
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = (repo_.app().state.contains("imus") ? repo_.app().state["imus"] : nlohmann::json::array()).dump();
});
svr.Post("/api/imus", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
if (!SensorValidator::validateImuPayload(payload, err))
return HttpUtil::jsonError(res, 400, err);
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
const std::string frame_id = StringUtil::trimCopy(payload["frame_id"].get<std::string>());
const std::string topic = StringUtil::trimCopy(payload["topic"].get<std::string>());
if (SensorValidator::imuFrameExists(repo_.app().state, frame_id))
return HttpUtil::jsonError(res, 409, "imu with same frame_id already exists");
if (!repo_.app().state.contains("imus") || !repo_.app().state["imus"].is_array())
repo_.app().state["imus"] = nlohmann::json::array();
const std::string source =
payload.contains("source") && payload["source"].is_string() ? payload["source"].get<std::string>()
: "external";
const bool enabled = !payload.contains("enabled") || payload["enabled"].get<bool>();
const double rate_hz =
payload.contains("rate_hz") && payload["rate_hz"].is_number() ? payload["rate_hz"].get<double>() : 100.0;
nlohmann::json imu = {{"id", IdUtil::newId()},
{"name", name},
{"frame_id", frame_id},
{"topic", topic},
{"source", source},
{"enabled", enabled},
{"rate_hz", rate_hz}};
repo_.app().state["imus"].push_back(imu);
if (!repo_.saveAppState())
return HttpUtil::jsonError(res, 500, "failed to save layout");
res.status = 201;
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = imu.dump();
});
svr.Put(R"(/api/imus/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1].str();
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
if (!SensorValidator::validateImuPayload(payload, err))
return HttpUtil::jsonError(res, 400, err);
auto idx = SensorValidator::findImuIndex(repo_.app().state, id);
if (!idx)
return HttpUtil::jsonError(res, 404, "imu not found");
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
const std::string frame_id = StringUtil::trimCopy(payload["frame_id"].get<std::string>());
const std::string topic = StringUtil::trimCopy(payload["topic"].get<std::string>());
if (SensorValidator::imuFrameExists(repo_.app().state, frame_id, &id))
return HttpUtil::jsonError(res, 409, "imu with same frame_id already exists");
auto& imu = repo_.app().state["imus"][*idx];
imu["name"] = name;
imu["frame_id"] = frame_id;
imu["topic"] = topic;
if (payload.contains("source") && payload["source"].is_string())
imu["source"] = payload["source"];
if (payload.contains("enabled") && payload["enabled"].is_boolean())
imu["enabled"] = payload["enabled"];
if (payload.contains("rate_hz") && payload["rate_hz"].is_number())
imu["rate_hz"] = payload["rate_hz"];
if (!repo_.saveAppState())
return HttpUtil::jsonError(res, 500, "failed to save layout");
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = imu.dump();
});
svr.Delete(R"(/api/imus/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1].str();
auto idx = SensorValidator::findImuIndex(repo_.app().state, id);
if (!idx)
return HttpUtil::jsonError(res, 404, "imu not found");
repo_.app().state["imus"].erase(repo_.app().state["imus"].begin() + static_cast<nlohmann::json::difference_type>(*idx));
if (repo_.app().state.contains("layout") && repo_.app().state["layout"].is_object())
{
if (repo_.app().state["layout"].contains("imuPoses") && repo_.app().state["layout"]["imuPoses"].is_object())
repo_.app().state["layout"]["imuPoses"].erase(id);
}
if (!repo_.saveAppState())
return HttpUtil::jsonError(res, 500, "failed to save layout");
res.status = 204;
});
svr.Get("/api/mission_queue", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"queue", mission_queue_.list()}, {"runner", mission_queue_.runnerStatus()}}).dump();
});
svr.Post("/api/mission_queue", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
if (!payload.contains("source"))
payload["source"] = "ui";
enqueueRequest(payload, res, 201);
});
svr.Delete("/api/mission_queue", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!mission_queue_.clearAll(err))
return HttpUtil::jsonError(res, 400, err);
res.status = 204;
});
svr.Put("/api/mission_queue/reorder", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
if (!payload.contains("ordered_ids") || !payload["ordered_ids"].is_array())
return HttpUtil::jsonError(res, 400, "ordered_ids is required");
std::string err;
if (!mission_queue_.reorder(payload["ordered_ids"], err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"ok", true}}).dump();
});
svr.Delete(R"(/api/mission_queue/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1].str();
std::string err;
if (!mission_queue_.removeById(id, err))
return HttpUtil::jsonError(res, 400, err);
res.status = 204;
});
svr.Post("/api/mission_queue/pause", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!mission_queue_.pause(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = mission_queue_.runnerStatus().dump();
});
svr.Post("/api/mission_queue/continue", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!mission_queue_.resume(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
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);
registerIntegrationRoutes(svr);
registerMirV2Routes(svr);
registerRobotRoutes(svr);
registerMediaRoutes(svr);
registerDashboardRoutes(svr);
}
} // namespace lm

57
src/server/api_server.hpp Normal file
View File

@@ -0,0 +1,57 @@
#pragma once
#include <httplib.h>
#include "mission/mission_queue.hpp"
#include "mission/mission_scheduler.hpp"
#include "mission/mission_store.hpp"
#include "mission/modbus_trigger_service.hpp"
#include "robot/robot_runtime.hpp"
#include "storage/dashboard_store.hpp"
#include "storage/map_store.hpp"
#include "storage/site_store.hpp"
#include "storage/sound_store.hpp"
#include "storage/state_repository.hpp"
namespace lm {
class ApiServer
{
public:
ApiServer(StateRepository& repo,
MissionQueue& mission_queue,
MissionStore& mission_store,
ModbusTriggerService& modbus,
MissionScheduler& scheduler,
RobotRuntime& robot_runtime,
MapStore& map_store,
SiteStore& site_store,
SoundStore& sound_store,
DashboardStore& dashboard_store);
void registerRoutes(httplib::Server& svr);
private:
StateRepository& repo_;
MissionQueue& mission_queue_;
MissionStore& mission_store_;
ModbusTriggerService& modbus_;
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);
std::optional<nlohmann::json> enqueueMission(const nlohmann::json& request, std::string& err);
nlohmann::json toMirQueueEntry(const nlohmann::json& entry) const;
void registerMissionRoutes(httplib::Server& svr);
void registerMirV2Routes(httplib::Server& svr);
void registerIntegrationRoutes(httplib::Server& svr);
void registerRobotRoutes(httplib::Server& svr);
void registerMediaRoutes(httplib::Server& svr);
void registerDashboardRoutes(httplib::Server& svr);
};
} // namespace lm

View File

@@ -0,0 +1,49 @@
#include "server/static_file_server.hpp"
#include "util/file_util.hpp"
#include "util/http_util.hpp"
namespace fs = std::filesystem;
namespace lm {
void StaticFileServer::mount(httplib::Server& svr, const fs::path& www_root)
{
svr.Get(R"(/(.*))", [www_root](const httplib::Request& req, httplib::Response& res) {
std::string rel = req.matches.size() >= 2 ? req.matches[1].str() : "";
if (rel.empty())
rel = "index.html";
if (rel.find("..") != std::string::npos)
{
res.status = 400;
res.set_content("Bad path", "text/plain; charset=utf-8");
return;
}
fs::path file_path = www_root / rel;
if (fs::is_directory(file_path))
file_path /= "index.html";
std::error_code ec;
if (!fs::exists(file_path, ec) || ec)
{
res.status = 404;
res.set_content("Not Found", "text/plain; charset=utf-8");
return;
}
const auto body = FileUtil::readBinary(file_path);
if (body.empty())
{
res.status = 500;
res.set_content("Failed to read file", "text/plain; charset=utf-8");
return;
}
res.set_header("Cache-Control", "no-store");
res.set_content(body, HttpUtil::contentTypeForPath(file_path));
});
}
} // namespace lm

View File

@@ -0,0 +1,15 @@
#pragma once
#include <httplib.h>
#include <filesystem>
namespace lm {
class StaticFileServer
{
public:
static void mount(httplib::Server& svr, const std::filesystem::path& www_root);
};
} // namespace lm

View File

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

View File

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

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

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

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

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

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

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

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

@@ -0,0 +1,42 @@
#pragma once
#include <nlohmann/json.hpp>
#include <filesystem>
#include <mutex>
#include <optional>
#include <string>
namespace lm {
class Database;
class MapStore
{
public:
MapStore(Database& db);
nlohmann::json list() const;
std::optional<nlohmann::json> find(const std::string& id) const;
std::optional<nlohmann::json> create(const nlohmann::json& payload, std::string& err);
bool update(const std::string& id, const nlohmann::json& payload, std::string& err);
bool remove(const std::string& id, std::string& err);
std::filesystem::path mapDir(const std::string& id) const;
std::optional<std::filesystem::path> imagePath(const std::string& id) const;
/** Scan/original floor plan (map_base.png); falls back to composite image if missing. */
std::optional<std::filesystem::path> baseImagePath(const std::string& id) const;
std::optional<std::filesystem::path> yamlPath(const std::string& id) const;
bool saveImageFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
/** Save flattened composite (map.png) without touching map_base.png. */
bool saveCompositeImageFile(const std::string& id, const std::string& bytes, std::string& err);
/** Save base scan layer (map_base.png) only. */
bool saveBaseImageFile(const std::string& id, const std::string& bytes, std::string& err);
bool saveYamlFile(const std::string& id, const std::string& yaml_text, std::string& err);
private:
Database& db_;
mutable std::mutex mu_;
};
} // namespace lm

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

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,318 @@
#include "storage/state_repository.hpp"
#include "domain/layout_profile.hpp"
#include "domain/layout_schema.hpp"
#include "storage/database.hpp"
#include "util/file_util.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
namespace lm {
std::filesystem::path StateRepository::modelsDir() const
{
return app_.data_path.parent_path() / "models";
}
std::filesystem::path StateRepository::profileFilePath(const std::string& id) const
{
return modelsDir() / (id + ".json");
}
std::optional<nlohmann::json> StateRepository::loadProfileFromDisk(const std::string& id) const
{
if (auto profile = db_.getLayoutProfile(id))
return profile;
const auto raw = FileUtil::readBinary(profileFilePath(id));
if (raw.empty())
return std::nullopt;
try
{
return nlohmann::json::parse(raw);
}
catch (...)
{
return std::nullopt;
}
}
bool StateRepository::saveProfileToDisk(const nlohmann::json& profile) const
{
if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string())
return false;
return db_.setLayoutProfile(profile);
}
bool StateRepository::deleteProfileFile(const std::string& id) const
{
db_.deleteLayoutProfile(id);
std::error_code ec;
std::filesystem::remove(profileFilePath(id), ec);
return true;
}
void StateRepository::loadActiveCache()
{
nlohmann::json& state = app_.state;
const auto idx = LayoutProfile::findActiveIndex(state);
if (!idx)
return;
const std::string id = state["layouts"][*idx]["id"].get<std::string>();
nlohmann::json profile;
if (auto loaded = loadProfileFromDisk(id))
profile = *loaded;
else
{
profile = LayoutProfile::make(state["layouts"][*idx]["name"].get<std::string>(),
LayoutSchema::defaultLayoutObject(),
nlohmann::json::array());
profile["id"] = id;
if (state["layouts"][*idx].contains("created_at"))
profile["created_at"] = state["layouts"][*idx]["created_at"];
}
if (!profile.contains("layout") || !profile["layout"].is_object())
profile["layout"] = LayoutSchema::defaultLayoutObject();
if (!profile.contains("lidars") || !profile["lidars"].is_array())
profile["lidars"] = nlohmann::json::array();
if (!profile.contains("imus") || !profile["imus"].is_array())
profile["imus"] = nlohmann::json::array();
LayoutSchema::ensure(profile["layout"]);
state["layout"] = profile["layout"];
state["lidars"] = profile["lidars"];
state["imus"] = profile["imus"];
}
bool StateRepository::persistActiveProfile()
{
nlohmann::json& state = app_.state;
const auto idx = LayoutProfile::findActiveIndex(state);
if (!idx)
return false;
auto& entry = state["layouts"][*idx];
nlohmann::json profile;
profile["id"] = entry["id"];
profile["name"] = entry.contains("name") ? entry["name"] : nlohmann::json("Layout");
profile["created_at"] = entry.value("created_at", IdUtil::nowIso8601());
profile["updated_at"] = IdUtil::nowIso8601();
profile["layout"] = state["layout"];
profile["lidars"] = state["lidars"];
profile["imus"] = state.contains("imus") && state["imus"].is_array() ? state["imus"] : nlohmann::json::array();
LayoutSchema::ensure(profile["layout"]);
if (!saveProfileToDisk(profile))
return false;
entry = LayoutProfile::catalogEntryFromProfile(profile);
return true;
}
nlohmann::json StateRepository::globalStateForDisk(const nlohmann::json& state) const
{
nlohmann::json out = nlohmann::json::object();
out["version"] = 3;
if (state.contains("active_layout_id"))
out["active_layout_id"] = state["active_layout_id"];
out["layouts"] = nlohmann::json::array();
if (state.contains("layouts") && state["layouts"].is_array())
{
for (const auto& entry : state["layouts"])
{
if (!entry.is_object() || !entry.contains("id") || !entry.contains("name"))
continue;
if (entry.contains("layout"))
out["layouts"].push_back(LayoutProfile::catalogEntryFromProfile(entry));
else
out["layouts"].push_back(entry);
}
}
return out;
}
void StateRepository::stripInlineProfiles(nlohmann::json& state) const
{
if (!state.contains("layouts") || !state["layouts"].is_array())
return;
nlohmann::json catalog = nlohmann::json::array();
for (const auto& entry : state["layouts"])
{
if (!entry.is_object() || !entry.contains("id"))
continue;
if (entry.contains("layout"))
catalog.push_back(LayoutProfile::catalogEntryFromProfile(entry));
else
catalog.push_back(entry);
}
state["layouts"] = catalog;
}
void StateRepository::migrateStorage()
{
nlohmann::json& s = app_.state;
if (!s.is_object())
s = nlohmann::json::object();
const int version = s.contains("version") && s["version"].is_number_integer() ? s["version"].get<int>() : 1;
if (!s.contains("layouts") || !s["layouts"].is_array() || s["layouts"].empty())
{
nlohmann::json layout = s.contains("layout") && s["layout"].is_object() ? s["layout"] : LayoutSchema::defaultLayoutObject();
nlohmann::json lidars = s.contains("lidars") && s["lidars"].is_array() ? s["lidars"] : nlohmann::json::array();
nlohmann::json profile = LayoutProfile::make("Mặc định", layout, lidars);
LayoutSchema::ensure(profile["layout"]);
saveProfileToDisk(profile);
s["layouts"] = nlohmann::json::array({LayoutProfile::catalogEntryFromProfile(profile)});
s["active_layout_id"] = profile["id"].get<std::string>();
}
else if (version < 3)
{
if (!s.contains("active_layout_id") || !s["active_layout_id"].is_string() ||
!LayoutProfile::findIndex(s, s["active_layout_id"].get<std::string>()))
{
s["active_layout_id"] = s["layouts"][0]["id"].get<std::string>();
}
nlohmann::json catalog = nlohmann::json::array();
for (auto& entry : s["layouts"])
{
if (!entry.is_object() || !entry.contains("id"))
continue;
nlohmann::json profile;
if (entry.contains("layout"))
{
profile = entry;
if (!profile.contains("name"))
profile["name"] = "Layout";
if (!profile.contains("lidars") || !profile["lidars"].is_array())
profile["lidars"] = nlohmann::json::array();
if (!profile.contains("created_at"))
profile["created_at"] = IdUtil::nowIso8601();
LayoutProfile::touch(profile);
LayoutSchema::ensure(profile["layout"]);
saveProfileToDisk(profile);
catalog.push_back(LayoutProfile::catalogEntryFromProfile(profile));
}
else
{
const std::string id = entry["id"].get<std::string>();
if (auto loaded = loadProfileFromDisk(id))
{
catalog.push_back(LayoutProfile::catalogEntryFromProfile(*loaded));
}
else
{
profile = LayoutProfile::make(entry["name"].get<std::string>(), LayoutSchema::defaultLayoutObject(), nlohmann::json::array());
profile["id"] = id;
profile["created_at"] = entry.value("created_at", IdUtil::nowIso8601());
LayoutProfile::touch(profile);
saveProfileToDisk(profile);
catalog.push_back(LayoutProfile::catalogEntryFromProfile(profile));
}
}
}
s["layouts"] = catalog;
}
else
{
stripInlineProfiles(s);
if (!s.contains("active_layout_id") || !s["active_layout_id"].is_string() ||
!LayoutProfile::findIndex(s, s["active_layout_id"].get<std::string>()))
{
s["active_layout_id"] = s["layouts"][0]["id"].get<std::string>();
}
}
s["version"] = 3;
s.erase("layout");
s.erase("lidars");
loadActiveCache();
}
void StateRepository::bootstrapDefaultState()
{
const nlohmann::json layout = LayoutSchema::defaultLayoutObject();
nlohmann::json profile = LayoutProfile::make("Mặc định", layout, nlohmann::json::array());
LayoutSchema::ensure(profile["layout"]);
saveProfileToDisk(profile);
app_.state = nlohmann::json{{"version", 3},
{"active_layout_id", profile["id"]},
{"layouts", nlohmann::json::array({LayoutProfile::catalogEntryFromProfile(profile)})}};
app_.state["layout"] = profile["layout"];
app_.state["lidars"] = profile["lidars"];
app_.state["imus"] = profile.contains("imus") ? profile["imus"] : nlohmann::json::array();
}
StateRepository::StateRepository(std::filesystem::path data_path, Database& db) : db_(db)
{
app_.data_path = std::move(data_path);
}
bool StateRepository::load()
{
if (!db_.getDocument("state", app_.state))
{
bootstrapDefaultState();
save();
return true;
}
try
{
ensureSchema();
save();
return true;
}
catch (...)
{
bootstrapDefaultState();
save();
return false;
}
}
void StateRepository::ensureSchema()
{
migrateStorage();
}
bool StateRepository::saveProfile(const nlohmann::json& profile)
{
return saveProfileToDisk(profile);
}
void StateRepository::reloadActiveCache()
{
loadActiveCache();
}
bool StateRepository::deleteProfile(const std::string& id)
{
return deleteProfileFile(id);
}
std::optional<nlohmann::json> StateRepository::loadProfileById(const std::string& id) const
{
return loadProfileFromDisk(id);
}
bool StateRepository::saveAppState()
{
if (!persistActiveProfile())
return false;
return save();
}
bool StateRepository::save() const
{
try
{
const nlohmann::json disk = globalStateForDisk(app_.state);
return db_.setDocument("state", disk);
}
catch (...)
{
return false;
}
}
} // namespace lm

View File

@@ -0,0 +1,52 @@
#pragma once
#include "app/app_state.hpp"
#include <nlohmann/json.hpp>
#include <filesystem>
#include <optional>
#include <string>
namespace lm {
class Database;
class StateRepository
{
public:
StateRepository(std::filesystem::path data_path, Database& db);
AppState& app() { return app_; }
const AppState& app() const { return app_; }
bool load();
bool save() const;
bool saveAppState();
void ensureSchema();
bool saveProfile(const nlohmann::json& profile);
void reloadActiveCache();
bool deleteProfile(const std::string& id);
std::optional<nlohmann::json> loadProfileById(const std::string& id) const;
private:
AppState app_;
Database& db_;
std::filesystem::path modelsDir() const;
std::filesystem::path profileFilePath(const std::string& id) const;
std::optional<nlohmann::json> loadProfileFromDisk(const std::string& id) const;
bool saveProfileToDisk(const nlohmann::json& profile) const;
bool deleteProfileFile(const std::string& id) const;
void loadActiveCache();
bool persistActiveProfile();
nlohmann::json globalStateForDisk(const nlohmann::json& state) const;
void stripInlineProfiles(nlohmann::json& state) const;
void migrateStorage();
void bootstrapDefaultState();
};
} // namespace lm

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

47
src/util/file_util.cpp Normal file
View File

@@ -0,0 +1,47 @@
#include "util/file_util.hpp"
#include <fstream>
#include <sstream>
namespace fs = std::filesystem;
namespace lm {
std::string FileUtil::readBinary(const fs::path& path)
{
std::ifstream in(path, std::ios::binary);
if (!in)
return {};
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
bool FileUtil::writeBinaryAtomic(const fs::path& path, const std::string& contents)
{
fs::create_directories(path.parent_path());
auto tmp = path;
tmp += ".tmp";
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
if (!out)
return false;
out.write(contents.data(), static_cast<std::streamsize>(contents.size()));
out.flush();
if (!out)
return false;
}
std::error_code ec;
fs::rename(tmp, path, ec);
if (!ec)
return true;
fs::remove(path, ec);
ec.clear();
fs::rename(tmp, path, ec);
return !ec;
}
} // namespace lm

15
src/util/file_util.hpp Normal file
View File

@@ -0,0 +1,15 @@
#pragma once
#include <filesystem>
#include <string>
namespace lm {
class FileUtil
{
public:
static std::string readBinary(const std::filesystem::path& path);
static bool writeBinaryAtomic(const std::filesystem::path& path, const std::string& contents);
};
} // namespace lm

41
src/util/http_util.cpp Normal file
View File

@@ -0,0 +1,41 @@
#include "util/http_util.hpp"
#include "util/string_util.hpp"
namespace lm {
std::string HttpUtil::contentTypeForPath(const std::filesystem::path& p)
{
const auto ext = StringUtil::toLower(p.extension().string());
if (ext == ".html")
return "text/html; charset=utf-8";
if (ext == ".css")
return "text/css; charset=utf-8";
if (ext == ".js")
return "application/javascript; charset=utf-8";
if (ext == ".json")
return "application/json; charset=utf-8";
if (ext == ".png")
return "image/png";
if (ext == ".svg")
return "image/svg+xml";
if (ext == ".ico")
return "image/x-icon";
return "application/octet-stream";
}
void HttpUtil::jsonError(httplib::Response& res, int status, const std::string& msg)
{
res.status = status;
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"error", msg}}).dump();
}
void HttpUtil::addCors(httplib::Response& res)
{
res.set_header("Access-Control-Allow-Origin", "*");
res.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept-Language");
}
} // namespace lm

19
src/util/http_util.hpp Normal file
View File

@@ -0,0 +1,19 @@
#pragma once
#include <httplib.h>
#include <nlohmann/json.hpp>
#include <filesystem>
#include <string>
namespace lm {
class HttpUtil
{
public:
static std::string contentTypeForPath(const std::filesystem::path& p);
static void jsonError(httplib::Response& res, int status, const std::string& msg);
static void addCors(httplib::Response& res);
};
} // namespace lm

35
src/util/id_util.cpp Normal file
View File

@@ -0,0 +1,35 @@
#include "util/id_util.hpp"
#include <chrono>
#include <ctime>
#include <random>
namespace lm {
std::string IdUtil::newId()
{
static thread_local std::mt19937_64 rng{std::random_device{}()};
static constexpr char kHex[] = "0123456789abcdef";
std::string out;
out.resize(16);
for (int i = 0; i < 16; i++)
out[i] = kHex[static_cast<size_t>(rng() & 0xF)];
return out;
}
std::string IdUtil::nowIso8601()
{
using clock = std::chrono::system_clock;
const std::time_t t = clock::to_time_t(clock::now());
std::tm tm{};
#if defined(_WIN32)
gmtime_s(&tm, &t);
#else
gmtime_r(&t, &tm);
#endif
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm);
return buf;
}
} // namespace lm

14
src/util/id_util.hpp Normal file
View File

@@ -0,0 +1,14 @@
#pragma once
#include <string>
namespace lm {
class IdUtil
{
public:
static std::string newId();
static std::string nowIso8601();
};
} // namespace lm

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

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

View File

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

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

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

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

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

25
src/util/string_util.cpp Normal file
View File

@@ -0,0 +1,25 @@
#include "util/string_util.hpp"
#include <algorithm>
#include <cctype>
namespace lm {
std::string StringUtil::toLower(std::string s)
{
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return s;
}
std::string StringUtil::trimCopy(const std::string& s)
{
size_t a = 0;
while (a < s.size() && std::isspace(static_cast<unsigned char>(s[a])))
a++;
size_t b = s.size();
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1])))
b--;
return s.substr(a, b - a);
}
} // namespace lm

14
src/util/string_util.hpp Normal file
View File

@@ -0,0 +1,14 @@
#pragma once
#include <string>
namespace lm {
class StringUtil
{
public:
static std::string toLower(std::string s);
static std::string trimCopy(const std::string& s);
};
} // namespace lm

View File

@@ -0,0 +1,151 @@
#include "validation/sensor_validator.hpp"
#include "util/string_util.hpp"
namespace lm {
std::optional<size_t> SensorValidator::findLidarIndex(const nlohmann::json& state, const std::string& id)
{
if (!state.contains("lidars") || !state["lidars"].is_array())
return std::nullopt;
const auto& lidars = state["lidars"];
for (size_t i = 0; i < lidars.size(); i++)
{
const auto& l = lidars[i];
if (l.is_object() && l.contains("id") && l["id"].is_string() && l["id"].get<std::string>() == id)
return i;
}
return std::nullopt;
}
std::optional<size_t> SensorValidator::findImuIndex(const nlohmann::json& state, const std::string& id)
{
if (!state.contains("imus") || !state["imus"].is_array())
return std::nullopt;
const auto& imus = state["imus"];
for (size_t i = 0; i < imus.size(); i++)
{
const auto& im = imus[i];
if (im.is_object() && im.contains("id") && im["id"].is_string() && im["id"].get<std::string>() == id)
return i;
}
return std::nullopt;
}
bool SensorValidator::validateLidarPayload(const nlohmann::json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be a JSON object";
return false;
}
if (!payload.contains("name") || !payload["name"].is_string() || payload["name"].get<std::string>().empty())
{
err = "name is required";
return false;
}
if (!payload.contains("ip") || !payload["ip"].is_string() || payload["ip"].get<std::string>().empty())
{
err = "ip is required";
return false;
}
if (!payload.contains("port") || !payload["port"].is_number_integer())
{
err = "port must be an integer";
return false;
}
const int port = payload["port"].get<int>();
if (port < 1 || port > 65535)
{
err = "port must be in range 1..65535";
return false;
}
return true;
}
bool SensorValidator::lidarTripletExists(const nlohmann::json& state,
const std::string& name,
const std::string& ip,
int port,
const std::string* exclude_id)
{
if (!state.contains("lidars") || !state["lidars"].is_array())
return false;
const std::string n = StringUtil::trimCopy(name);
const std::string i = StringUtil::trimCopy(ip);
for (const auto& l : state["lidars"])
{
if (!l.is_object())
continue;
if (exclude_id && l.contains("id") && l["id"].get<std::string>() == *exclude_id)
continue;
if (!l.contains("name") || !l.contains("ip") || !l.contains("port"))
continue;
if (StringUtil::trimCopy(l["name"].get<std::string>()) == n && StringUtil::trimCopy(l["ip"].get<std::string>()) == i &&
l["port"].get<int>() == port)
return true;
}
return false;
}
bool SensorValidator::validateImuPayload(const nlohmann::json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be a JSON object";
return false;
}
if (!payload.contains("name") || !payload["name"].is_string() || payload["name"].get<std::string>().empty())
{
err = "name is required";
return false;
}
if (!payload.contains("frame_id") || !payload["frame_id"].is_string() ||
payload["frame_id"].get<std::string>().empty())
{
err = "frame_id is required";
return false;
}
if (!payload.contains("topic") || !payload["topic"].is_string() || payload["topic"].get<std::string>().empty())
{
err = "topic is required";
return false;
}
if (payload.contains("source") && payload["source"].is_string())
{
const std::string src = payload["source"].get<std::string>();
if (src != "external" && src != "lidar_builtin" && src != "onboard")
{
err = "source must be external, lidar_builtin, or onboard";
return false;
}
}
if (payload.contains("rate_hz") && !payload["rate_hz"].is_number())
{
err = "rate_hz must be a number";
return false;
}
return true;
}
bool SensorValidator::imuFrameExists(const nlohmann::json& state,
const std::string& frame_id,
const std::string* exclude_id)
{
if (!state.contains("imus") || !state["imus"].is_array())
return false;
const std::string f = StringUtil::trimCopy(frame_id);
for (const auto& im : state["imus"])
{
if (!im.is_object())
continue;
if (exclude_id && im.contains("id") && im["id"].get<std::string>() == *exclude_id)
continue;
if (!im.contains("frame_id"))
continue;
if (StringUtil::trimCopy(im["frame_id"].get<std::string>()) == f)
return true;
}
return false;
}
} // namespace lm

Some files were not shown because too many files have changed in this diff Show More