This commit is contained in:
2026-09-09 16:31:27 +03:00
commit 0a8894f3b2
100 changed files with 5091 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.log
*.tsbuildinfo
.env
+127
View File
@@ -0,0 +1,127 @@
# Модуль Г — веб-интерфейс FMS
Одностраничная панель управления двумя роверами. Стек: React, TypeScript, Vite,
Tailwind CSS 4, Bun и небольшой ROS 2-мост на Python.
## Что реализовано
- выбор RMC1 или RMC2;
- положение, скорость и история движения обоих роверов;
- ArUco-граф 6 × 6 с координатами маркеров 0–35;
- карта занятости RMC1 из `/map`;
- облако точек `/RMC*/scan`, повернутое и перенесённое вместе с ровером;
- план Nav2 из `/RMC*/plan` и текущая целевая точка;
- ручное управление кнопками и клавишами W/A/S/D, для RMC1 также Q/E;
- watchdog: после отпускания клавиши или потери окна скорость обнуляется;
- установка текущей позиции через `/RMC*/initialpose` без движения ровера;
- автономная цель через Nav2, с простым контроллером по одометрии как fallback;
- независимый аварийный стоп с отменой активной Nav2-задачи;
- схват RMC1 и лифт RMC2;
- вольтаж из стандартных ROS-топиков, если такой топик доступен в окружении;
- WebSocket-телеметрия с частотой 5 Гц;
- mock-режим для проверки вебки без ROS/Webots.
## Запуск на VM
Нужен Bun и уже собранное ROS 2-окружение соревнования.
```bash
cd ~/scripts/mvch/module4
bun install
bun run build
```
В каждом ROS-терминале сначала подключить Jazzy и workspace:
```bash
source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/setup.bash
```
1. Запустить карту с двумя роверами:
```bash
ros2 launch ar_webots_fms_ros2 module5.launch.py
```
2. Для полноценной автономной навигации RMC1 запустить Nav2:
```bash
ros2 launch ar_nav_ros2 bringup_launch.py namespace:=/RMC1 robot_type:=rmc1
```
Если Nav2 в образе не установлен или action недоступен, ROS-мост автоматически
использует простой контроллер по одометрии. Он доводит оба ровера до x/y/yaw, но
не строит обход сложных препятствий, поэтому Nav2 для RMC1 предпочтительнее.
3. В третьем, также ROS-sourced терминале запустить FMS:
```bash
cd ~/scripts/mvch/module4
bun run start
```
Открыть <http://localhost:3000>. Сервер Bun сам запускает
`server/ros_bridge.py`; отдельно стартовать Python-файл не нужно.
Для разработки с Vite HMR:
```bash
bun run dev
```
Страница будет на <http://localhost:5173>, API — на порту 3001.
## Быстрая проверка без симулятора
```bash
bun run build
MOCK_ROS=1 bun run start
```
Mock не используется на соревновании. Он нужен только для проверки кнопок,
WebSocket и компоновки интерфейса.
## ROS-топики
| Назначение | RMC1 | RMC2 |
| --- | --- | --- |
| Одометрия | `/RMC1/odometry` | `/RMC2/odometry` |
| Лидар | `/RMC1/scan` | `/RMC2/scan` |
| Ручная скорость | `/RMC1/cmd_vel` | `/RMC2/cmd_vel` |
| Начальная позиция | `/RMC1/initialpose` | `/RMC2/initialpose` |
| Цель | `/RMC1/goal_pose` | `/RMC2/goal_pose` |
| План | `/RMC1/plan` | `/RMC2/plan` |
| Рабочий инструмент | `.../gripper_trajectory_controller/joint_trajectory` | `/RMC2/lift` |
Дополнительно используются `/map`, `/RMC2/aruco_id` и
`/RMC2/lift_status`.
Симулятор из открытого пакета не публикует напряжение аккумуляторов. Мост уже
подписан на `/RMC*/battery_state` (`sensor_msgs/BatteryState`) и
`/RMC*/battery_voltage` (`std_msgs/Float64`). Если на площадке топика тоже не
будет, интерфейс честно покажет `нет топика`, а не фиктивное значение.
## HTTP API
- `GET /api/health` — состояние Bun и ROS-моста;
- `GET /api/state` — последний снимок телеметрии;
- `POST /api/command` — команда управления;
- `WS /ws` — телеметрия в реальном времени.
Пример остановки RMC1:
```bash
curl -X POST http://localhost:3000/api/command \
-H 'content-type: application/json' \
-d '{"type":"emergency","robot":"RMC1","active":true}'
```
## Проверки
```bash
bun run check
bun test
bun run build
python3 -m py_compile server/ros_bridge.py
```
+298
View File
@@ -0,0 +1,298 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "mvch-module4-fms",
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"@vitejs/plugin-react-swc": "^3.11.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"tailwindcss": "^4.2.2",
"vite": "^6.4.2",
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "~5.8.3",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.1", "", { "os": "android", "cpu": "arm" }, "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.1", "", { "os": "android", "cpu": "arm64" }, "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.1", "", { "os": "none", "cpu": "arm64" }, "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w=="],
"@swc/core": ["@swc/core@1.16.2", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.28" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.16.2", "@swc/core-darwin-x64": "1.16.2", "@swc/core-linux-arm-gnueabihf": "1.16.2", "@swc/core-linux-arm64-gnu": "1.16.2", "@swc/core-linux-arm64-musl": "1.16.2", "@swc/core-linux-ppc64-gnu": "1.16.2", "@swc/core-linux-s390x-gnu": "1.16.2", "@swc/core-linux-x64-gnu": "1.16.2", "@swc/core-linux-x64-musl": "1.16.2", "@swc/core-win32-arm64-msvc": "1.16.2", "@swc/core-win32-ia32-msvc": "1.16.2", "@swc/core-win32-x64-msvc": "1.16.2" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.16.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.16.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.16.2", "", { "os": "linux", "cpu": "arm" }, "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.16.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.16.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw=="],
"@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.16.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w=="],
"@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.16.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.16.2", "", { "os": "linux", "cpu": "x64" }, "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.16.2", "", { "os": "linux", "cpu": "x64" }, "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.16.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.16.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.16.2", "", { "os": "win32", "cpu": "x64" }, "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.28", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="],
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
"@types/react-dom": ["@types/react-dom@19.2.7", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ=="],
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.11.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.27", "@swc/core": "^1.12.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w=="],
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" />
<title>MVCH FMS - модуль Г</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "mvch-module4-fms",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "bun run scripts/dev.ts",
"mock": "MOCK_ROS=1 bun run scripts/dev.ts",
"build": "tsc -b && vite build",
"start": "bun run server/index.ts",
"run": "bun run build && bun run start",
"check": "tsc -b",
"test": "bun test"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"@vitejs/plugin-react-swc": "^3.11.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"tailwindcss": "^4.2.2",
"vite": "^6.4.2"
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "~5.8.3"
}
}
+18
View File
@@ -0,0 +1,18 @@
const bun = process.execPath
const env = { ...process.env, NODE_ENV: 'development' }
const backend = Bun.spawn([bun, 'run', 'server/index.ts'], { env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' })
const frontend = Bun.spawn([bun, 'x', 'vite'], { env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' })
async function stop(): Promise<void> {
backend.kill()
frontend.kill()
}
process.on('SIGINT', stop)
process.on('SIGTERM', stop)
const result = await Promise.race([backend.exited, frontend.exited])
await stop()
process.exit(result)
export {}
+42
View File
@@ -0,0 +1,42 @@
import type { Command, Pose, RobotId } from '../src/types'
const ROBOTS: RobotId[] = ['RMC1', 'RMC2']
function isRobot(value: unknown): value is RobotId {
return typeof value === 'string' && ROBOTS.includes(value as RobotId)
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function isPose(value: unknown): value is Pose {
if (!value || typeof value !== 'object') return false
const pose = value as Record<string, unknown>
return isFiniteNumber(pose.x) && isFiniteNumber(pose.y) && isFiniteNumber(pose.yaw)
}
export function isCommand(value: unknown): value is Command {
if (!value || typeof value !== 'object') return false
const command = value as Record<string, unknown>
if (!isRobot(command.robot) || typeof command.type !== 'string') return false
switch (command.type) {
case 'manual':
return isFiniteNumber(command.x) && isFiniteNumber(command.y) && isFiniteNumber(command.yaw)
case 'stop':
case 'cancel_goal':
return true
case 'emergency':
return typeof command.active === 'boolean'
case 'set_pose':
case 'set_goal':
return isPose(command.pose)
case 'lift':
return command.robot === 'RMC2' && (command.height === 0 || command.height === 0.1)
case 'gripper':
return command.robot === 'RMC1' && (command.state === 'open' || command.state === 'closed')
default:
return false
}
}
+133
View File
@@ -0,0 +1,133 @@
import { dirname, join } from 'node:path'
import type { Server, ServerWebSocket } from 'bun'
import type { Command, FmsState } from '../src/types'
import { isCommand } from './commands'
import { applyMockCommand, initialState, tickMock } from './state'
type WsData = { connectedAt: number }
const moduleDir = dirname(dirname(import.meta.path))
const port = Number(process.env.PORT ?? (process.env.NODE_ENV === 'development' ? 3001 : 3000))
const mock = process.env.MOCK_ROS === '1'
let state = initialState(mock ? 'mock' : 'ros')
let bridge: Bun.Subprocess<'pipe', 'pipe', 'inherit'> | null = null
function json(data: unknown, status = 200): Response {
return Response.json(data, { status, headers: { 'cache-control': 'no-store' } })
}
function sendToBridge(command: Command): boolean {
if (mock) {
applyMockCommand(state, command)
return true
}
if (!bridge?.stdin) return false
bridge.stdin.write(`${JSON.stringify(command)}\n`)
bridge.stdin.flush()
return true
}
function broadcast(server: Server<WsData>): void {
server.publish('telemetry', JSON.stringify(state))
}
async function readBridge(server: Server<WsData>): Promise<void> {
if (!bridge?.stdout) return
const decoder = new TextDecoder()
let pending = ''
for await (const chunk of bridge.stdout) {
pending += decoder.decode(chunk, { stream: true })
const lines = pending.split('\n')
pending = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try {
const next = JSON.parse(line) as FmsState
if (next.type === 'state') {
state = next
state.mode = 'ros'
state.bridgeOnline = true
state.bridgeError = null
broadcast(server)
}
} catch {
console.error(`[ROS bridge] invalid JSON: ${line.slice(0, 180)}`)
}
}
}
state.bridgeOnline = false
state.bridgeError = `ROS bridge stopped (code ${await bridge.exited})`
broadcast(server)
}
function startBridge(server: Server<WsData>): void {
if (mock) {
setInterval(() => {
tickMock(state)
broadcast(server)
}, 100)
return
}
bridge = Bun.spawn(['python3', join(moduleDir, 'server', 'ros_bridge.py')], {
cwd: moduleDir,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'inherit',
env: { ...process.env, PYTHONUNBUFFERED: '1' },
})
void readBridge(server)
}
const server = Bun.serve<WsData>({
port,
hostname: '0.0.0.0',
async fetch(request, server) {
const url = new URL(request.url)
if (url.pathname === '/ws') {
return server.upgrade(request, { data: { connectedAt: Date.now() } })
? undefined
: new Response('WebSocket upgrade failed', { status: 400 })
}
if (url.pathname === '/api/health') {
return json({ ok: true, mode: state.mode, bridgeOnline: state.bridgeOnline, timestamp: Date.now() })
}
if (url.pathname === '/api/state') return json(state)
if (url.pathname === '/api/command' && request.method === 'POST') {
let body: unknown
try {
body = await request.json()
} catch {
return json({ ok: false, error: 'invalid JSON' }, 400)
}
if (!isCommand(body)) return json({ ok: false, error: 'invalid command' }, 400)
if (!sendToBridge(body)) return json({ ok: false, error: 'ROS bridge is offline' }, 503)
broadcast(server)
return json({ ok: true })
}
const relative = url.pathname === '/' ? 'index.html' : url.pathname.slice(1)
if (relative.includes('..')) return new Response('Bad path', { status: 400 })
const file = Bun.file(join(moduleDir, 'dist', relative))
if (await file.exists()) return new Response(file)
const fallback = Bun.file(join(moduleDir, 'dist', 'index.html'))
return (await fallback.exists()) ? new Response(fallback) : new Response('Run bun run build first', { status: 404 })
},
websocket: {
open(ws) {
ws.subscribe('telemetry')
ws.send(JSON.stringify(state))
},
message(ws: ServerWebSocket<WsData>, message) {
try {
const command: unknown = JSON.parse(String(message))
if (isCommand(command)) sendToBridge(command)
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'invalid command' }))
}
},
},
})
startBridge(server)
console.log(`MVCH FMS: http://0.0.0.0:${port} (${mock ? 'mock' : 'ROS'})`)
+504
View File
@@ -0,0 +1,504 @@
#!/usr/bin/env python3
"""Small JSON <-> ROS 2 bridge for the module G web interface.
stdin: one JSON command per line
stdout: one JSON state per line (only machine-readable data)
stderr: diagnostics
"""
import json
import math
import queue
import sys
import threading
import time
from copy import deepcopy
import rclpy
from builtin_interfaces.msg import Duration
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist
from nav_msgs.msg import OccupancyGrid, Odometry, Path
from rclpy.action import ActionClient
from rclpy.executors import ExternalShutdownException
from rclpy.node import Node
from rclpy.qos import DurabilityPolicy, HistoryPolicy, QoSProfile, ReliabilityPolicy
from sensor_msgs.msg import BatteryState, LaserScan
from std_msgs.msg import Float64, String
from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint
try:
from nav2_msgs.action import NavigateToPose
except ImportError:
NavigateToPose = None
ROBOTS = ("RMC1", "RMC2")
MANUAL_TIMEOUT = 0.35
def empty_robot():
return {
"online": False,
"lastMessageMs": None,
"pose": {"x": 0.0, "y": 0.0, "yaw": 0.0},
"velocity": {"x": 0.0, "y": 0.0, "yaw": 0.0},
"battery": None,
"lidar": [],
"trail": [],
"plan": [],
"goal": None,
"emergency": False,
"arucoId": None,
"liftStatus": None,
"gripper": "unknown",
}
def yaw_from_quaternion(q):
siny = 2.0 * (q.w * q.z + q.x * q.y)
cosy = 1.0 - 2.0 * (q.y * q.y + q.z * q.z)
return math.atan2(siny, cosy)
def quaternion_from_yaw(yaw):
return 0.0, 0.0, math.sin(yaw / 2.0), math.cos(yaw / 2.0)
def wrap_angle(angle):
return math.atan2(math.sin(angle), math.cos(angle))
def clamp(value, limit):
return max(-limit, min(limit, value))
class FmsBridge(Node):
def __init__(self):
super().__init__("mvch_fms_bridge")
self.commands = queue.Queue()
self.lock = threading.Lock()
self.state = {
"type": "state",
"timestamp": int(time.time() * 1000),
"mode": "ros",
"bridgeOnline": True,
"bridgeError": None,
"map": None,
"robots": {robot: empty_robot() for robot in ROBOTS},
}
self.last_seen = {robot: 0.0 for robot in ROBOTS}
self.last_manual = {robot: 0.0 for robot in ROBOTS}
self.last_sent_zero = {robot: True for robot in ROBOTS}
self.pose_offsets = {robot: {"x": 0.0, "y": 0.0, "yaw": 0.0} for robot in ROBOTS}
self.raw_pose = {robot: {"x": 0.0, "y": 0.0, "yaw": 0.0} for robot in ROBOTS}
self.goal_handles = {robot: None for robot in ROBOTS}
self.simple_goals = {robot: None for robot in ROBOTS}
sensor_qos = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
durability=DurabilityPolicy.VOLATILE,
history=HistoryPolicy.KEEP_LAST,
depth=5,
)
map_qos = QoSProfile(
reliability=ReliabilityPolicy.RELIABLE,
durability=DurabilityPolicy.TRANSIENT_LOCAL,
history=HistoryPolicy.KEEP_LAST,
depth=1,
)
self.cmd_publishers = {}
self.pose_publishers = {}
self.goal_publishers = {}
self.nav_clients = {}
for robot in ROBOTS:
self.cmd_publishers[robot] = self.create_publisher(Twist, f"/{robot}/cmd_vel", 10)
self.pose_publishers[robot] = self.create_publisher(
PoseWithCovarianceStamped, f"/{robot}/initialpose", 10
)
self.goal_publishers[robot] = self.create_publisher(PoseStamped, f"/{robot}/goal_pose", 10)
if NavigateToPose is not None:
self.nav_clients[robot] = ActionClient(
self, NavigateToPose, f"/{robot}/navigate_to_pose"
)
self.create_subscription(
Odometry,
f"/{robot}/odometry",
lambda msg, r=robot: self.on_odometry(r, msg),
sensor_qos,
)
self.create_subscription(
LaserScan,
f"/{robot}/scan",
lambda msg, r=robot: self.on_scan(r, msg),
sensor_qos,
)
self.create_subscription(
Path,
f"/{robot}/plan",
lambda msg, r=robot: self.on_path(r, msg),
10,
)
self.create_subscription(
BatteryState,
f"/{robot}/battery_state",
lambda msg, r=robot: self.on_battery(r, msg.voltage),
sensor_qos,
)
self.create_subscription(
Float64,
f"/{robot}/battery_voltage",
lambda msg, r=robot: self.on_battery(r, msg.data),
sensor_qos,
)
self.create_subscription(OccupancyGrid, "/map", self.on_map, map_qos)
self.create_subscription(OccupancyGrid, "/RMC1/map", self.on_map, map_qos)
self.create_subscription(String, "/RMC2/aruco_id", self.on_aruco, sensor_qos)
self.create_subscription(String, "/RMC2/lift_status", self.on_lift_status, 10)
self.lift_publisher = self.create_publisher(Float64, "/RMC2/lift", 10)
self.gripper_publisher = self.create_publisher(
JointTrajectory,
"/RMC1/arm95/gripper_trajectory_controller/joint_trajectory",
10,
)
self.create_timer(0.05, self.process_commands_and_watchdog)
self.create_timer(0.2, self.emit_state)
def mark_seen(self, robot):
self.last_seen[robot] = time.monotonic()
def on_odometry(self, robot, msg):
raw = {
"x": float(msg.pose.pose.position.x),
"y": float(msg.pose.pose.position.y),
"yaw": yaw_from_quaternion(msg.pose.pose.orientation),
}
with self.lock:
self.raw_pose[robot] = raw
offset = self.pose_offsets[robot]
pose = {
"x": raw["x"] + offset["x"],
"y": raw["y"] + offset["y"],
"yaw": raw["yaw"] + offset["yaw"],
}
rover = self.state["robots"][robot]
rover["pose"] = pose
rover["velocity"] = {
"x": float(msg.twist.twist.linear.x),
"y": float(msg.twist.twist.linear.y),
"yaw": float(msg.twist.twist.angular.z),
}
trail = rover["trail"]
if not trail or math.hypot(pose["x"] - trail[-1]["x"], pose["y"] - trail[-1]["y"]) > 0.04:
trail.append({"x": pose["x"], "y": pose["y"]})
del trail[:-250]
self.mark_seen(robot)
def on_scan(self, robot, msg):
with self.lock:
pose = self.state["robots"][robot]["pose"]
points = []
stride = max(1, len(msg.ranges) // 180)
for index in range(0, len(msg.ranges), stride):
distance = float(msg.ranges[index])
if not math.isfinite(distance) or distance < msg.range_min or distance > msg.range_max:
continue
angle = msg.angle_min + index * msg.angle_increment + pose["yaw"]
points.append(
{
"x": pose["x"] + math.cos(angle) * distance,
"y": pose["y"] + math.sin(angle) * distance,
}
)
self.state["robots"][robot]["lidar"] = points[:200]
self.mark_seen(robot)
def on_path(self, robot, msg):
with self.lock:
self.state["robots"][robot]["plan"] = [
{"x": float(p.pose.position.x), "y": float(p.pose.position.y)}
for p in msg.poses[:: max(1, len(msg.poses) // 200)]
][:200]
def on_map(self, msg):
width, height = int(msg.info.width), int(msg.info.height)
if not width or not height:
return
step = max(1, math.ceil(max(width, height) / 120))
sampled = []
for y in range(0, height, step):
for x in range(0, width, step):
sampled.append(int(msg.data[y * width + x]))
with self.lock:
self.state["map"] = {
"width": math.ceil(width / step),
"height": math.ceil(height / step),
"resolution": float(msg.info.resolution) * step,
"origin": {
"x": float(msg.info.origin.position.x),
"y": float(msg.info.origin.position.y),
},
"data": sampled,
}
def on_battery(self, robot, value):
if math.isfinite(float(value)):
with self.lock:
self.state["robots"][robot]["battery"] = round(float(value), 2)
def on_aruco(self, msg):
with self.lock:
self.state["robots"]["RMC2"]["arucoId"] = str(msg.data)
self.mark_seen("RMC2")
def on_lift_status(self, msg):
with self.lock:
self.state["robots"]["RMC2"]["liftStatus"] = str(msg.data)
def publish_twist(self, robot, x=0.0, y=0.0, yaw=0.0):
msg = Twist()
msg.linear.x = max(-0.7, min(0.7, float(x)))
msg.linear.y = max(-0.7, min(0.7, float(y))) if robot == "RMC1" else 0.0
msg.angular.z = max(-1.2, min(1.2, float(yaw)))
self.cmd_publishers[robot].publish(msg)
def process_commands_and_watchdog(self):
while True:
try:
command = self.commands.get_nowait()
except queue.Empty:
break
try:
self.handle_command(command)
except Exception as error:
print(f"command error: {error}", file=sys.stderr, flush=True)
now = time.monotonic()
with self.lock:
emergency = {r: self.state["robots"][r]["emergency"] for r in ROBOTS}
simple_goals = deepcopy(self.simple_goals)
for robot in ROBOTS:
stale = now - self.last_manual[robot] > MANUAL_TIMEOUT
if emergency[robot]:
self.publish_twist(robot)
self.last_sent_zero[robot] = True
elif simple_goals[robot] is not None:
self.drive_simple_goal(robot, simple_goals[robot])
elif stale and not self.last_sent_zero[robot]:
self.publish_twist(robot)
self.last_sent_zero[robot] = True
def handle_command(self, command):
kind = command.get("type")
robot = command.get("robot")
if robot not in ROBOTS:
return
with self.lock:
emergency = self.state["robots"][robot]["emergency"]
if kind == "manual" and not emergency:
self.cancel_navigation(robot)
with self.lock:
self.simple_goals[robot] = None
self.state["robots"][robot]["goal"] = None
self.state["robots"][robot]["plan"] = []
self.publish_twist(robot, command.get("x", 0), command.get("y", 0), command.get("yaw", 0))
self.last_manual[robot] = time.monotonic()
self.last_sent_zero[robot] = False
elif kind == "stop":
self.cancel_navigation(robot)
with self.lock:
self.simple_goals[robot] = None
self.publish_twist(robot)
self.last_sent_zero[robot] = True
elif kind == "emergency":
active = bool(command.get("active"))
with self.lock:
self.state["robots"][robot]["emergency"] = active
self.publish_twist(robot)
self.last_sent_zero[robot] = True
if active:
self.cancel_navigation(robot)
with self.lock:
self.simple_goals[robot] = None
elif kind == "set_pose":
self.set_pose(robot, command.get("pose", {}))
elif kind == "set_goal" and not emergency:
self.set_goal(robot, command.get("pose", {}))
elif kind == "cancel_goal":
self.cancel_navigation(robot)
self.publish_twist(robot)
with self.lock:
self.simple_goals[robot] = None
self.state["robots"][robot]["goal"] = None
self.state["robots"][robot]["plan"] = []
elif kind == "lift" and robot == "RMC2" and not emergency:
height = 0.1 if float(command.get("height", 0)) >= 0.05 else 0.0
self.lift_publisher.publish(Float64(data=height))
with self.lock:
self.state["robots"][robot]["liftStatus"] = "command: up" if height else "command: down"
elif kind == "gripper" and robot == "RMC1" and not emergency:
self.set_gripper(command.get("state") == "open")
def set_pose(self, robot, pose):
x, y, yaw = float(pose.get("x", 0)), float(pose.get("y", 0)), float(pose.get("yaw", 0))
with self.lock:
raw = self.raw_pose[robot]
self.pose_offsets[robot] = {"x": x - raw["x"], "y": y - raw["y"], "yaw": yaw - raw["yaw"]}
self.state["robots"][robot]["pose"] = {"x": x, "y": y, "yaw": yaw}
self.state["robots"][robot]["trail"] = [{"x": x, "y": y}]
msg = PoseWithCovarianceStamped()
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = "map"
msg.pose.pose.position.x = x
msg.pose.pose.position.y = y
qx, qy, qz, qw = quaternion_from_yaw(yaw)
msg.pose.pose.orientation.x, msg.pose.pose.orientation.y = qx, qy
msg.pose.pose.orientation.z, msg.pose.pose.orientation.w = qz, qw
msg.pose.covariance[0] = msg.pose.covariance[7] = 0.25
msg.pose.covariance[35] = 0.07
self.pose_publishers[robot].publish(msg)
def make_goal(self, pose):
goal = PoseStamped()
goal.header.stamp = self.get_clock().now().to_msg()
goal.header.frame_id = "map"
goal.pose.position.x = float(pose.get("x", 0))
goal.pose.position.y = float(pose.get("y", 0))
qx, qy, qz, qw = quaternion_from_yaw(float(pose.get("yaw", 0)))
goal.pose.orientation.x, goal.pose.orientation.y = qx, qy
goal.pose.orientation.z, goal.pose.orientation.w = qz, qw
return goal
def set_goal(self, robot, pose):
goal = self.make_goal(pose)
self.goal_publishers[robot].publish(goal)
target = {"x": goal.pose.position.x, "y": goal.pose.position.y, "yaw": float(pose.get("yaw", 0))}
with self.lock:
rover = self.state["robots"][robot]
rover["goal"] = target
if not rover["plan"]:
rover["plan"] = [
{"x": rover["pose"]["x"], "y": rover["pose"]["y"]},
{"x": target["x"], "y": target["y"]},
]
client = self.nav_clients.get(robot)
if client is not None and client.server_is_ready():
request = NavigateToPose.Goal()
request.pose = goal
future = client.send_goal_async(request)
future.add_done_callback(lambda f, r=robot: self.on_goal_response(r, f))
else:
with self.lock:
self.simple_goals[robot] = target
print(f"{robot}: Nav2 is unavailable; using odometry goal controller", file=sys.stderr, flush=True)
def drive_simple_goal(self, robot, target):
with self.lock:
pose = deepcopy(self.state["robots"][robot]["pose"])
dx = target["x"] - pose["x"]
dy = target["y"] - pose["y"]
distance = math.hypot(dx, dy)
if distance < 0.12:
yaw_error = wrap_angle(target["yaw"] - pose["yaw"])
if abs(yaw_error) < 0.12:
self.publish_twist(robot)
with self.lock:
self.simple_goals[robot] = None
self.last_sent_zero[robot] = True
else:
self.publish_twist(robot, yaw=clamp(yaw_error * 1.5, 0.65))
self.last_sent_zero[robot] = False
return
if robot == "RMC1":
cosine, sine = math.cos(pose["yaw"]), math.sin(pose["yaw"])
body_x = cosine * dx + sine * dy
body_y = -sine * dx + cosine * dy
self.publish_twist(
robot,
x=clamp(body_x * 0.7, 0.38),
y=clamp(body_y * 0.7, 0.38),
yaw=clamp(wrap_angle(target["yaw"] - pose["yaw"]), 0.45),
)
else:
heading_error = wrap_angle(math.atan2(dy, dx) - pose["yaw"])
if abs(heading_error) > 0.28:
self.publish_twist(robot, yaw=clamp(heading_error * 1.4, 0.7))
else:
self.publish_twist(
robot,
x=min(0.32, max(0.10, distance * 0.65)),
yaw=clamp(heading_error * 1.2, 0.45),
)
self.last_sent_zero[robot] = False
def on_goal_response(self, robot, future):
handle = future.result()
if handle.accepted:
self.goal_handles[robot] = handle
else:
print(f"{robot}: Nav2 rejected goal", file=sys.stderr, flush=True)
def cancel_navigation(self, robot):
handle = self.goal_handles.get(robot)
if handle is not None:
handle.cancel_goal_async()
self.goal_handles[robot] = None
def set_gripper(self, opened):
msg = JointTrajectory()
msg.joint_names = ["left_joint", "right_joint"]
point = JointTrajectoryPoint()
value = 0.04 if opened else 0.0213
point.positions = [value, value]
point.time_from_start = Duration(sec=1)
msg.points = [point]
self.gripper_publisher.publish(msg)
with self.lock:
self.state["robots"]["RMC1"]["gripper"] = "open" if opened else "closed"
def emit_state(self):
now = time.monotonic()
with self.lock:
for robot in ROBOTS:
age = now - self.last_seen[robot]
self.state["robots"][robot]["online"] = age < 2.0
self.state["robots"][robot]["lastMessageMs"] = round(age * 1000) if self.last_seen[robot] else None
self.state["timestamp"] = int(time.time() * 1000)
payload = deepcopy(self.state)
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), flush=True)
def read_commands(node):
for line in sys.stdin:
try:
command = json.loads(line)
if isinstance(command, dict):
node.commands.put(command)
except json.JSONDecodeError as error:
print(f"invalid command JSON: {error}", file=sys.stderr, flush=True)
def main():
rclpy.init()
node = FmsBridge()
threading.Thread(target=read_commands, args=(node,), daemon=True).start()
try:
rclpy.spin(node)
except (KeyboardInterrupt, ExternalShutdownException):
pass
finally:
if rclpy.ok():
for robot in ROBOTS:
node.publish_twist(robot)
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
main()
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'bun:test'
import { isCommand } from './commands'
import { applyMockCommand, initialState, tickMock } from './state'
describe('command validation', () => {
test('accepts valid commands', () => {
expect(isCommand({ type: 'manual', robot: 'RMC1', x: 0.4, y: 0.2, yaw: 0 })).toBe(true)
expect(isCommand({ type: 'lift', robot: 'RMC2', height: 0.1 })).toBe(true)
expect(isCommand({ type: 'gripper', robot: 'RMC1', state: 'closed' })).toBe(true)
})
test('rejects wrong robot tools and non-finite coordinates', () => {
expect(isCommand({ type: 'lift', robot: 'RMC1', height: 0.1 })).toBe(false)
expect(isCommand({ type: 'gripper', robot: 'RMC2', state: 'open' })).toBe(false)
expect(isCommand({ type: 'set_goal', robot: 'RMC1', pose: { x: Number.NaN, y: 0, yaw: 0 } })).toBe(false)
})
})
describe('mock state', () => {
test('emergency stop blocks motion and tool state is retained', () => {
const state = initialState('mock')
applyMockCommand(state, { type: 'manual', robot: 'RMC2', x: 0.5, y: 0, yaw: 0 })
tickMock(state, 1)
expect(state.robots.RMC2.pose.x).toBeCloseTo(0.5)
applyMockCommand(state, { type: 'lift', robot: 'RMC2', height: 0.1 })
applyMockCommand(state, { type: 'emergency', robot: 'RMC2', active: true })
expect(state.robots.RMC2.velocity.x).toBe(0)
expect(state.robots.RMC2.liftStatus).toBe('up')
})
})
+116
View File
@@ -0,0 +1,116 @@
import type { Command, FmsState, Point, RobotId, RobotState } from '../src/types'
const robot = (x: number, y: number): RobotState => ({
online: false,
lastMessageMs: null,
pose: { x, y, yaw: 0 },
velocity: { x: 0, y: 0, yaw: 0 },
battery: null,
lidar: [],
trail: [{ x, y }],
plan: [],
goal: null,
emergency: false,
arucoId: null,
liftStatus: null,
gripper: 'unknown',
})
export function initialState(mode: 'ros' | 'mock'): FmsState {
const mockMap = mode === 'mock' ? makeMockMap() : null
return {
type: 'state',
timestamp: Date.now(),
mode,
bridgeOnline: mode === 'mock',
bridgeError: null,
map: mockMap,
robots: { RMC1: robot(-2, 3), RMC2: robot(0, 0) },
}
}
function makeMockMap() {
const width = 60
const height = 60
const data = Array<number>(width * height).fill(0)
for (let row = 0; row < height; row += 1) {
for (let col = 0; col < width; col += 1) {
const border = row < 2 || col < 2 || row > height - 3 || col > width - 3
const shelf = col >= 7 && col <= 10 && row >= 8 && row <= 46
if (border || shelf) data[row * width + col] = 100
}
}
return { width, height, resolution: 0.1, origin: { x: -5.5, y: -0.5 }, data }
}
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
export function applyMockCommand(state: FmsState, command: Command): void {
const rover = state.robots[command.robot]
if (!rover) return
if (command.type === 'emergency') {
rover.emergency = command.active
rover.velocity = { x: 0, y: 0, yaw: 0 }
} else if (command.type === 'stop' || command.type === 'cancel_goal') {
rover.velocity = { x: 0, y: 0, yaw: 0 }
if (command.type === 'cancel_goal') {
rover.goal = null
rover.plan = []
}
} else if (command.type === 'manual' && !rover.emergency) {
rover.velocity = {
x: clamp(command.x, -0.7, 0.7),
y: command.robot === 'RMC1' ? clamp(command.y, -0.7, 0.7) : 0,
yaw: clamp(command.yaw, -1.2, 1.2),
}
} else if (command.type === 'set_pose') {
rover.pose = command.pose
rover.trail = [{ x: command.pose.x, y: command.pose.y }]
} else if (command.type === 'set_goal') {
rover.goal = command.pose
rover.plan = [
{ x: rover.pose.x, y: rover.pose.y },
{ x: command.pose.x, y: command.pose.y },
]
} else if (command.type === 'lift') {
rover.liftStatus = command.height > 0 ? 'up' : 'down'
} else if (command.type === 'gripper') {
rover.gripper = command.state
}
state.timestamp = Date.now()
}
export function tickMock(state: FmsState, dt = 0.1): void {
const now = Date.now()
for (const id of ['RMC1', 'RMC2'] as RobotId[]) {
const rover = state.robots[id]
rover.online = true
rover.lastMessageMs = 0
rover.battery = id === 'RMC1' ? 24.4 : 23.9
rover.pose.yaw += rover.velocity.yaw * dt
const c = Math.cos(rover.pose.yaw)
const s = Math.sin(rover.pose.yaw)
rover.pose.x += (rover.velocity.x * c - rover.velocity.y * s) * dt
rover.pose.y += (rover.velocity.x * s + rover.velocity.y * c) * dt
if (rover.trail.length === 0 || Math.hypot(rover.pose.x - rover.trail.at(-1)!.x, rover.pose.y - rover.trail.at(-1)!.y) > 0.03) {
rover.trail.push({ x: rover.pose.x, y: rover.pose.y })
if (rover.trail.length > 180) rover.trail.shift()
}
const points: Point[] = []
for (let angle = 0; angle < Math.PI * 2; angle += Math.PI / 28) {
const distance = 1.0 + 0.35 * Math.sin(angle * 3 + now / 1200)
points.push({
x: rover.pose.x + Math.cos(angle + rover.pose.yaw) * distance,
y: rover.pose.y + Math.sin(angle + rover.pose.yaw) * distance,
})
}
rover.lidar = points
if (id === 'RMC2') {
const row = Math.max(0, Math.min(5, Math.round(-rover.pose.x)))
const col = Math.max(0, Math.min(5, Math.round(rover.pose.y)))
rover.arucoId = String(row * 6 + col)
}
}
state.timestamp = now
}
+395
View File
@@ -0,0 +1,395 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Command, FmsState, Point, Pose, RobotId, RobotState } from './types'
const WORLD = { minX: -5.5, maxX: 0.5, minY: -0.5, maxY: 5.5 }
const SVG_SIZE = 600
const emptyRobot: RobotState = {
online: false,
lastMessageMs: null,
pose: { x: 0, y: 0, yaw: 0 },
velocity: { x: 0, y: 0, yaw: 0 },
battery: null,
lidar: [],
trail: [],
plan: [],
goal: null,
emergency: false,
arucoId: null,
liftStatus: null,
gripper: 'unknown',
}
const initialState: FmsState = {
type: 'state',
timestamp: 0,
mode: 'ros',
bridgeOnline: false,
bridgeError: null,
map: null,
robots: { RMC1: emptyRobot, RMC2: emptyRobot },
}
function worldToSvg(point: Point): Point {
return {
x: ((point.y - WORLD.minY) / (WORLD.maxY - WORLD.minY)) * SVG_SIZE,
y: ((point.x - WORLD.minX) / (WORLD.maxX - WORLD.minX)) * SVG_SIZE,
}
}
function points(pointsValue: Point[]): string {
return pointsValue.map(worldToSvg).map((point) => `${point.x},${point.y}`).join(' ')
}
function format(value: number | null, digits = 2): string {
return value == null || !Number.isFinite(value) ? '—' : value.toFixed(digits)
}
function Card({ title, children, className = '' }: { title: string; children: React.ReactNode; className?: string }) {
return (
<section className={`rounded-xl border border-slate-700 bg-slate-900/80 p-4 ${className}`}>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-400">{title}</h2>
{children}
</section>
)
}
function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: number) => void }) {
return (
<label className="grid gap-1 text-xs text-slate-400">
{label}
<input
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-2 py-2 text-sm text-slate-100 outline-none focus:border-cyan-500"
type="number"
step="0.1"
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
)
}
function FieldMap({ state, selected }: { state: FmsState; selected: RobotId }) {
const occupied = useMemo(() => {
if (!state.map) return []
const result: { x: number; y: number; size: number; value: number }[] = []
for (let row = 0; row < state.map.height; row += 1) {
for (let col = 0; col < state.map.width; col += 1) {
const value = state.map.data[row * state.map.width + col]
if (value > 25) {
result.push({
x: state.map.origin.x + col * state.map.resolution,
y: state.map.origin.y + row * state.map.resolution,
size: state.map.resolution,
value,
})
}
}
}
return result.slice(0, 5000)
}, [state.map])
const markers = Array.from({ length: 36 }, (_, id) => ({
id,
x: -Math.floor(id / 6),
y: id % 6,
}))
return (
<div className="relative aspect-square min-h-[420px] overflow-hidden rounded-xl border border-slate-700 bg-slate-950">
<svg viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`} className="h-full w-full" aria-label="Карта поля и телеметрия роверов">
<rect width={SVG_SIZE} height={SVG_SIZE} fill="#020617" />
{occupied.map((cell, index) => {
const topLeft = worldToSvg({ x: cell.x, y: cell.y })
const bottomRight = worldToSvg({ x: cell.x + cell.size, y: cell.y + cell.size })
return (
<rect
key={`cell-${index}`}
x={Math.min(topLeft.x, bottomRight.x)}
y={Math.min(topLeft.y, bottomRight.y)}
width={Math.max(1, Math.abs(bottomRight.x - topLeft.x))}
height={Math.max(1, Math.abs(bottomRight.y - topLeft.y))}
fill={cell.value > 65 ? '#334155' : '#1e293b'}
opacity="0.7"
/>
)
})}
{Array.from({ length: 6 }, (_, row) => Array.from({ length: 6 }, (_, col) => {
const here = worldToSvg({ x: -row, y: col })
const right = col < 5 ? worldToSvg({ x: -row, y: col + 1 }) : null
const down = row < 5 ? worldToSvg({ x: -(row + 1), y: col }) : null
return (
<g key={`edges-${row}-${col}`} stroke="#1e3a5f" strokeWidth="2">
{right && <line x1={here.x} y1={here.y} x2={right.x} y2={right.y} />}
{down && <line x1={here.x} y1={here.y} x2={down.x} y2={down.y} />}
</g>
)
}))}
{(['RMC1', 'RMC2'] as RobotId[]).map((id) => {
const rover = state.robots[id]
const color = id === 'RMC1' ? '#22d3ee' : '#f59e0b'
const center = worldToSvg(rover.pose)
const direction = rover.pose.yaw - Math.PI / 2
return (
<g key={id} opacity={id === selected ? 1 : 0.55}>
{rover.trail.length > 1 && <polyline points={points(rover.trail)} fill="none" stroke={color} strokeWidth="3" opacity="0.45" />}
{rover.plan.length > 1 && <polyline points={points(rover.plan)} fill="none" stroke="#a78bfa" strokeWidth="4" strokeDasharray="10 7" />}
{rover.lidar.map((point, index) => {
const p = worldToSvg(point)
return <circle key={`${id}-lidar-${index}`} cx={p.x} cy={p.y} r="2.2" fill={color} opacity="0.55" />
})}
{rover.goal && (() => {
const goal = worldToSvg(rover.goal)
return <g><circle cx={goal.x} cy={goal.y} r="12" fill="none" stroke="#a78bfa" strokeWidth="3" /><line x1={goal.x - 16} y1={goal.y} x2={goal.x + 16} y2={goal.y} stroke="#a78bfa" /><line x1={goal.x} y1={goal.y - 16} x2={goal.x} y2={goal.y + 16} stroke="#a78bfa" /></g>
})()}
<g transform={`translate(${center.x} ${center.y}) rotate(${direction * 180 / Math.PI})`}>
<circle r={id === selected ? 18 : 14} fill={color} stroke="#f8fafc" strokeWidth={id === selected ? 3 : 1} />
<path d="M 0 -25 L -7 -10 L 7 -10 Z" fill="#f8fafc" />
</g>
<text x={center.x + 21} y={center.y - 18} fill={color} fontSize="14" fontWeight="700">{id}</text>
</g>
)
})}
{markers.map((marker) => {
const point = worldToSvg(marker)
const current = state.robots.RMC2.arucoId === String(marker.id)
return (
<g key={marker.id}>
<rect x={point.x - 11} y={point.y - 11} width="22" height="22" rx="4" fill={current ? '#f59e0b' : '#0f172a'} stroke={current ? '#fbbf24' : '#475569'} strokeWidth="2" />
<text x={point.x} y={point.y + 4} textAnchor="middle" fill="#f8fafc" fontSize="10">{marker.id}</text>
</g>
)
})}
</svg>
<div className="absolute left-3 top-3 rounded-lg bg-slate-950/80 px-3 py-2 text-xs text-slate-300 backdrop-blur">
ArUco: шаг 1 м · X вниз · Y вправо
</div>
<div className="absolute bottom-3 right-3 flex gap-3 rounded-lg bg-slate-950/80 px-3 py-2 text-xs backdrop-blur">
<span className="text-cyan-300">● RMC1</span><span className="text-amber-300">● RMC2</span><span className="text-violet-300">-- маршрут</span>
</div>
</div>
)
}
function ManualControls({ robot, emergency, send }: { robot: RobotId; emergency: boolean; send: (command: Command) => void }) {
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
const stop = useCallback(() => {
if (timer.current) clearInterval(timer.current)
timer.current = null
send({ type: 'stop', robot })
}, [robot, send])
const start = (x: number, y: number, yaw: number) => {
if (emergency) return
if (timer.current) clearInterval(timer.current)
const command: Command = { type: 'manual', robot, x, y, yaw }
send(command)
timer.current = setInterval(() => send(command), 120)
}
useEffect(() => stop, [stop])
const button = 'select-none rounded-lg border border-slate-600 bg-slate-800 px-3 py-3 font-semibold text-slate-100 hover:bg-slate-700 active:bg-cyan-700 disabled:cursor-not-allowed disabled:opacity-40'
const handlers = (x: number, y: number, yaw: number) => ({
onPointerDown: (event: React.PointerEvent<HTMLButtonElement>) => {
event.currentTarget.setPointerCapture(event.pointerId)
start(x, y, yaw)
},
onPointerUp: stop,
onPointerCancel: stop,
})
return (
<div className="grid grid-cols-3 gap-2">
<button className={button} disabled={robot === 'RMC2' || emergency} {...handlers(0, 0.35, 0)}>↖ бок</button>
<button className={button} disabled={emergency} {...handlers(0.45, 0, 0)}>↑ W</button>
<button className={button} disabled={robot === 'RMC2' || emergency} {...handlers(0, -0.35, 0)}>бок ↗</button>
<button className={button} disabled={emergency} {...handlers(0, 0, 0.75)}>↶ A</button>
<button className={`${button} border-red-700 bg-red-950 text-red-200`} onClick={stop}>STOP</button>
<button className={button} disabled={emergency} {...handlers(0, 0, -0.75)}>D ↷</button>
<button className={button} disabled={robot === 'RMC2' || emergency} {...handlers(0, -0.35, 0)}>↙ бок</button>
<button className={button} disabled={emergency} {...handlers(-0.45, 0, 0)}>↓ S</button>
<button className={button} disabled={robot === 'RMC2' || emergency} {...handlers(0, 0.35, 0)}>бок ↘</button>
</div>
)
}
export default function App() {
const [state, setState] = useState<FmsState>(initialState)
const [selected, setSelected] = useState<RobotId>('RMC1')
const [socketOnline, setSocketOnline] = useState(false)
const [message, setMessage] = useState('Подключение…')
const [poseForm, setPoseForm] = useState<Pose>({ x: 0, y: 0, yaw: 0 })
const [goalForm, setGoalForm] = useState<Pose>({ x: -2, y: 3, yaw: 0 })
const send = useCallback(async (command: Command) => {
try {
const response = await fetch('/api/command', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(command),
})
const result = await response.json() as { ok: boolean; error?: string }
setMessage(result.ok ? `Команда: ${command.type}` : `Ошибка: ${result.error}`)
} catch (error) {
setMessage(`API недоступен: ${String(error)}`)
}
}, [])
useEffect(() => {
let socket: WebSocket | null = null
let retry: ReturnType<typeof setTimeout>
let disposed = false
const connect = () => {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
socket = new WebSocket(`${protocol}//${location.host}/ws`)
socket.onopen = () => { setSocketOnline(true); setMessage('Телеметрия подключена') }
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as FmsState
if (data.type === 'state') setState(data)
} catch { /* ignore malformed packet */ }
}
socket.onclose = () => {
setSocketOnline(false)
if (!disposed) retry = setTimeout(connect, 1200)
}
}
connect()
return () => { disposed = true; clearTimeout(retry); socket?.close() }
}, [])
useEffect(() => {
const pressed = new Set<string>()
const movement = () => {
let x = 0, y = 0, yaw = 0
if (pressed.has('w')) x += 0.45
if (pressed.has('s')) x -= 0.45
if (pressed.has('a')) yaw += 0.75
if (pressed.has('d')) yaw -= 0.75
if (selected === 'RMC1' && pressed.has('q')) y += 0.35
if (selected === 'RMC1' && pressed.has('e')) y -= 0.35
if (x || y || yaw) send({ type: 'manual', robot: selected, x, y, yaw })
else send({ type: 'stop', robot: selected })
}
const down = (event: KeyboardEvent) => {
if ((event.target as HTMLElement).tagName === 'INPUT') return
const key = event.key.toLowerCase()
if (!['w', 'a', 's', 'd', 'q', 'e'].includes(key)) return
event.preventDefault()
pressed.add(key)
movement()
}
const up = (event: KeyboardEvent) => {
const key = event.key.toLowerCase()
if (!pressed.delete(key)) return
movement()
}
const blur = () => { pressed.clear(); movement() }
const repeat = setInterval(() => { if (pressed.size) movement() }, 120)
window.addEventListener('keydown', down)
window.addEventListener('keyup', up)
window.addEventListener('blur', blur)
return () => {
clearInterval(repeat)
window.removeEventListener('keydown', down)
window.removeEventListener('keyup', up)
window.removeEventListener('blur', blur)
}
}, [selected, send])
const rover = state.robots[selected]
const fresh = Date.now() - state.timestamp < 1500
const setForm = (setter: React.Dispatch<React.SetStateAction<Pose>>, key: keyof Pose, value: number) => setter((current) => ({ ...current, [key]: value }))
return (
<main className="min-h-screen bg-slate-950 p-3 text-slate-100 lg:p-5">
<header className="mx-auto mb-4 flex max-w-[1600px] flex-wrap items-center justify-between gap-3">
<div><h1 className="text-xl font-bold">MVCH FMS · модуль Г</h1><p className="text-sm text-slate-400">Управление, навигация и телеметрия RMC1 / RMC2</p></div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className={`rounded-full px-3 py-1.5 ${socketOnline && fresh ? 'bg-emerald-950 text-emerald-300' : 'bg-red-950 text-red-300'}`}>WebSocket: {socketOnline && fresh ? 'online' : 'offline'}</span>
<span className={`rounded-full px-3 py-1.5 ${state.bridgeOnline ? 'bg-emerald-950 text-emerald-300' : 'bg-amber-950 text-amber-300'}`}>Bridge: {state.bridgeOnline ? state.mode : 'offline'}</span>
<span className="max-w-sm truncate rounded-full bg-slate-800 px-3 py-1.5 text-slate-300">{state.bridgeError ?? message}</span>
</div>
</header>
<div className="mx-auto grid max-w-[1600px] gap-4 xl:grid-cols-[minmax(600px,1fr)_420px]">
<div className="grid gap-4">
<div className="flex gap-2">
{(['RMC1', 'RMC2'] as RobotId[]).map((id) => (
<button key={id} onClick={() => { setSelected(id); setPoseForm(state.robots[id].pose) }} className={`flex-1 rounded-xl border px-4 py-3 text-left ${selected === id ? 'border-cyan-500 bg-cyan-950/60' : 'border-slate-700 bg-slate-900'}`}>
<span className="font-bold">{id}</span>
<span className={`ml-3 text-xs ${state.robots[id].online ? 'text-emerald-400' : 'text-red-400'}`}>{state.robots[id].online ? 'ROS online' : 'нет данных'}</span>
</button>
))}
</div>
<FieldMap state={state} selected={selected} />
<Card title="Состояние двух роверов">
<div className="grid grid-cols-2 gap-3 text-sm lg:grid-cols-4">
{(['RMC1', 'RMC2'] as RobotId[]).map((id) => <div key={id} className="rounded-lg bg-slate-950 p-3"><strong>{id}</strong><div className="mt-1 text-slate-400">x {format(state.robots[id].pose.x)} · y {format(state.robots[id].pose.y)}</div><div className="text-slate-400">yaw {format(state.robots[id].pose.yaw)} rad</div></div>)}
<div className="rounded-lg bg-slate-950 p-3"><strong>Карта</strong><div className="mt-1 text-slate-400">{state.map ? `${state.map.width} × ${state.map.height}` : 'нет /map'}</div></div>
<div className="rounded-lg bg-slate-950 p-3"><strong>Обновление</strong><div className="mt-1 text-slate-400">{state.timestamp ? new Date(state.timestamp).toLocaleTimeString() : '—'}</div></div>
</div>
</Card>
</div>
<aside className="grid content-start gap-4">
<Card title={`Ручное управление ${selected}`}>
<ManualControls robot={selected} emergency={rover.emergency} send={send} />
<p className="mt-2 text-xs text-slate-500">Клавиши: W/S — ход, A/D — поворот, Q/E — боковой ход RMC1. При отпускании отправляется STOP.</p>
</Card>
<button onClick={() => send({ type: 'emergency', robot: selected, active: !rover.emergency })} className={`rounded-xl border-2 px-5 py-5 text-lg font-black tracking-wide ${rover.emergency ? 'border-emerald-500 bg-emerald-950 text-emerald-300' : 'border-red-500 bg-red-950 text-red-200'}`}>
{rover.emergency ? 'СНЯТЬ АВАРИЙНЫЙ СТОП' : 'АВАРИЙНЫЙ СТОП'}
</button>
<Card title="Телеметрия">
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-slate-400">Позиция</dt><dd>x {format(rover.pose.x)} · y {format(rover.pose.y)}</dd>
<dt className="text-slate-400">Ориентация</dt><dd>{format(rover.pose.yaw)} rad</dd>
<dt className="text-slate-400">Скорость</dt><dd>{format(rover.velocity.x)} м/с</dd>
<dt className="text-slate-400">Лидар</dt><dd>{rover.lidar.length} точек</dd>
<dt className="text-slate-400">Аккумулятор</dt><dd>{rover.battery == null ? 'нет топика' : `${format(rover.battery)} В`}</dd>
<dt className="text-slate-400">ArUco</dt><dd>{rover.arucoId ?? '—'}</dd>
<dt className="text-slate-400">Инструмент</dt><dd>{selected === 'RMC1' ? rover.gripper : (rover.liftStatus ?? '—')}</dd>
<dt className="text-slate-400">Возраст данных</dt><dd>{rover.lastMessageMs == null ? '—' : `${rover.lastMessageMs} мс`}</dd>
</dl>
</Card>
<Card title="Установить текущее положение">
<div className="grid grid-cols-3 gap-2">
<NumberField label="X, м" value={poseForm.x} onChange={(value) => setForm(setPoseForm, 'x', value)} />
<NumberField label="Y, м" value={poseForm.y} onChange={(value) => setForm(setPoseForm, 'y', value)} />
<NumberField label="Yaw, рад" value={poseForm.yaw} onChange={(value) => setForm(setPoseForm, 'yaw', value)} />
</div>
<button onClick={() => send({ type: 'set_pose', robot: selected, pose: poseForm })} className="mt-3 w-full rounded-lg bg-slate-700 px-4 py-2 font-semibold hover:bg-slate-600">Применить без движения</button>
</Card>
<Card title="Автономная цель Nav2">
<div className="grid grid-cols-3 gap-2">
<NumberField label="X, м" value={goalForm.x} onChange={(value) => setForm(setGoalForm, 'x', value)} />
<NumberField label="Y, м" value={goalForm.y} onChange={(value) => setForm(setGoalForm, 'y', value)} />
<NumberField label="Yaw, рад" value={goalForm.yaw} onChange={(value) => setForm(setGoalForm, 'yaw', value)} />
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<button disabled={rover.emergency} onClick={() => send({ type: 'set_goal', robot: selected, pose: goalForm })} className="rounded-lg bg-violet-700 px-3 py-2 font-semibold hover:bg-violet-600 disabled:opacity-40">Ехать к точке</button>
<button onClick={() => send({ type: 'cancel_goal', robot: selected })} className="rounded-lg bg-slate-700 px-3 py-2 font-semibold hover:bg-slate-600">Отменить</button>
</div>
</Card>
<Card title="Рабочий инструмент">
{selected === 'RMC1' ? (
<div className="grid grid-cols-2 gap-2"><button disabled={rover.emergency} onClick={() => send({ type: 'gripper', robot: 'RMC1', state: 'open' })} className="rounded-lg bg-cyan-800 px-3 py-2 disabled:opacity-40">Открыть схват</button><button disabled={rover.emergency} onClick={() => send({ type: 'gripper', robot: 'RMC1', state: 'closed' })} className="rounded-lg bg-cyan-800 px-3 py-2 disabled:opacity-40">Закрыть схват</button></div>
) : (
<div className="grid grid-cols-2 gap-2"><button disabled={rover.emergency} onClick={() => send({ type: 'lift', robot: 'RMC2', height: 0.1 })} className="rounded-lg bg-amber-800 px-3 py-2 disabled:opacity-40">Поднять лифт</button><button disabled={rover.emergency} onClick={() => send({ type: 'lift', robot: 'RMC2', height: 0 })} className="rounded-lg bg-amber-800 px-3 py-2 disabled:opacity-40">Опустить лифт</button></div>
)}
</Card>
</aside>
</div>
</main>
)
}
+12
View File
@@ -0,0 +1,12 @@
@import "tailwindcss";
:root {
color: #e2e8f0;
background: #020617;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
button, input { font: inherit; }
button { touch-action: manipulation; }
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+48
View File
@@ -0,0 +1,48 @@
export type RobotId = 'RMC1' | 'RMC2'
export type Point = { x: number; y: number }
export type Pose = Point & { yaw: number }
export type RobotState = {
online: boolean
lastMessageMs: number | null
pose: Pose
velocity: { x: number; y: number; yaw: number }
battery: number | null
lidar: Point[]
trail: Point[]
plan: Point[]
goal: Pose | null
emergency: boolean
arucoId: string | null
liftStatus: string | null
gripper: 'open' | 'closed' | 'unknown'
}
export type MapState = {
width: number
height: number
resolution: number
origin: Point
data: number[]
}
export type FmsState = {
type: 'state'
timestamp: number
mode: 'ros' | 'mock'
bridgeOnline: boolean
bridgeError: string | null
map: MapState | null
robots: Record<RobotId, RobotState>
}
export type Command =
| { type: 'manual'; robot: RobotId; x: number; y: number; yaw: number }
| { type: 'stop'; robot: RobotId }
| { type: 'emergency'; robot: RobotId; active: boolean }
| { type: 'set_pose'; robot: RobotId; pose: Pose }
| { type: 'set_goal'; robot: RobotId; pose: Pose }
| { type: 'cancel_goal'; robot: RobotId }
| { type: 'lift'; robot: 'RMC2'; height: 0 | 0.1 }
| { type: 'gripper'; robot: 'RMC1'; state: 'open' | 'closed' }
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun"]
},
"include": ["vite.config.ts", "server", "scripts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:3001',
'/ws': { target: 'ws://127.0.0.1:3001', ws: true },
},
},
})