This commit is contained in:
2026-09-09 16:31:27 +03:00
commit 0a8894f3b2
100 changed files with 5091 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.venv/
reports/
.pytest_cache/
*.pt
!module3/models/latest.pt
module3/runs/
module3/predictions/
+134
View File
@@ -0,0 +1,134 @@
# mvch — подготовка к ЧВТ-2026
Репозиторий для самостоятельного изучения и тренировок по модулям ЧВТ.
- `module2/module_b.py` — модуль **Б**, навигация РМК-2 по ArUco.
- `module3/` — модуль **В**, датасет и распознавание инструментов.
- `module4/` — модуль **Г**, веб-FMS для двух роверов на React/Bun.
- `module5/` — модуль **Д**, единый сценарий RMC1 + RMC2.
Все актуальные модули находятся в `main`. Ветки `module2`–`module5` сохранены
как промежуточные этапы разработки.
## Запуск в Ubuntu VM
Нужен установленный официальный симулятор, ROS 2 Jazzy, `rclpy`, `tf2_ros`.
Дополнительные pip-пакеты и сборка colcon самого решения не нужны.
Терминал 1 — штатный симулятор (один экземпляр):
```bash
source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/setup.bash
ros2 launch ar_webots_fms_ros2 module2.launch.py
```
Терминал 2 — решение:
```bash
cd ~/mvch
git pull --ff-only
source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/setup.bash
python3 module2/module_b.py --target 14 --sim
```
1. Робот должен стоять **центром над известной меткой**. Начальная ориентация любая:
она определяется по TF метки. Программа ждёт odometry, lidar, TF и ArUco.
2. Программа печатает `ROUTE`. Эксперт фиксирует маршрут; **Enter** разрешает движение.
3. После `TARGET_REACHED` робот стоит. Выставьте препятствие штатным инструментом симулятора.
4. **Enter** строит и выводит обратный маршрут с учётом видимого препятствия.
5. Ещё **Enter** разрешает возврат. Успех — `MISSION_FINISHED`.
Если за время подтверждения маршрут изменился, программа покажет новый и снова дождётся Enter.
Время паузы между этапами не входит в `movement_seconds`.
Команды в том же терминале: `stop`, `resume`, `return`, `go`, `quit`.
Ctrl+C немедленно инициирует остановку и завершение с ненулевым кодом.
Для внешнего интерфейса: `/mvch/command` (`std_msgs/String`), `/mvch/emergency_stop`
(`std_msgs/Bool`, true фиксирует стоп), `/mvch/status` (JSON в `std_msgs/String`).
После потери датчиков программа останавливается и требует `resume`.
Автоматическая тренировка без подтверждений (только симулятор):
```bash
python3 module2/module_b.py --target 14 --sim --auto --return-delay 10
```
Цель координатами и конечная ориентация, в системе **поля**:
```bash
python3 module2/module_b.py --xy -2 2 --target-yaw 1.57 --sim
```
На физическом роботе `--sim` не указывать. Перед переносом обязательно проверить
размеры корпуса, тормозной путь, TF, шаг и ориентации графа, датчики и аппаратную
аварийную остановку. Испытания в Webots не подтверждают готовность к реальному полю.
У штатного Webots-драйвера нет таймаута последней команды: при SIGKILL/обрыве питания
процесса программная остановка не гарантируется; остановить симулятор вручную.
## Как устроен код
Весь модуль находится в одном файле `module2/module_b.py`: сверху параметры поля и
скоростей, затем геометрия и Дейкстра, ниже ROS-узел, безопасность и логи.
Читайте в таком порядке: `Field.route` → `localize` → `scan_points` → `plan` → `drive` → `tick`.
Координаты штатного поля: `0=(0,0)`, `6=(-1,0)`, `1=(0,1)`.
Поворот `yaw` каждой метки — ориентация её TF-системы относительно поля.
Измеренное положение метки в odom сравнивается с её известным положением на поле;
так получаем преобразование `map_from_odom`. Между метками движение идёт по одометрии.
У каждой промежуточной точки нужна свежая метка и попадание в допуск; «успех по таймауту» отсутствует.
Лидар переводится через TF с учётом `angle_min`, `angle_increment` и времени скана.
У текущего симулятора `laser_merged` повёрнут относительно корпуса на **120°**.
Планировщик исключает рёбра, проходящие слишком близко к точкам препятствий.
Он не знает ID или место будущего препятствия заранее и сам ничего не спавнит.
Если преграда появилась во время движения, робот тормозит, возвращается задним ходом
к предыдущему маркеру по своему ребру и публикует новый маршрут `ROUTE_REPLANNED`.
Если отступить безопасно нельзя, он стоит и завершает попытку по таймауту.
Размеры сетки, шаг и известные занятые клетки задаются константами `ROWS`, `COLS`,
`MARKER_SPACING`, `BLOCKED_MARKERS` в начале скрипта. Все длины — метры.
## Проверки и логи
```bash
python3 module2/module_b.py --target 14 --dry-run
```
`reports/module_b_*.jsonl` содержат маршрут, движение, ошибки, препятствия,
точность попадания по меткам и время. Логи исключены из Git.
Условия финального КЗ: один запускаемый скрипт решения; маршруты в терминале и файле;
явные `movement_start` / `movement_stop`; подтверждение эксперта перед каждым этапом.
Фактические баллы определяют эксперты по заезду, а не наличие функций в коде.
## Обновление VM с хоста
GitHub: `git@github.com:mavxa/mvch.git` (приватный).
Для текущей VM используется локальное зеркало хостового Git без передачи ключей GitHub.
На хосте запущен read-only Git daemon только для этого репозитория:
```bash
git daemon --reuseaddr --export-all --listen=127.0.0.1 --port=9418 \
--base-path=/home/mavxa/zed/chvtFinal /home/mavxa/zed/chvtFinal/mvch
```
В VM с QEMU user networking хост доступен как `10.0.2.2`:
```bash
git clone --branch module2 git://10.0.2.2:9418/mvch ~/mvch
cd ~/mvch
git pull --ff-only
```
SSH VM: `ssh -p 2222 -o HostKeyAlias=chvt-3735b9fd mavxa@127.0.0.1`.
Проброс действует до выключения VM. Восстановить на хосте:
```bash
virsh -c qemu:///system qemu-monitor-command ubuntu24.04-chvt \
--hmp 'hostfwd_add hostnet0 tcp:127.0.0.1:2222-:22'
```
Пароли и ключи в репозитории не хранятся.
View File
+621
View File
@@ -0,0 +1,621 @@
#!/usr/bin/env python3
"""Модуль Б: один запуск, маршрут туда, пауза эксперта, маршрут обратно.
python3 module_b.py --target 14 --sim
Команды в терминале: Enter / go, stop, resume, return, quit.
"""
import argparse
import heapq
import json
import math
import queue
import re
import signal
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
# Все параметры модуля Б находятся здесь. Перед реальным полем меняются в одном месте.
ROWS = 6
COLS = 6
MARKER_SPACING = 1.0
BLOCKED_MARKERS = set()
ROBOT_NAMESPACE = "RMC2"
MARKER_PREFIX = "aruco_"
# RMC2.proto: край корпуса находится примерно в 0.373 м от base_link.
# Четыре сантиметра запаса оставляют проходимым узкий коридор у marker 33.
ROBOT_HALF_LENGTH = 0.375
ROBOT_HALF_WIDTH = 0.375
SAFETY_MARGIN = 0.04
ROUTE_CLEARANCE = 0.44
MAX_LINEAR = 0.25
MAX_ANGULAR = 0.45
LINEAR_ACCEL = 0.35
ANGULAR_ACCEL = 0.80
POSITION_TOLERANCE = 0.025
YAW_TOLERANCE = 0.045
MOVE_YAW_TOLERANCE = 0.14
SENSOR_TIMEOUT = 1.5
MARKER_TIMEOUT = 0.8
WAYPOINT_TIMEOUT = 50.0
MISSION_TIMEOUT = 330.0
def wrap(angle):
return math.atan2(math.sin(angle), math.cos(angle))
def clamp(value, low, high):
return max(low, min(high, value))
def compose(a, b):
"""Переносит позу b из системы a в её родительскую систему."""
x, y, yaw = a
c, s = math.cos(yaw), math.sin(yaw)
return x + c*b[0] - s*b[1], y + s*b[0] + c*b[1], wrap(yaw+b[2])
def inverse(pose):
x, y, yaw = pose
c, s = math.cos(yaw), math.sin(yaw)
return -c*x-s*y, s*x-c*y, -yaw
def distance(a, b):
return math.hypot(a[0]-b[0], a[1]-b[1])
def segment_distance(point, a, b):
dx, dy = b[0]-a[0], b[1]-a[1]
length2 = dx*dx+dy*dy
if length2 == 0:
return distance(point, a)
t = clamp(((point[0]-a[0])*dx+(point[1]-a[1])*dy)/length2, 0, 1)
return distance(point, (a[0]+t*dx, a[1]+t*dy))
class Field:
"""Регулярная сетка ArUco и кратчайший путь по ней."""
def __init__(self):
self.poses = {}
self.graph = {}
for row in range(ROWS):
for col in range(COLS):
marker = row*COLS+col
self.poses[marker] = (-row*MARKER_SPACING, col*MARKER_SPACING, 0.0)
self.graph[marker] = [
r*COLS+c for r, c in
((row+1, col), (row, col+1), (row-1, col), (row, col-1))
if 0 <= r < ROWS and 0 <= c < COLS
]
def nearest(self, x, y):
return min(self.poses, key=lambda marker: distance((x, y), self.poses[marker]))
def route(self, start, goal, obstacle_points=(), forbidden_edges=()):
"""Дейкстра: кратчайший по длине свободный путь."""
if start not in self.poses or goal not in self.poses:
raise ValueError("ID старта или цели отсутствует в графе")
if start in BLOCKED_MARKERS or goal in BLOCKED_MARKERS:
raise ValueError("Старт или цель заблокированы")
forbidden = {frozenset(edge) for edge in forbidden_edges}
costs = {start: 0.0}
parents = {start: None}
queue_path = [(0.0, start)]
while queue_path:
cost, current = heapq.heappop(queue_path)
if cost > costs[current]:
continue
if current == goal:
path = []
while current is not None:
path.append(current)
current = parents[current]
return path[::-1]
for neighbor in self.graph[current]:
edge = frozenset((current, neighbor))
if neighbor in BLOCKED_MARKERS or edge in forbidden:
continue
a, b = self.poses[current], self.poses[neighbor]
if any(segment_distance(p, a, b) < ROUTE_CLEARANCE for p in obstacle_points):
continue
new_cost = cost+distance(a, b)
if new_cost < costs.get(neighbor, math.inf):
costs[neighbor] = new_cost
parents[neighbor] = current
heapq.heappush(queue_path, (new_cost, neighbor))
raise ValueError(f"Нет безопасного маршрута {start} -> {goal}")
def arguments():
parser = argparse.ArgumentParser(description=__doc__)
target = parser.add_mutually_exclusive_group(required=True)
target.add_argument("--target", type=int, help="ID целевой метки")
target.add_argument("--xy", nargs=2, type=float, help="Координаты цели на карте, метры")
parser.add_argument("--sim", action="store_true", help="Часы Webots /clock")
parser.add_argument("--target-yaw", type=float, help="Ориентация на цели в системе поля, рад")
parser.add_argument("--auto", action="store_true", help="Только тренировка: без команд эксперта")
parser.add_argument("--return-delay", type=float, default=5.0, help="Пауза --auto, секунд симуляции")
parser.add_argument("--dry-run", action="store_true", help="Показать путь без ROS и без движения")
parser.add_argument("--start", type=int, default=0, help="Старт только для --dry-run")
args = parser.parse_args()
if args.auto and not args.sim:
parser.error("--auto разрешён только с --sim")
if args.return_delay < 0 or not math.isfinite(args.return_delay):
parser.error("--return-delay должен быть конечным неотрицательным числом")
if args.target_yaw is not None and not math.isfinite(args.target_yaw):
parser.error("--target-yaw должен быть конечным числом")
if args.xy and not all(math.isfinite(v) for v in args.xy):
parser.error("--xy должен содержать конечные числа")
return args
def main():
args = arguments()
field = Field()
target = args.target if args.target is not None else field.nearest(*args.xy)
if target not in field.poses or target in BLOCKED_MARKERS:
raise SystemExit("Целевая метка отсутствует в графе или заблокирована")
if args.dry_run:
print("ROUTE", field.route(args.start, target), "TARGET", field.poses[target])
return
import rclpy
from geometry_msgs.msg import Twist
from nav_msgs.msg import Odometry
from rclpy.clock import Clock, ClockType
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from rclpy.signals import SignalHandlerOptions
from rclpy.time import Time
from sensor_msgs.msg import LaserScan
from std_msgs.msg import Bool, String
from tf2_ros import Buffer, TransformListener, TransformException
def yaw(q):
return math.atan2(2*(q.w*q.z+q.x*q.y), 1-2*(q.y*q.y+q.z*q.z))
def tf_pose(transform):
t = transform.transform
return t.translation.x, t.translation.y, yaw(t.rotation)
class Mission(Node):
def __init__(self):
super().__init__("mvch_module_b")
self.set_parameters([rclpy.parameter.Parameter("use_sim_time", value=args.sim)])
self.cfg = {
"max_linear": MAX_LINEAR, "max_angular": MAX_ANGULAR,
"linear_accel": LINEAR_ACCEL, "angular_accel": ANGULAR_ACCEL,
"position_tolerance": POSITION_TOLERANCE,
"yaw_tolerance": YAW_TOLERANCE,
"move_yaw_tolerance": MOVE_YAW_TOLERANCE,
"sensor_timeout": SENSOR_TIMEOUT, "marker_timeout": MARKER_TIMEOUT,
"waypoint_timeout": WAYPOINT_TIMEOUT, "mission_timeout": MISSION_TIMEOUT,
}
self.robot = {
"namespace": ROBOT_NAMESPACE, "marker_prefix": MARKER_PREFIX,
"clearance": ROUTE_CLEARANCE, "half_length": ROBOT_HALF_LENGTH,
"half_width": ROBOT_HALF_WIDTH, "safety_margin": SAFETY_MARGIN,
}
ns = ROBOT_NAMESPACE.strip("/")
self.base, self.odom_frame = f"{ns}/base_link", f"{ns}/odom"
self.buffer = Buffer()
self.listener = TransformListener(self.buffer, self)
self.pub = self.create_publisher(Twist, f"/{ns}/cmd_vel", 1)
self.status_pub = self.create_publisher(String, "/mvch/status", 10)
self.create_subscription(Odometry, f"/{ns}/odometry", self.on_odom, qos_profile_sensor_data)
self.create_subscription(LaserScan, f"/{ns}/scan", self.on_scan, qos_profile_sensor_data)
self.create_subscription(String, f"/{ns}/aruco_id", self.on_marker, 10)
self.create_subscription(Bool, "/mvch/emergency_stop", self.on_stop, 10)
self.create_subscription(String, "/mvch/command", lambda m: self.commands.put(m.data), 10)
self.commands = queue.Queue()
self.state, self.leg = "WAIT_SENSORS", "outbound"
self.odom = self.map_from_odom = self.pose = None
self.odom_wall = self.scan_wall = 0.0
self.scan = None
self.points, self.body_points = [], []
self.lidar_tf_wall = 0.0
self.lidar_tf_error = "TF лидара ещё не получен"
self.marker = None
self.marker_wall = self.marker_stamp = 0.0
self.marker_distance = math.inf
self.start = self.current = None
self.start_yaw = 0.0
self.goal, self.route, self.index = target, [], 0
self.forbidden = set()
self.paused = False
self.terminal = False
self.failed = False
self.v = self.w = 0.0
self.last_sim = self.now()
self.last_wall = self.clock_wall = time.monotonic()
self.last_clock = self.last_sim
self.leg_started = self.waypoint_started = self.last_sim
self.total_time = 0.0
self.last_status = 0.0
self.wait_reason = ""
log_dir = Path(__file__).parent / "reports"
log_dir.mkdir(exist_ok=True)
self.log_path = log_dir / f"module_b_{datetime.now():%Y%m%d_%H%M%S_%f}.jsonl"
self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1)
# Таймер на монотонных часах: остановка сработает даже при паузе /clock.
self.timer = self.create_timer(0.05, self.tick, clock=Clock(clock_type=ClockType.STEADY_TIME))
self.log("READY", target=target, config="constants in module_b.py", sim=args.sim,
instructions="Enter/go: старт; return: построить возврат; stop/resume; quit")
def now(self):
return self.get_clock().now().nanoseconds / 1e9
def log(self, event, **data):
item = dict(time=datetime.now().isoformat(timespec="milliseconds"),
sim_time=round(self.now(), 3), event=event, leg=self.leg, **data)
line = json.dumps(item, ensure_ascii=False)
print(line, flush=True)
self.log_file.write(line+"\n")
def on_odom(self, msg):
p = msg.pose.pose
pose = p.position.x, p.position.y, yaw(p.orientation)
if all(math.isfinite(v) for v in pose):
self.odom, self.odom_wall = pose, time.monotonic()
def on_scan(self, msg):
# Пустой/повреждённый скан не считается свободным пространством.
if not msg.ranges or msg.angle_increment == 0:
return
if not any(math.isfinite(r) and msg.range_min <= r <= msg.range_max for r in msg.ranges):
return
self.scan, self.scan_wall = msg, time.monotonic()
def on_marker(self, msg):
match = re.fullmatch(re.escape(MARKER_PREFIX)+r"(\d+)", msg.data.strip())
if not match and msg.data.strip().isdigit():
marker = int(msg.data.strip())
elif match:
marker = int(match[1])
else:
return
if marker in field.poses:
self.marker, self.marker_wall = marker, time.monotonic()
def on_stop(self, msg):
if msg.data:
self.paused = True
self.stop()
self.log("EMERGENCY_STOP")
# false сам по себе не разрешает движение: требуется resume.
def localize(self):
if self.odom is None or self.marker is None:
return
frame = MARKER_PREFIX+str(self.marker)
try:
transform = self.buffer.lookup_transform(self.odom_frame, frame, Time())
stamp = transform.header.stamp.sec+transform.header.stamp.nanosec/1e9
if not 0 <= self.now()-stamp < self.cfg["marker_timeout"]:
return
if time.monotonic()-self.marker_wall > self.cfg["sensor_timeout"]:
return
odom_marker = tf_pose(transform)
self.marker_distance = distance(odom_marker, self.odom)
if stamp <= self.marker_stamp:
return
correction = compose(field.poses[self.marker], inverse(odom_marker))
if self.map_from_odom is not None:
old_pose = compose(self.map_from_odom, self.odom)
new_pose = compose(correction, self.odom)
if distance(old_pose, new_pose) > 0.4 or abs(wrap(old_pose[2]-new_pose[2])) > 0.6:
return # Не принимаем скачок распознавания; таймаут остановит у метки.
self.map_from_odom, self.marker_stamp = correction, stamp
except TransformException:
pass
def scan_points(self):
scan = self.scan
try:
# В этом симуляторе laser_merged повёрнут на 120 градусов!
mount = tf_pose(self.buffer.lookup_transform(self.base, scan.header.frame_id, Time()))
# Последний TF достаточно свежий: scan проверяется отдельно по wall-clock.
# Запрос строго по stamp иногда попадает между двумя odom TF и кратко падает.
at_scan = tf_pose(self.buffer.lookup_transform(self.odom_frame, self.base, Time()))
except TransformException as error:
self.lidar_tf_error = str(error)
return False
scan_in_map = compose(compose(self.map_from_odom, at_scan), mount)
body_from_map = inverse(self.pose)
points = {}
for i, r in enumerate(scan.ranges):
if not math.isfinite(r) or not scan.range_min <= r <= scan.range_max:
continue
angle = scan.angle_min+i*scan.angle_increment
p = compose(scan_in_map, (r*math.cos(angle), r*math.sin(angle), 0))[:2]
b = compose(body_from_map, (*p, 0))
if abs(b[0]) < self.robot["half_length"] and abs(b[1]) < self.robot["half_width"]:
continue # собственный корпус, не внешнее препятствие
points[(round(p[0]/0.04), round(p[1]/0.04))] = p
self.points = list(points.values())
self.body_points = [compose(body_from_map, (*p, 0)) for p in self.points]
self.lidar_tf_wall = time.monotonic()
self.lidar_tf_error = ""
return True
def plan(self):
self.stop()
try:
self.route = field.route(
self.current, self.goal, self.points, self.forbidden
)
except ValueError as error:
self.abort(str(error))
return
self.index = 1
self.state = "WAIT_GO"
direction = "К ЦЕЛИ" if self.leg == "outbound" else "НА СТАРТ"
print(f"\nМАРШРУТ {direction}: {' -> '.join(map(str, self.route))}", flush=True)
print("Ровер пока стоит. Эксперт проверяет маршрут; Enter/go разрешает движение.\n",
flush=True)
self.log("ROUTE", markers=self.route, goal=self.goal)
self.log("WAIT_GO", message="Эксперт фиксирует маршрут. Enter/go разрешает движение.")
def stop(self):
self.v = self.w = 0.0
self.pub.publish(Twist())
def abort(self, reason):
self.stop()
self.failed = self.terminal = True
self.state = "FAILED"
self.log("FAILED", reason=reason, current=self.current)
def command(self, command):
command = command.strip().lower()
if command in ("stop", "s"):
self.paused = True
self.stop()
self.log("EMERGENCY_STOP")
elif command == "resume":
self.paused = False
self.log("RESUME")
elif command in ("quit", "q"):
self.abort("Остановлено оператором")
elif self.state == "WAIT_RETURN" and command in ("", "return", "go"):
self.leg, self.goal = "return", self.start
self.forbidden.clear()
self.plan()
elif self.state == "WAIT_GO" and command in ("", "go") and not self.paused:
# Новая преграда за время паузы: сначала показать новый маршрут.
try:
updated = field.route(
self.current, self.goal, self.points, self.forbidden
)
except ValueError as error:
self.abort(str(error))
return
if updated != self.route:
self.plan()
return
self.state = "DRIVE"
self.leg_started = self.waypoint_started = self.now()
self.log("movement_start", markers=self.route)
def blocked_motion(self, v, w):
margin = self.robot["safety_margin"]
# Тормозной путь + время реакции; размеры считаются от центра корпуса.
braking = v*v/(2*self.cfg["linear_accel"])+abs(v)*0.25
front = self.robot["half_length"]+margin+braking
width = self.robot["half_width"]+margin
if abs(v) > 0.001:
direction = 1 if v > 0 else -1
return any(0 < direction*x < front and abs(y) < width for x, y, _ in self.body_points)
if abs(w) > 0.001:
return any(math.hypot(x, y) < self.robot["clearance"] for x, y, _ in self.body_points)
return False
def send(self, v, w, dt):
if self.blocked_motion(v, w):
self.stop()
return False
self.v += clamp(v-self.v, -self.cfg["linear_accel"]*dt, self.cfg["linear_accel"]*dt)
self.w += clamp(w-self.w, -self.cfg["angular_accel"]*dt, self.cfg["angular_accel"]*dt)
# Поворот на месте и аварийная остановка не должны оставлять линейный хвост.
if v == 0:
self.v = 0.0
if self.blocked_motion(self.v, self.w):
self.stop()
return False
msg = Twist()
msg.linear.x, msg.angular.z = float(self.v), float(self.w)
self.pub.publish(msg)
return True
def drive(self, dt):
if self.now()-self.waypoint_started > self.cfg["waypoint_timeout"]:
self.abort("Таймаут точки: проверить камеру, препятствие и положение робота")
return
if self.state == "ALIGN":
desired = args.target_yaw if self.leg == "outbound" else self.start_yaw
error = wrap(desired-self.pose[2])
if abs(error) <= self.cfg["yaw_tolerance"]:
self.arrived()
else:
self.send(0, clamp(1.4*error, -self.cfg["max_angular"], self.cfg["max_angular"]), dt)
return
if self.index >= len(self.route) and self.state != "BACKTRACK":
if self.leg == "return" or args.target_yaw is not None:
self.state = "ALIGN"
else:
self.arrived()
return
marker = self.current if self.state == "BACKTRACK" else self.route[self.index]
target_pose = field.poses[marker]
dx, dy = target_pose[0]-self.pose[0], target_pose[1]-self.pose[1]
d = math.hypot(dx, dy)
fresh = self.marker == marker and 0 <= self.now()-self.marker_stamp < self.cfg["marker_timeout"]
if d < self.cfg["position_tolerance"] and fresh and self.marker_distance < 0.04:
self.stop()
if self.state == "BACKTRACK":
self.log("BACKTRACK_DONE", marker=marker)
try:
self.route = field.route(
self.current, self.goal, self.points, self.forbidden
)
except ValueError as error:
self.abort(str(error))
return
self.index, self.state = 1, "DRIVE"
self.log("ROUTE_REPLANNED", markers=self.route)
else:
self.current = marker
self.index += 1
self.log("WAYPOINT", marker=marker, pose=list(self.pose), marker_error=self.marker_distance)
self.waypoint_started = self.now()
return
if d < self.cfg["position_tolerance"] and not fresh:
self.stop() # Без свежей метки достижение НЕ засчитываем.
return
error = wrap(math.atan2(dy, dx)-self.pose[2])
# Если следующая метка сзади, едем к ней задним ходом. Это не даёт
# роботу разворачиваться корпусом вплотную к стеллажам у marker 33.
reverse = self.state == "BACKTRACK" or abs(error) > math.pi/2
if reverse:
error = wrap(error-math.pi)
v = min(self.cfg["max_linear"], 0.8*d)
if reverse:
v = -min(v, 0.12)
if abs(error) > self.cfg["move_yaw_tolerance"]:
v = 0
w = clamp(1.5*error, -self.cfg["max_angular"], self.cfg["max_angular"])
if not self.send(v, w, dt) and self.state == "DRIVE":
edge = (self.current, marker)
self.forbidden.add(edge)
self.log("OBSTACLE", edge=edge, pose=list(self.pose))
# Вернуться по уже пройденному ребру, затем ехать по новому графу.
self.state = "BACKTRACK"
self.waypoint_started = self.now()
self.log("BACKTRACK", marker=self.current)
def arrived(self):
self.stop()
elapsed = self.now()-self.leg_started
self.total_time += elapsed
self.log("movement_stop", marker=self.goal, seconds=round(elapsed, 3))
if self.leg == "outbound":
self.state = "WAIT_RETURN"
self.return_wait = self.now()
self.log("TARGET_REACHED", marker=self.goal,
message="Выставьте препятствие. return/Enter строит обратный маршрут.")
else:
self.state, self.terminal = "FINISHED", True
self.log("MISSION_FINISHED", marker=self.start, movement_seconds=round(self.total_time, 3))
def tick(self):
wall, now = time.monotonic(), self.now()
dt = clamp(now-self.last_sim, 0, 0.15)
if now < self.last_sim-0.01:
self.abort("Часы симуляции сброшены; запустите новую попытку")
return
if now != self.last_clock:
self.clock_wall, self.last_clock = wall, now
self.last_sim = now
self.localize()
if self.map_from_odom is not None and self.odom is not None:
self.pose = compose(self.map_from_odom, self.odom)
reasons = []
if self.pose is None:
reasons.append("нет локализации")
if self.scan is None:
reasons.append("нет scan")
if wall-self.odom_wall >= SENSOR_TIMEOUT:
reasons.append(f"odometry старше {wall-self.odom_wall:.2f}с")
if wall-self.scan_wall >= SENSOR_TIMEOUT:
reasons.append(f"scan старше {wall-self.scan_wall:.2f}с")
if wall-self.clock_wall >= SENSOR_TIMEOUT:
reasons.append(f"clock старше {wall-self.clock_wall:.2f}с")
healthy = not reasons
if healthy and not self.scan_points():
# Один краткий промах TF не должен ставить миссию на вечную паузу.
if wall-self.lidar_tf_wall >= SENSOR_TIMEOUT:
reasons.append("TF лидара: "+self.lidar_tf_error)
healthy = False
if not healthy:
self.stop()
if self.state != "WAIT_SENSORS" and not self.paused:
self.paused = True
self.log("SENSOR_STOP", reason="; ".join(reasons),
message="После восстановления введите resume")
while not self.commands.empty():
command = self.commands.get_nowait()
if healthy or command.strip() in ("stop", "quit", "q", "s"):
self.command(command)
if not healthy or self.paused or self.terminal:
self.stop()
return
if wall-self.last_status > 1:
msg = String()
msg.data = json.dumps(dict(state=self.state, leg=self.leg, pose=self.pose,
current=self.current, route=self.route))
self.status_pub.publish(msg)
self.last_status = wall
if self.state == "WAIT_SENSORS":
if self.marker_distance > 0.04:
return
self.start = self.current = self.marker
self.start_yaw = self.pose[2]
self.log("LOCALIZED", marker=self.start, pose=list(self.pose))
self.plan()
elif self.state == "WAIT_GO":
self.stop()
if args.auto:
self.command("go")
elif self.state == "WAIT_RETURN":
self.stop()
if args.auto and now-self.return_wait >= args.return_delay:
self.command("return")
else:
if self.total_time+now-self.leg_started > self.cfg["mission_timeout"]:
self.abort("Превышено время миссии")
else:
self.drive(dt)
rclpy.init(signal_handler_options=SignalHandlerOptions.NO)
node = Mission()
quitting = threading.Event()
signal.signal(signal.SIGINT, lambda *_: quitting.set())
signal.signal(signal.SIGTERM, lambda *_: quitting.set())
def read_commands():
for line in sys.stdin:
node.commands.put(line)
threading.Thread(target=read_commands, daemon=True).start()
try:
while rclpy.ok() and not quitting.is_set() and not node.terminal:
rclpy.spin_once(node, timeout_sec=0.05)
if quitting.is_set() and not node.terminal:
node.abort("SIGINT/SIGTERM")
except Exception as error:
node.abort(f"{type(error).__name__}: {error}")
raise
finally:
# Перед закрытием соединения несколько раз отправить настоящий нулевой Twist.
for _ in range(5):
node.stop()
time.sleep(0.04)
node.log_file.close()
node.destroy_node()
rclpy.shutdown()
if node.failed:
raise SystemExit(1)
if __name__ == "__main__":
main()
+172
View File
@@ -0,0 +1,172 @@
# Модуль В: датасет и YOLO
Камера: `/RMC1/arm95/camera_gripper/image_color` (`1600x1200`).
Основной зачетный скрипт: `module3.py`. Он показывает обработанный видеопоток,
определяет координаты выбранной детали относительно `Base_link`, захватывает ее,
возвращает ARM95 в исходное положение с деталью, кладет деталь обратно на
освободившееся место и снова возвращает руку в исходное положение.
После первого безопасного подхода координаты повторно уточняются по близкому
кадру, поэтому рука не едет к детали только по одному дальнему измерению.
Если в кадре несколько деталей одного класса, скрипт выбирает ближайшую к базе.
## Запуск задания
В VM один раз создайте окружение с доступом к системным пакетам ROS:
```bash
cd ~/scripts/mvch/module3
sudo apt install python3-venv
python3 -m venv --system-site-packages .venv
.venv/bin/pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
.venv/bin/pip install -r requirements.txt tqdm ultralytics-thop
```
Если окружение уже обновило NumPy до 2.x и `cv_bridge` ругается на ABI:
```bash
.venv/bin/pip install --force-reinstall "numpy<2" "opencv-python<4.12" lark
```
`lark` — зависимость ROS 2 Launch/MoveIt, а не часть алгоритма распознавания.
Ограничение `numpy<2` нужно потому, что бинарный `cv_bridge` в этом образе ROS
собран с NumPy 1.x.
Если venv уже был создан без `--system-site-packages`, пересоздавать его не
нужно. Включите системные зависимости ROS и заново активируйте окружение:
```bash
sed -i 's/include-system-site-packages = false/include-system-site-packages = true/' .venv/pyvenv.cfg
deactivate
source .venv/bin/activate
```
Запустите симулятор модуля В, затем в новом терминале:
```bash
source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/setup.bash
cd ~/scripts/mvch
module3/.venv/bin/python module3/module3.py --target 1
```
Цели: `1/hammer`, `2/wrench`, `3/pliers`. После того как эксперт зафиксировал
видеопоток, нажмите `G` или `Space` в окне либо введите `g` и
нажмите Enter в терминале. `Esc` останавливает скрипт до начала движения.
Сначала обязательно проверьте только координаты:
```bash
module3/.venv/bin/python module3/module3.py --target hammer --dry-run
```
В режиме `--dry-run` подтверждать запуск клавишей не нужно: скрипт завершится
после первой валидной детекции и вывода рассчитанных координат.
Для проверки через SSH без окна:
```bash
module3/.venv/bin/python module3/module3.py --target hammer --dry-run --no-window
```
Геометрию реального стенда нельзя брать из симулятора вслепую. Перед зачетом
измерьте и подстройте `--plane-z`, `--pick-z` и `--approach-z`. По умолчанию
деталь возвращается туда, откуда была взята; другую свободную точку можно задать
через `--drop-x X --drop-y Y`.
События распознавания, координаты, построение движений и состояния схвата
одновременно выводятся в терминал и сохраняются в `module3/logs/*.log`.
Туда же пишутся контрольные кадры `*_gripped.jpg`, `*_with_detail.jpg` и
`*_finished.jpg`.
## Что размечать
Классы строго в таком написании:
```text
hammer
pliers
wrench
```
Рамкой выделяйте изображение инструмента на карточке, а класс задавайте по
картинке. Используйте одинаковый стиль разметки в симуляторе и на реальном поле.
Размечайте каждый видимый экземпляр: в штатной сцене две карточки `pliers`, одна
`hammer` и одна `wrench`. Классы `box` и `background` не создавайте.
В Roboflow создайте `Object Detection` project, загрузите `dataset/raw/*.jpg`,
разметьте кадры, сделайте split 70/20/10 и экспортируйте в формате YOLO11.
Распакуйте экспорт в `module3/training`: там должны появиться `data.yaml` и
каталоги `train`, `valid`, `test`. Не переставляйте номера классов в `data.yaml`:
они назначаются Roboflow и должны совпадать с ID внутри label-файлов.
Локально в `dataset/raw` могут лежать собранные кадры, но Git их намеренно
игнорирует: после клонирования датасет нужно перенести отдельно или собрать
заново. Расположение объектов менять не требуется: дополнительные повороты и
сдвиги выполняются аугментациями во время обучения.
## Сбор кадров из симулятора
```bash
source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/setup.bash
python3 module3/capture_dataset.py --count 30
```
Во время сбора плавно меняйте положение RMC1/манипулятора. Скрипт пропускает почти
одинаковые кадры. Для одиночного снимка используйте `--count 1 --min-change 0`.
## Установка для обучения
```bash
cd module3
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -U pip
pip install -r requirements.txt
```
Ultralytics ставит PyTorch как зависимость. Для конкретной NVIDIA CUDA или AMD
ROCm лучше сначала установить подходящую сборку PyTorch, затем `requirements.txt`.
## Обучение
Если часть объектов была размечена полигонами, один раз приведите экспорт к
обычным bounding boxes:
```bash
python prepare_dataset.py
```
Для теста используются максимум 30 эпох и early stopping после 7 эпох без
улучшения validation metrics.
Автовыбор: NVIDIA CUDA / AMD ROCm, а если GPU недоступен — CPU:
```bash
python train.py --device auto
```
Только CPU (подойдёт на AMD без настроенного ROCm):
```bash
python train.py --device cpu --batch 4 --workers 2
```
Принудительно NVIDIA CUDA или настроенный AMD ROCm:
```bash
python train.py --device cuda
```
Лучшие веса сохраняются в `training/runs/tools_yolo11n/weights/best.pt` и
автоматически копируются в `models/latest.pt` для простого запуска проверки.
## Проверка на фотографии
```bash
python test_model.py training/test/images/example.jpg --device cpu
```
С `--show` откроется окно. Независимо от окна размеченная фотография сохраняется
в `predictions/`: на ней видны bbox, названия классов и confidence.
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Сохраняет непохожие кадры с камеры захвата RMC1 для разметки."""
import argparse
import time
from datetime import datetime
from pathlib import Path
HERE = Path(__file__).resolve().parent
def arguments():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--topic",
default="/RMC1/arm95/camera_gripper/image_color",
help="ROS-топик sensor_msgs/Image",
)
parser.add_argument("--output", default="dataset/raw", help="Каталог для JPG")
parser.add_argument("--count", type=int, default=30, help="Сколько кадров сохранить")
parser.add_argument("--interval", type=float, default=0.5, help="Минимум секунд между JPG")
parser.add_argument(
"--min-change",
type=float,
default=1.0,
help="Минимальное среднее изменение пикселя; 0 сохраняет одинаковые кадры",
)
parser.add_argument("--timeout", type=float, default=180.0, help="Общий таймаут")
parser.add_argument("--jpeg-quality", type=int, default=95)
args = parser.parse_args()
if args.count < 1 or args.interval < 0 or args.timeout <= 0:
parser.error("count >= 1, interval >= 0, timeout > 0")
if not 1 <= args.jpeg_quality <= 100 or args.min_change < 0:
parser.error("jpeg-quality должен быть 1..100, min-change >= 0")
return args
def main():
args = arguments()
try:
import cv2
import numpy as np
import rclpy
from cv_bridge import CvBridge
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import Image
except ImportError as error:
raise SystemExit(
f"Нет ROS/OpenCV зависимости: {error}. Запускайте после source ROS 2 в VM."
)
output = Path(args.output).expanduser()
if not output.is_absolute():
output = HERE / output
output.mkdir(parents=True, exist_ok=True)
class Collector(Node):
def __init__(self):
super().__init__("mvch_dataset_collector")
self.bridge = CvBridge()
self.saved = 0
self.previous = None
self.last_save = 0.0
self.create_subscription(Image, args.topic, self.on_image, qos_profile_sensor_data)
def on_image(self, message):
now = time.monotonic()
if now - self.last_save < args.interval:
return
try:
frame = self.bridge.imgmsg_to_cv2(message, desired_encoding="bgr8")
except Exception as error:
self.get_logger().error(f"Не удалось преобразовать кадр: {error}")
return
preview = cv2.resize(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY), (64, 48))
if self.previous is not None:
change = float(np.mean(cv2.absdiff(preview, self.previous)))
if change < args.min_change:
return
filename = output / f"frame_{datetime.now():%Y%m%d_%H%M%S_%f}_{self.saved:03d}.jpg"
if not cv2.imwrite(
str(filename), frame, [cv2.IMWRITE_JPEG_QUALITY, args.jpeg_quality]
):
self.get_logger().error(f"Не удалось записать {filename}")
return
self.previous = preview
self.last_save = now
self.saved += 1
print(f"[{self.saved}/{args.count}] {filename}", flush=True)
rclpy.init()
node = Collector()
started = time.monotonic()
print(f"Камера: {args.topic}")
print("Плавно двигайте RMC1 или манипулятор, чтобы ракурсы менялись.")
try:
while rclpy.ok() and node.saved < args.count:
if time.monotonic() - started >= args.timeout:
break
rclpy.spin_once(node, timeout_sec=0.2)
except KeyboardInterrupt:
pass
finally:
saved = node.saved
node.destroy_node()
rclpy.shutdown()
print(f"Готово: сохранено {saved} кадров в {output}")
if saved == 0:
raise SystemExit(1)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
raw
+3
View File
@@ -0,0 +1,3 @@
hammer
pliers
wrench
+9
View File
@@ -0,0 +1,9 @@
path: .
train: train/images
val: valid/images
test: test/images
names:
0: hammer
1: pliers
2: wrench
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
Binary file not shown.
+691
View File
@@ -0,0 +1,691 @@
#!/usr/bin/env python3
"""Модуль В: распознать деталь, взять ARM95 и вернуть на полку."""
import argparse
import json
import math
import os
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from tempfile import NamedTemporaryFile
HERE = Path(__file__).resolve().parent
TARGETS = {"1": "hammer", "2": "wrench", "3": "pliers"}
class EventLog:
def __init__(self):
directory = HERE / "logs"
directory.mkdir(parents=True, exist_ok=True)
self.path = directory / f"module3_{datetime.now():%Y%m%d_%H%M%S}.log"
def write(self, event, **fields):
record = {"time": datetime.now().isoformat(timespec="milliseconds"), "event": event}
record.update(fields)
line = json.dumps(record, ensure_ascii=False)
print(line, flush=True)
with self.path.open("a", encoding="utf-8") as stream:
stream.write(line + "\n")
def image_path(self, suffix):
return self.path.with_name(f"{self.path.stem}_{suffix}.jpg")
def local_path(value):
path = Path(value).expanduser()
return path if path.is_absolute() else HERE / path
def target_name(value):
value = value.strip().lower()
return TARGETS.get(value, value)
def arguments():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", help="1/hammer, 2/wrench или 3/pliers")
parser.add_argument("--weights", default="models/latest.pt")
parser.add_argument("--topic", default="/RMC1/arm95/camera_gripper/image_color")
parser.add_argument("--conf", type=float, default=0.20)
parser.add_argument("--imgsz", type=int, default=512)
parser.add_argument("--device", default="cpu")
parser.add_argument("--timeout", type=float, default=60.0)
parser.add_argument(
"--plane-z",
type=float,
default=0.128,
help="Высота верхней грани детали относительно Base_link, м",
)
parser.add_argument("--pick-z", type=float, default=0.210)
parser.add_argument("--approach-z", type=float, default=0.38)
parser.add_argument("--drop-x", type=float, help="Точка возврата; по умолчанию исходная")
parser.add_argument("--drop-y", type=float, help="Точка возврата; по умолчанию исходная")
parser.add_argument("--no-window", action="store_true", help="Не открывать видеопоток")
parser.add_argument("--auto-start", action="store_true", help="Не ждать клавишу G/Enter")
parser.add_argument("--dry-run", action="store_true", help="Только распознавание и координаты")
parser.add_argument("--plan-only", action="store_true", help="Спланировать подход, не двигать руку")
args = parser.parse_args()
if not 0.0 <= args.conf <= 1.0:
parser.error("--conf должен быть от 0 до 1")
if args.timeout <= 0 or args.approach_z <= args.pick_z:
parser.error("timeout > 0, approach-z должен быть выше pick-z")
if (args.drop_x is None) != (args.drop_y is None):
parser.error("--drop-x и --drop-y задаются вместе")
return args
def rotate_vector(vector, quaternion):
"""Поворот вектора кватернионом geometry_msgs без внешней математики."""
x, y, z = vector
qx, qy, qz, qw = quaternion
tx = 2.0 * (qy * z - qz * y)
ty = 2.0 * (qz * x - qx * z)
tz = 2.0 * (qx * y - qy * x)
return (
x + qw * tx + qy * tz - qz * ty,
y + qw * ty + qz * tx - qx * tz,
z + qw * tz + qx * ty - qy * tx,
)
def normalize_half_turn(angle, reference=math.pi / 2):
"""Выбирает эквивалентный yaw (схват симметричен через 180 градусов)."""
choices = (angle - math.pi, angle, angle + math.pi)
return min(choices, key=lambda item: abs(item - reference))
def card_short_axis(frame, xyxy, cv2, np):
"""Находит направление короткой стороны светлой карточки внутри YOLO bbox."""
height, width = frame.shape[:2]
x1, y1, x2, y2 = (int(round(value)) for value in xyxy)
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(width, x2), min(height, y2)
crop = frame[y1:y2, x1:x2]
if crop.size == 0:
return None
hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, (0, 0, 105), (180, 115, 255))
kernel = np.ones((5, 5), np.uint8)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
crop_area = crop.shape[0] * crop.shape[1]
candidates = [contour for contour in contours if cv2.contourArea(contour) > crop_area * 0.12]
if not candidates:
return None
rectangle = cv2.minAreaRect(max(candidates, key=cv2.contourArea))
points = cv2.boxPoints(rectangle)
edges = [points[(index + 1) % 4] - points[index] for index in range(4)]
short_edge = min(edges, key=lambda edge: float(np.linalg.norm(edge)))
length = float(np.linalg.norm(short_edge))
if length < 2.0:
return None
return float(short_edge[0] / length), float(short_edge[1] / length)
class Detection:
def __init__(self, name, confidence, xyxy, short_axis):
self.name = name
self.confidence = confidence
self.xyxy = xyxy
self.short_axis = short_axis
@property
def center(self):
x1, y1, x2, y2 = self.xyxy
return (x1 + x2) / 2.0, (y1 + y2) / 2.0
def find_targets(result, frame, requested, cv2, np):
found = []
if result.boxes is None:
return found
for box in result.boxes:
class_id = int(box.cls.item())
name = str(result.names[class_id]).lower()
if name != requested:
continue
confidence = float(box.conf.item())
xyxy = tuple(float(value) for value in box.xyxy[0])
found.append(Detection(name, confidence, xyxy, card_short_axis(frame, xyxy, cv2, np)))
return found
def choose_target(node, detections, plane_z):
"""При одинаковых классах выбирает ближайшую к базе доступную деталь."""
reachable = []
for detection in detections:
try:
x, y, _ = estimate_pose(node, detection, plane_z)
except Exception:
continue
reachable.append((math.hypot(x, y), -detection.confidence, detection))
if reachable:
return min(reachable, key=lambda item: (item[0], item[1]))[2]
return max(detections, key=lambda item: item.confidence, default=None)
def card_near_center(frame, requested, cv2, np):
"""На близком кадре находит светлую коробку около центра камеры."""
height, width = frame.shape[:2]
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, (0, 0, 115), (180, 110, 255))
kernel = np.ones((7, 7), np.uint8)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
candidates = []
for contour in contours:
rectangle = cv2.minAreaRect(contour)
rect_width, rect_height = rectangle[1]
rect_area = rect_width * rect_height
if rect_area < 2000 or min(rect_width, rect_height) < 20:
continue
aspect = max(rect_width, rect_height) / min(rect_width, rect_height)
if not 1.2 <= aspect <= 4.0:
continue
center_x, center_y = rectangle[0]
distance = math.hypot(center_x - width / 2.0, center_y - height / 2.0)
candidates.append((distance, rectangle))
if not candidates:
raise RuntimeError("На близком кадре не найдена коробка")
distance, rectangle = min(candidates, key=lambda item: item[0])
if distance > min(width, height) * 0.30:
raise RuntimeError(f"Коробка слишком далеко от центра кадра: {distance:.0f} px")
points = cv2.boxPoints(rectangle)
edges = [points[(index + 1) % 4] - points[index] for index in range(4)]
short_edge = min(edges, key=lambda edge: float(np.linalg.norm(edge)))
length = float(np.linalg.norm(short_edge))
short_axis = float(short_edge[0] / length), float(short_edge[1] / length)
x1, y1 = np.min(points, axis=0)
x2, y2 = np.max(points, axis=0)
return Detection(requested, 1.0, (float(x1), float(y1), float(x2), float(y2)), short_axis)
def estimate_pose(node, detection, plane_z):
if node.camera_info is None:
raise RuntimeError("Нет CameraInfo")
frame = node.camera_frame or node.camera_info.header.frame_id
transform = node.tf_buffer.lookup_transform(
"Base_link", frame, node.Time(), timeout=node.Duration(seconds=1.0)
)
translation = transform.transform.translation
rotation = transform.transform.rotation
quaternion = (rotation.x, rotation.y, rotation.z, rotation.w)
u, v = detection.center
matrix = node.camera_info.k
fx, fy, cx, cy = matrix[0], matrix[4], matrix[2], matrix[5]
ray_camera = ((u - cx) / fx, (v - cy) / fy, 1.0)
ray_base = rotate_vector(ray_camera, quaternion)
if abs(ray_base[2]) < 1e-6:
raise RuntimeError("Луч камеры параллелен полке")
distance = (plane_z - translation.z) / ray_base[2]
if distance <= 0:
raise RuntimeError("Полка находится позади камеры")
x = translation.x + distance * ray_base[0]
y = translation.y + distance * ray_base[1]
if math.hypot(x, y) > 0.9:
raise RuntimeError(f"Координаты вне рабочей зоны: x={x:.3f}, y={y:.3f}")
yaw = math.pi / 2
if detection.short_axis is not None:
vx, vy = detection.short_axis
axis_base = rotate_vector((vx, vy, 0.0), quaternion)
short_angle = math.atan2(axis_base[1], axis_base[0])
yaw = normalize_half_turn(short_angle - math.pi / 2)
return x, y, yaw
def build_vision_node(args, imports):
Node, CvBridge, Image, CameraInfo, qos, Buffer, TransformListener, Duration, Time = imports
class VisionNode(Node):
def __init__(self):
super().__init__("mvch_module3_vision")
self.Duration = Duration
self.Time = Time
self.bridge = CvBridge()
self.frame = None
self.camera_info = None
self.camera_frame = None
self.lock = threading.Lock()
self.tf_buffer = Buffer()
self.tf_listener = TransformListener(self.tf_buffer, self)
self.create_subscription(Image, args.topic, self.on_image, qos)
info_topic = args.topic.rsplit("/", 1)[0] + "/camera_info"
self.create_subscription(CameraInfo, info_topic, self.on_info, qos)
def on_image(self, message):
try:
frame = self.bridge.imgmsg_to_cv2(message, desired_encoding="bgr8")
except Exception as error:
self.get_logger().error(f"Ошибка кадра: {error}")
return
with self.lock:
self.frame = frame
self.camera_frame = message.header.frame_id
def on_info(self, message):
self.camera_info = message
def latest_frame(self):
with self.lock:
return None if self.frame is None else self.frame.copy()
return VisionNode()
class Arm:
def __init__(
self,
moveit,
params_file,
PoseStamped,
quaternion_from_euler,
event_log,
plan_only=False,
):
self.robot = moveit(
node_name="mvch_module3_moveit",
name_space="/RMC1/arm95",
launch_params_filepaths=[params_file],
config_dict=None,
)
self.arm = self.robot.get_planning_component("arm95_group")
self.gripper = self.robot.get_planning_component("gripper")
self.PoseStamped = PoseStamped
self.quaternion_from_euler = quaternion_from_euler
self.log = event_log
self.plan_only = plan_only
def execute(self, component, stage):
plan = component.plan()
if not plan:
raise RuntimeError(f"MoveIt не построил траекторию: {stage}")
self.log.write("PLAN_READY", stage=stage)
if not self.plan_only:
self.robot.execute(plan.trajectory, controllers=[])
time.sleep(0.4)
self.log.write("MOTION_DONE", stage=stage)
def named(self, component, state, stage):
component.set_start_state_to_current_state()
component.set_goal_state(configuration_name=state)
self.execute(component, stage)
def pose(self, x, y, z, yaw, stage):
goal = self.PoseStamped()
goal.header.frame_id = "Base_link"
qx, qy, qz, qw = self.quaternion_from_euler(math.pi, 0.0, yaw)
goal.pose.orientation.x = qx
goal.pose.orientation.y = qy
goal.pose.orientation.z = qz
goal.pose.orientation.w = qw
goal.pose.position.x = x
goal.pose.position.y = y
goal.pose.position.z = z
self.arm.set_start_state_to_current_state()
self.arm.set_goal_state(pose_stamped_msg=goal, pose_link="gripper_base")
self.execute(self.arm, stage)
def open(self):
self.named(self.gripper, "open", "открыть схват")
if not self.plan_only:
self.log.write("GRIPPER", state="open")
def close(self):
self.named(self.gripper, "closed", "закрыть схват")
if not self.plan_only:
self.log.write("GRIPPER", state="closed")
def initial(self, stage="исходное положение"):
self.named(self.arm, "table_pos", stage)
def wait_for_trigger(args, node, model, requested, cv2, np, event_log):
started = time.monotonic()
last_detection = None
last_frame_time = 0.0
last_status_time = 0.0
frame_count = 0
best_classes = {}
terminal_trigger = threading.Event()
target_visible = threading.Event()
window = not args.no_window and bool(os.environ.get("DISPLAY"))
if not args.no_window and not window:
print("[WARN] DISPLAY не найден, работаю без окна")
def read_terminal_command():
while not terminal_trigger.is_set():
try:
command = input().strip().lower()
except EOFError:
return
if command not in ("", "g", "go"):
print("[WAIT] Команда не распознана. Введите g и нажмите Enter.")
continue
if not target_visible.is_set():
print(f"[WAIT] Класс {requested} пока не найден, команда не принята.")
continue
terminal_trigger.set()
if not args.auto_start and not args.dry_run and sys.stdin.isatty():
threading.Thread(target=read_terminal_command, daemon=True).start()
if args.dry_run:
print(f"[READY] Dry-run: жду первое распознавание класса {requested}.")
else:
print(
f"[READY] Цель: {requested}. После фиксации нажмите G в окне "
"или введите g + Enter в терминале."
)
while time.monotonic() - started < args.timeout:
frame = node.latest_frame()
if frame is None:
time.sleep(0.05)
continue
frame_count += 1
result = model.predict(
source=frame,
conf=args.conf,
imgsz=args.imgsz,
device=args.device,
verbose=False,
)[0]
if result.boxes is not None:
for box in result.boxes:
name = str(result.names[int(box.cls.item())]).lower()
confidence = float(box.conf.item())
best_classes[name] = max(best_classes.get(name, 0.0), confidence)
detections = find_targets(result, frame, requested, cv2, np)
last_detection = choose_target(node, detections, args.plane_z)
if last_detection is None:
target_visible.clear()
else:
target_visible.set()
annotated = result.plot(labels=True, conf=True, line_width=2)
if last_detection is not None:
u, v = (int(value) for value in last_detection.center)
cv2.circle(annotated, (u, v), 7, (0, 255, 255), -1)
if last_detection.short_axis is not None:
vx, vy = last_detection.short_axis
cv2.line(
annotated,
(u - int(vx * 45), v - int(vy * 45)),
(u + int(vx * 45), v + int(vy * 45)),
(255, 255, 0),
3,
)
now = time.monotonic()
if last_detection is not None:
if now - last_frame_time > 0.8:
event_log.write(
"DETECTION",
target=last_detection.name,
confidence=round(last_detection.confidence, 4),
center=[round(value, 1) for value in last_detection.center],
)
last_frame_time = now
elif now - last_status_time > 5.0:
event_log.write(
"VISION_WAIT",
target=requested,
frames=frame_count,
seen={name: round(value, 3) for name, value in sorted(best_classes.items())},
)
last_status_time = now
key = -1
if window:
cv2.imshow("CHVT module 3 - G: start, ESC: exit", annotated)
key = cv2.waitKey(1) & 0xFF
if key == 27:
raise KeyboardInterrupt
if last_detection is not None and (
args.dry_run
or args.auto_start
or terminal_trigger.is_set()
or key in (ord("g"), ord("G"), 13, 32)
):
return last_detection
if frame_count == 0:
raise RuntimeError(f"Нет кадров с топика {args.topic} за {args.timeout:.0f} с")
seen = ", ".join(f"{name}={value:.2f}" for name, value in sorted(best_classes.items()))
raise RuntimeError(
f"За {args.timeout:.0f} с класс {requested} не найден; "
f"обработано кадров: {frame_count}; распознано: {seen or 'ничего'}"
)
def fresh_detection(node, model, requested, args, cv2, np):
time.sleep(0.7)
frame = node.latest_frame()
if frame is None:
raise RuntimeError("Нет свежего кадра после возврата руки")
result = model.predict(
source=frame,
conf=args.conf,
imgsz=args.imgsz,
device=args.device,
verbose=False,
)[0]
detections = find_targets(result, frame, requested, cv2, np)
detection = choose_target(node, detections, args.plane_z)
if detection is None:
raise RuntimeError(f"После установки руки не найден класс {requested}")
return detection
def save_snapshot(node, event_log, suffix, cv2):
time.sleep(0.5)
frame = node.latest_frame()
if frame is None:
event_log.write("SNAPSHOT_FAILED", stage=suffix, reason="нет кадра")
return
path = event_log.image_path(suffix)
if cv2.imwrite(str(path), frame):
event_log.write("SNAPSHOT", stage=suffix, path=str(path))
else:
event_log.write("SNAPSHOT_FAILED", stage=suffix, reason="ошибка записи")
def main():
args = arguments()
requested = target_name(args.target or input("Цель (1 hammer, 2 wrench, 3 pliers): "))
if requested not in TARGETS.values():
raise SystemExit(f"Неизвестная цель: {requested}")
weights = local_path(args.weights)
if not weights.is_file():
raise SystemExit(f"Не найдены веса: {weights}")
try:
import cv2
import numpy as np
if int(np.__version__.split(".", 1)[0]) >= 2:
raise SystemExit(
"NumPy 2.x несовместим с cv_bridge из ROS Jazzy. "
"Выполните: python3 -m pip install --force-reinstall 'numpy<2'"
)
import rclpy
import yaml
from ament_index_python.packages import get_package_share_directory
from cv_bridge import CvBridge
from geometry_msgs.msg import PoseStamped
from moveit.planning import MoveItPy
from moveit_configs_utils import MoveItConfigsBuilder
from rclpy.duration import Duration
from rclpy.executors import SingleThreadedExecutor
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from rclpy.time import Time
from sensor_msgs.msg import CameraInfo, Image
from tf2_ros import Buffer, TransformListener
from tf_transformations import quaternion_from_euler
from ultralytics import YOLO
except ImportError as error:
if error.name == "transforms3d":
raise SystemExit(
"Venv не видит системные зависимости ROS. Включите их: "
"sed -i 's/include-system-site-packages = false/"
"include-system-site-packages = true/' .venv/pyvenv.cfg"
)
raise SystemExit(
f"Не удалось импортировать {error.name}: {error}. "
"Выполните pip install -r requirements.txt после source ROS"
)
event_log = EventLog()
event_log.write("START", target=requested, log=str(event_log.path))
rclpy.init()
imports = (
Node,
CvBridge,
Image,
CameraInfo,
qos_profile_sensor_data,
Buffer,
TransformListener,
Duration,
Time,
)
node = build_vision_node(args, imports)
executor = SingleThreadedExecutor()
executor.add_node(node)
spin_thread = threading.Thread(target=executor.spin, daemon=True)
spin_thread.start()
model = YOLO(str(weights))
moveit_active = False
exit_code = 0
try:
detection = wait_for_trigger(args, node, model, requested, cv2, np, event_log)
if args.dry_run:
x, y, yaw = estimate_pose(node, detection, args.plane_z)
event_log.write(
"DRY_RUN",
x=round(x, 4),
y=round(y, 4),
plane_z=args.plane_z,
yaw=round(yaw, 4),
)
return
package_dir = Path(get_package_share_directory("ar_webots_fms_ros2"))
moveit_config = (
MoveItConfigsBuilder("arm95")
.robot_description(file_path=str(package_dir / "resource/urdf/arm95_webots.urdf"))
.trajectory_execution(
file_path=str(package_dir / "resource/config/moveit_controllers.yaml")
)
.moveit_cpp(file_path=str(package_dir / "resource/config/moveit_cpp.yaml"))
.to_moveit_configs()
.to_dict()
)
moveit_config["use_sim_time"] = True
# Wildcard нужен из-за namespace /RMC1/arm95: обычный config_dict
# записывает параметры только для имени узла без namespace.
with NamedTemporaryFile("w", suffix=".yaml", delete=False) as stream:
yaml.safe_dump({"/**": {"ros__parameters": moveit_config}}, stream)
params_file = stream.name
try:
arm = Arm(
MoveItPy,
params_file,
PoseStamped,
quaternion_from_euler,
event_log,
args.plan_only,
)
moveit_active = True
finally:
Path(params_file).unlink(missing_ok=True)
arm.open()
arm.initial("установить исходное положение")
detection = fresh_detection(node, model, requested, args, cv2, np)
x, y, yaw = estimate_pose(node, detection, args.plane_z)
drop_x = x if args.drop_x is None else args.drop_x
drop_y = y if args.drop_y is None else args.drop_y
event_log.write(
"TARGET",
target=requested,
confidence=round(detection.confidence, 4),
x=round(x, 4),
y=round(y, 4),
yaw=round(yaw, 4),
)
arm.pose(x, y, args.approach_z, yaw, "подход над деталью")
if args.plan_only:
print("[PLAN-ONLY] Подход построен, движения не было")
return
time.sleep(0.5)
close_frame = node.latest_frame()
if close_frame is None:
raise RuntimeError("Нет близкого кадра для уточнения")
close_detection = card_near_center(close_frame, requested, cv2, np)
close_x, close_y, close_yaw = estimate_pose(node, close_detection, args.plane_z)
correction = math.hypot(close_x - x, close_y - y)
if correction > 0.12:
raise RuntimeError(f"Слишком большая поправка координат: {correction:.3f} м")
x, y, yaw = close_x, close_y, close_yaw
event_log.write(
"TARGET_REFINED",
method="card_near_center",
correction=round(correction, 4),
x=round(x, 4),
y=round(y, 4),
yaw=round(yaw, 4),
)
arm.pose(x, y, args.approach_z, yaw, "точный подход над деталью")
arm.pose(x, y, args.pick_z, yaw, "опустить схват")
arm.close()
save_snapshot(node, event_log, "gripped", cv2)
arm.pose(x, y, args.approach_z, yaw, "поднять деталь")
arm.initial("исходное положение с деталью")
save_snapshot(node, event_log, "with_detail", cv2)
arm.pose(drop_x, drop_y, args.approach_z, yaw, "подход к пустому месту")
arm.pose(drop_x, drop_y, args.pick_z, yaw, "опустить деталь")
arm.open()
arm.pose(drop_x, drop_y, args.approach_z, yaw, "отойти от детали")
arm.initial("исходное положение без детали")
save_snapshot(node, event_log, "finished", cv2)
event_log.write("DONE", message="Деталь возвращена, манипулятор в исходном положении")
except KeyboardInterrupt:
print("\n[STOP] Остановлено оператором")
exit_code = 130
except Exception as error:
event_log.write("FAILED", reason=str(error))
exit_code = 1
finally:
cv2.destroyAllWindows()
executor.shutdown()
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if moveit_active:
# MoveItPy в поставке Jazzy падает в C++-деструкторе после штатной
# работы. Все движения и логи уже завершены, поэтому не вызываем
# проблемный teardown при выходе единственного зачетного скрипта.
sys.stdout.flush()
sys.stderr.flush()
os._exit(exit_code)
if exit_code:
raise SystemExit(exit_code)
if __name__ == "__main__":
main()
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Преобразует polygon-разметку Roboflow в обычные YOLO bounding boxes."""
import argparse
from pathlib import Path
HERE = Path(__file__).resolve().parent
def arguments():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dataset", default="training", help="Каталог Roboflow export")
return parser.parse_args()
def convert_line(line, filename):
values = line.split()
if len(values) == 5:
return line.strip(), False
if len(values) < 7 or (len(values) - 1) % 2:
raise ValueError(f"Некорректная строка в {filename}: {line.strip()}")
class_id = int(values[0])
coordinates = [float(value) for value in values[1:]]
xs = coordinates[0::2]
ys = coordinates[1::2]
x1, x2 = min(xs), max(xs)
y1, y2 = min(ys), max(ys)
box = (class_id, (x1 + x2) / 2, (y1 + y2) / 2, x2 - x1, y2 - y1)
return f"{box[0]} {box[1]:.6f} {box[2]:.6f} {box[3]:.6f} {box[4]:.6f}", True
def main():
args = arguments()
dataset = Path(args.dataset).expanduser()
if not dataset.is_absolute():
dataset = HERE / dataset
files = sorted(dataset.glob("*/labels/*.txt"))
if not files:
raise SystemExit(f"Не найдены labels в {dataset}")
converted = 0
objects = 0
for filename in files:
output = []
for line in filename.read_text().splitlines():
if not line.strip():
continue
result, changed = convert_line(line, filename)
output.append(result)
converted += int(changed)
objects += 1
filename.write_text("\n".join(output) + "\n")
print(f"Готово: файлов={len(files)}, объектов={objects}, polygon->bbox={converted}")
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
numpy>=1.23.5,<2
opencv-python>=4.6,<4.12
lark>=1.1
transforms3d>=0.4
ultralytics==8.3.0
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Рисует bbox, название класса и confidence модели на фотографии."""
import argparse
from pathlib import Path
HERE = Path(__file__).resolve().parent
def local_path(value):
path = Path(value).expanduser()
return path if path.is_absolute() else HERE / path
def select_device(requested, torch):
if requested == "auto":
if torch.cuda.is_available():
return 0
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
return "cpu"
if requested in ("cuda", "gpu"):
if not torch.cuda.is_available():
raise SystemExit("CUDA/ROCm не найден. Используйте --device cpu.")
return 0
return requested
def arguments():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", help="Фотография для проверки")
parser.add_argument("--weights", default="models/latest.pt")
parser.add_argument("--output", default="predictions", help="Каталог результата")
parser.add_argument("--conf", type=float, default=0.35)
parser.add_argument("--imgsz", type=int, default=512)
parser.add_argument("--device", default="auto", help="auto, cpu, cuda или номер GPU")
parser.add_argument("--show", action="store_true", help="Открыть окно с результатом")
return parser.parse_args()
def main():
args = arguments()
try:
import cv2
import torch
from ultralytics import YOLO
except ImportError as error:
raise SystemExit(f"Нет зависимости {error.name}. Выполните: pip install -r requirements.txt")
source = local_path(args.source)
weights = local_path(args.weights)
output = local_path(args.output)
if not source.is_file():
raise SystemExit(f"Не найдена фотография: {source}")
if not weights.is_file():
raise SystemExit(f"Не найдены веса: {weights}")
if not 0 <= args.conf <= 1:
raise SystemExit("--conf должен быть от 0 до 1")
model = YOLO(str(weights))
result = model.predict(
source=str(source),
conf=args.conf,
imgsz=args.imgsz,
device=select_device(args.device, torch),
verbose=False,
)[0]
output.mkdir(parents=True, exist_ok=True)
destination = output / f"{source.stem}_detected.jpg"
annotated = result.plot(labels=True, conf=True, line_width=2)
if not cv2.imwrite(str(destination), annotated):
raise SystemExit(f"Не удалось сохранить {destination}")
if result.boxes is None or len(result.boxes) == 0:
print("Объекты не найдены")
else:
for box in result.boxes:
class_id = int(box.cls.item())
confidence = float(box.conf.item())
x1, y1, x2, y2 = (round(float(v), 1) for v in box.xyxy[0])
print(f"{result.names[class_id]}: {confidence:.3f}, bbox=({x1}, {y1}, {x2}, {y2})")
print(f"Изображение с классами: {destination}")
if args.show:
cv2.imshow("Module 3 detection - press any key", annotated)
cv2.waitKey(0)
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Обучение YOLO11 на размеченном датасете инструментов."""
import argparse
import shutil
from pathlib import Path
HERE = Path(__file__).resolve().parent
TRAINING = HERE / "training"
def local_path(value):
path = Path(value).expanduser()
return path if path.is_absolute() else HERE / path
def select_device(requested, torch):
if requested == "cpu":
return "cpu"
if requested in ("cuda", "gpu"):
if not torch.cuda.is_available():
raise SystemExit("CUDA/ROCm не найден. Используйте --device cpu или установите PyTorch для GPU.")
return 0
if requested == "auto":
if torch.cuda.is_available():
return 0 # NVIDIA CUDA и AMD ROCm используют этот интерфейс PyTorch.
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
return "cpu"
return requested
def arguments():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--data", default="training/data.yaml", help="data.yaml из Roboflow")
parser.add_argument("--model", default="yolo11n.pt", help="Стартовые веса Ultralytics")
parser.add_argument("--epochs", type=int, default=30)
parser.add_argument(
"--patience",
type=int,
default=7,
help="Остановиться после N эпох без улучшения validation metrics",
)
parser.add_argument("--imgsz", type=int, default=512)
parser.add_argument("--batch", type=int, default=8)
parser.add_argument("--workers", type=int, default=4)
parser.add_argument(
"--device",
default="auto",
help="auto, cpu, cuda или номер GPU (например 0)",
)
parser.add_argument("--name", default="tools_yolo11n")
return parser.parse_args()
def main():
args = arguments()
try:
import torch
from ultralytics import YOLO
except ImportError as error:
raise SystemExit(f"Нет зависимости {error.name}. Выполните: pip install -r requirements.txt")
data = local_path(args.data)
if not data.is_file():
raise SystemExit(f"Не найден датасет: {data}\nРаспакуйте YOLO11 export из Roboflow в module3/dataset.")
device = select_device(args.device, torch)
if device == 0:
backend = "ROCm" if getattr(torch.version, "hip", None) else "CUDA"
print(f"Устройство: {backend} GPU — {torch.cuda.get_device_name(0)}")
else:
print(f"Устройство: {device}")
model = YOLO(args.model)
result = model.train(
data=str(data),
epochs=args.epochs,
patience=args.patience,
imgsz=args.imgsz,
batch=args.batch,
workers=args.workers,
device=device,
project=str(TRAINING / "runs"),
name=args.name,
seed=42,
deterministic=True,
hsv_h=0.0,
hsv_s=0.0,
hsv_v=0.0,
degrees=12.0,
translate=0.05,
scale=0.15,
shear=2.0,
fliplr=0.5,
flipud=0.5,
)
save_dir = Path(result.save_dir)
best_weights = save_dir / "weights" / "best.pt"
model_copy = HERE / "models" / "latest.pt"
model_copy.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(best_weights, model_copy)
print(f"Лучшие веса: {best_weights}")
print(f"Модель для test_model.py: {model_copy}")
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
*.zip
.venv
runs
*.cache
test
valid
+5
View File
@@ -0,0 +1,5 @@
# chvt-2026 > v0.1.0
https://universe.roboflow.com/jons-workspace-8viwr/chvt-2026
Provided by a Roboflow user
License: CC BY 4.0
+27
View File
@@ -0,0 +1,27 @@
chvt-2026 - v1 v0.1.0
==============================
This dataset was exported via roboflow.com on September 5, 2026 at 7:40 PM GMT
Roboflow is an end-to-end computer vision platform that helps you
* collaborate with your team on computer vision projects
* collect & organize images
* understand and search unstructured image data
* annotate, and create datasets
* export, train, and deploy computer vision models
* use active learning to improve your dataset over time
For state of the art Computer Vision training notebooks you can use with this dataset,
visit https://github.com/roboflow/notebooks
To find over 100k other datasets and pre-trained models, visit https://universe.roboflow.com
The dataset includes 40 images.
Chvt-2026 are annotated in YOLOv11 format.
The following pre-processing was applied to each image:
* Auto-orientation of pixel data (with EXIF-orientation stripping)
* Resize to 512x512 (Stretch)
No image augmentation techniques were applied.
+18
View File
@@ -0,0 +1,18 @@
train: train/images
val: valid/images
test: test/images
names:
0: hammer
1: pliers
2: wrench
# nc: 3
# names: ['hammer', 'pliers', 'wrench']
# roboflow:
# workspace: jons-workspace-8viwr
# project: chvt-2026
# version: 1
# license: CC BY 4.0
# url: https://universe.roboflow.com/jons-workspace-8viwr/chvt-2026/dataset/1
+9
View File
@@ -0,0 +1,9 @@
# пайторч без куда для амд
```
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
```
# база
```
pip install -r ../requirements.txt
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,4 @@
0 0.663086 0.459961 0.060547 0.111328
1 0.326172 0.395508 0.046875 0.115234
2 0.494141 0.460938 0.074219 0.070312
1 0.609375 0.326172 0.070312 0.093750
@@ -0,0 +1,4 @@
1 0.326172 0.395508 0.046875 0.115234
1 0.608398 0.327148 0.068359 0.095703
0 0.663086 0.459961 0.064453 0.111328
2 0.494141 0.460938 0.074219 0.070312
@@ -0,0 +1,4 @@
1 0.330078 0.379883 0.046875 0.111328
1 0.619141 0.338867 0.066406 0.099609
2 0.495117 0.460938 0.076172 0.070312
0 0.663086 0.475586 0.068359 0.107422
@@ -0,0 +1,4 @@
2 0.494141 0.458008 0.078125 0.064453
1 0.3359375 0.3671875 0.0546875 0.11328125
1 0.625000 0.348633 0.062500 0.103516
0 0.663086 0.488281 0.072266 0.105469
@@ -0,0 +1,4 @@
1 0.336914 0.359375 0.056641 0.109375
2 0.495117 0.458008 0.080078 0.060547
1 0.628906 0.353516 0.062500 0.105469
0 0.662109 0.496094 0.074219 0.101562
@@ -0,0 +1,4 @@
1 0.340820 0.351562 0.056641 0.109375
2 0.495117 0.457031 0.080078 0.058594
1 0.632812 0.360352 0.062500 0.107422
0 0.662109 0.503906 0.074219 0.101562
@@ -0,0 +1,4 @@
1 0.344727 0.343750 0.060547 0.109375
2 0.496094 0.457031 0.082031 0.054688
1 0.635742 0.365234 0.056641 0.109375
0 0.660156 0.510742 0.074219 0.099609
@@ -0,0 +1,4 @@
1 0.349609 0.334961 0.066406 0.107422
1 0.638672 0.373047 0.054688 0.109375
2 0.496094 0.457031 0.082031 0.050781
0 0.660156 0.518555 0.074219 0.091797
@@ -0,0 +1,4 @@
1 0.348633 0.334961 0.064453 0.107422
1 0.639648 0.373047 0.056641 0.109375
2 0.496094 0.457031 0.082031 0.050781
0 0.658203 0.520508 0.078125 0.095703
@@ -0,0 +1,4 @@
2 0.496094 0.457031 0.082031 0.050781
0 0.658203 0.520508 0.078125 0.095703
1 0.639648 0.373047 0.056641 0.109375
1 0.348633 0.334961 0.064453 0.107422
@@ -0,0 +1,4 @@
1 0.349609 0.335938 0.062500 0.105469
2 0.496094 0.457031 0.082031 0.050781
1 0.639648 0.371094 0.056641 0.109375
0 0.660156 0.517578 0.074219 0.093750
@@ -0,0 +1,4 @@
1 0.345703 0.342773 0.058594 0.107422
1 0.636719 0.367188 0.054688 0.109375
2 0.496094 0.456055 0.082031 0.052734
0 0.661133 0.511719 0.076172 0.097656
@@ -0,0 +1,4 @@
1 0.340820 0.350586 0.056641 0.107422
1 0.632812 0.360352 0.062500 0.107422
2 0.495117 0.458008 0.080078 0.060547
0 0.662109 0.505859 0.074219 0.097656
@@ -0,0 +1,4 @@
1 0.329102 0.379883 0.048828 0.111328
1 0.616211 0.334961 0.068359 0.099609
2 0.495117 0.458984 0.076172 0.070312
0 0.664062 0.473633 0.070312 0.107422
@@ -0,0 +1,4 @@
2 0.494141 0.459961 0.074219 0.072266
1 0.328125 0.388672 0.046875 0.113281
1 0.614258 0.332031 0.072266 0.097656
0 0.663086 0.467773 0.068359 0.107422
@@ -0,0 +1,4 @@
2 0.494141 0.460938 0.074219 0.074219
1 0.608398 0.325195 0.072266 0.095703
1 0.325195 0.398438 0.044922 0.113281
0 0.663086 0.458984 0.060547 0.109375
@@ -0,0 +1,4 @@
1 0.32421875 0.40625 0.046875 0.1171875
1 0.602539 0.319336 0.072266 0.091797
2 0.493164 0.460938 0.072266 0.074219
0 0.664062 0.449219 0.058594 0.109375
@@ -0,0 +1,4 @@
1 0.322266 0.416016 0.042969 0.113281
1 0.597656 0.315430 0.074219 0.087891
2 0.494141 0.461914 0.070312 0.080078
0 0.663086 0.441406 0.056641 0.113281
@@ -0,0 +1,4 @@
1 0.319336 0.458984 0.041016 0.113281
2 0.492188 0.464844 0.062500 0.089844
1 0.573242 0.296875 0.083984 0.078125
0 0.656250 0.406250 0.046875 0.117188
@@ -0,0 +1,4 @@
1 0.319336 0.462891 0.041016 0.113281
1 0.569336 0.295898 0.080078 0.076172
2 0.491211 0.464844 0.060547 0.089844
0 0.657227 0.401367 0.044922 0.115234
@@ -0,0 +1,4 @@
1 0.320312 0.463867 0.042969 0.115234
1 0.569336 0.293945 0.080078 0.076172
2 0.491211 0.464844 0.060547 0.089844
0 0.656250 0.400391 0.042969 0.117188
@@ -0,0 +1,4 @@
1 0.321289 0.454102 0.041016 0.115234
1 0.576172 0.296875 0.082031 0.078125
2 0.494141 0.463867 0.062500 0.091797
0 0.660156 0.407227 0.046875 0.115234
@@ -0,0 +1,4 @@
1 0.321289 0.442383 0.041016 0.115234
1 0.584961 0.301758 0.080078 0.083984
0 0.664062 0.416992 0.050781 0.115234
2 0.495117 0.461914 0.064453 0.087891
@@ -0,0 +1,4 @@
1 0.326172 0.419922 0.042969 0.117188
1 0.603516 0.311523 0.078125 0.091797
2 0.500977 0.462891 0.068359 0.078125
0 0.669922 0.438477 0.058594 0.115234
@@ -0,0 +1,4 @@
1 0.330078 0.407227 0.046875 0.119141
1 0.615234 0.318359 0.074219 0.097656
2 0.502930 0.461914 0.072266 0.080078
0 0.674805 0.450195 0.060547 0.111328
@@ -0,0 +1,4 @@
1 0.350586 0.360352 0.056641 0.115234
2 0.512695 0.465820 0.080078 0.064453
1 0.653320 0.357422 0.064453 0.113281
0 0.680664 0.506836 0.076172 0.103516
@@ -0,0 +1,4 @@
1 0.353516 0.355469 0.058594 0.117188
1 0.655273 0.363281 0.060547 0.113281
2 0.514648 0.465820 0.080078 0.060547
0 0.681641 0.514648 0.078125 0.103516
@@ -0,0 +1,4 @@
1 0.353516 0.355469 0.058594 0.117188
1 0.656250 0.363281 0.062500 0.113281
2 0.513672 0.465820 0.082031 0.060547
0 0.681641 0.517578 0.078125 0.105469
+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 },
},
},
})
+130
View File
@@ -0,0 +1,130 @@
# Модуль Д — складской кейс RMC1 + RMC2
Один запускаемый скрипт координирует два ровера в сценарии из конкурсного
задания:
1. RMC2 едет к стеллажу, поднимает его и отвозит в точку комплектации.
2. RMC2 опускает стеллаж и отъезжает на соседнюю свободную точку.
3. RMC1 подъезжает к стеллажу, YOLO находит заданную деталь, ARM95 берёт её.
4. RMC1 отвозит деталь к полке сдачи и кладёт её.
5. RMC2 возвращается под стеллаж и отвозит его на хранение.
6. Оба ровера последовательно возвращаются на свои стартовые точки.
7. В терминал и JSONL-лог выводится `MISSION_FINISHED`.
Решение специально последовательное: это медленнее параллельного управления,
зато уменьшает риск столкновения RMC1 и RMC2. Маршруты строятся BFS по графу
ArUco 6 × 6, занятые клетки исключаются. Движение замыкается по odometry,
`scan_front` вызывает остановку перед препятствием, команды скорости сглаживаются.
Соседняя парковочная точка RMC2 автоматически берётся из построенного пути к
комплектации, отдельный параметр для неё не нужен.
## Учебный сценарий симулятора
Дефолты образуют тренировочный сценарий на штатной геометрии `module5.launch.py`.
Стартовые позиции, стеллаж и стол взяты из сцены; свободная точка комплектации
выбрана нами для теста:
| Объект | ArUco ID | Координата поля |
| --- | ---: | --- |
| старт RMC2 | 0 | `(0, 0)` |
| старт RMC1 | 15 | `(-2, 3)` |
| выбранный стеллаж | 34 | `(-5, 4)` |
| комплектация | 16 | `(-2, 4)` |
| подход RMC1 к стеллажу | 10 | `(-1, 4)` |
| полка сдачи | 5 | `(0, 5)` |
| подход RMC1 к полке | 4 | `(0, 4)` |
| другие стеллажи | 31, 35 | занятые клетки |
Это только удобный тренировочный пресет. На зачётной попытке ID точек сообщает
эксперт — передайте их аргументами, не редактируя код. Подход RMC1 должен быть
соседней свободной клеткой с рабочей стороны стеллажа/полки.
## Запуск
Терминал 1:
```bash
source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/setup.bash
ros2 launch ar_webots_fms_ros2 module5.launch.py
```
Терминал 2. Используется уже настроенная venv модуля В с YOLO и `numpy<2`:
```bash
cd ~/scripts/mvch
git pull --ff-only
source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/setup.bash
source module3/.venv/bin/activate
python3 module5/module5.py --target 1
```
Перед движением программа показывает все точки и маршруты. После проверки
нажмите Enter. Классы цели: `1=hammer`, `2=wrench`, `3=pliers`.
Пример с точками эксперта:
```bash
python3 module5/module5.py --target wrench \
--rmc1-start 15 --rmc2-start 0 \
--rack 34 --assembly 16 --assembly-approach 10 \
--delivery 5 --delivery-approach 4 --blocked 31,35
```
Только маршруты, без ROS и движения:
```bash
python3 module5/module5.py --dry-run --target pliers
```
Тест платформ и лифта без YOLO/MoveIt:
```bash
python3 module5/module5.py --target hammer --skip-arm
```
`--auto` убирает только начальное подтверждение. На первой тренировке его не
использовать.
## Аварийная остановка
В терминале скрипта доступны `stop`, `resume`, `abort`. Внешнее управление:
```bash
ros2 topic pub /mvch/module5/emergency_stop std_msgs/msg/Bool '{data: true}' --once
ros2 topic pub /mvch/module5/emergency_stop std_msgs/msg/Bool '{data: false}' --once
ros2 topic pub /mvch/module5/command std_msgs/msg/String '{data: abort}' --once
```
Ctrl+C и `abort` публикуют нулевую скорость обоим роверам. Штатный драйвер
Webots не имеет аппаратного watchdog: после SIGKILL дополнительно остановите
симуляцию вручную.
## Калибровка ARM95
Захват переиспользует проверенный код `module3/module3.py` и веса
`module3/models/latest.pt`. При подходе к полке RMC1 разворачивается к её центру,
а точка выкладки задаётся относительно `Base_link`:
```text
--drop-arm-x 0.55
--drop-arm-y 0.0
--drop-arm-z 0.220
--drop-arm-yaw 1.5708
```
Эти четыре значения обязательно проверить в симуляторе и затем на реальном
поле: геометрия полки может отличаться. Если ARM95 пока не откалиброван,
`--skip-arm` позволяет отдельно проверить всю логистику RMC1/RMC2.
## Логи и критерии
Логи сохраняются в `module5/logs/module5_*.jsonl` и не попадают в Git. В них
есть старт/конец, маршруты, достигнутые маркеры, остановки по лидару, команды
лифта, класс/confidence YOLO и этапы манипулятора.
Практически закрываются: все перемещения двух РМК, подъём/возврат стеллажа,
захват/сдача детали, один запускаемый скрипт, безопасность, логирование, Git и
README. Результат физического захвата, отсутствие падения детали и время до
трёх минут подтверждаются только контрольным заездом.
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+921
View File
@@ -0,0 +1,921 @@
#!/usr/bin/env python3
"""Модуль Д: один скрипт для складского кейса RMC1 + RMC2."""
import argparse
import json
import math
import os
import sys
import threading
import time
from collections import deque
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from tempfile import NamedTemporaryFile
from types import SimpleNamespace
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
ROWS = 6
COLS = 6
MARKER_SPACING = 1.0
ROBOT_NAMES = ("RMC1", "RMC2")
def clamp(value, limit):
return max(-limit, min(limit, value))
def wrap_angle(value):
return math.atan2(math.sin(value), math.cos(value))
def marker_xy(marker_id):
"""Координаты поля: 0=(0,0), 6=(-1,0), 1=(0,1)."""
row, column = divmod(marker_id, COLS)
return -row * MARKER_SPACING, column * MARKER_SPACING
def neighbours(marker_id):
row, column = divmod(marker_id, COLS)
result = []
for dr, dc in ((-1, 0), (0, -1), (0, 1), (1, 0)):
nr, nc = row + dr, column + dc
if 0 <= nr < ROWS and 0 <= nc < COLS:
result.append(nr * COLS + nc)
return result
def shortest_path(start, goal, blocked=()):
"""BFS по регулярному графу ArUco."""
forbidden = set(blocked) - {start, goal}
queue = deque([start])
previous = {start: None}
while queue:
current = queue.popleft()
if current == goal:
break
for candidate in neighbours(current):
if candidate not in forbidden and candidate not in previous:
previous[candidate] = current
queue.append(candidate)
if goal not in previous:
raise ValueError(f"Нет маршрута {start} -> {goal}; занято: {sorted(forbidden)}")
route = []
current = goal
while current is not None:
route.append(current)
current = previous[current]
return list(reversed(route))
def face_marker(position, target):
x1, y1 = marker_xy(position)
x2, y2 = marker_xy(target)
return math.atan2(y2 - y1, x2 - x1)
def parse_markers(value):
if not value.strip():
return set()
try:
return {int(item.strip()) for item in value.split(",")}
except ValueError as error:
raise argparse.ArgumentTypeError("Нужны ID через запятую, например 31,35") from error
class EventLog:
def __init__(self):
directory = HERE / "logs"
directory.mkdir(parents=True, exist_ok=True)
self.path = directory / f"module5_{datetime.now():%Y%m%d_%H%M%S}.jsonl"
self.lock = threading.Lock()
def write(self, event, **fields):
record = {"time": datetime.now().isoformat(timespec="milliseconds"), "event": event}
record.update(fields)
line = json.dumps(record, ensure_ascii=False)
with self.lock:
print(line, flush=True)
with self.path.open("a", encoding="utf-8") as stream:
stream.write(line + "\n")
stream.flush()
os.fsync(stream.fileno())
@dataclass
class Scenario:
rmc1_start: int
rmc2_start: int
rack: int
assembly: int
assembly_approach: int
delivery: int
delivery_approach: int
blocked: set
def validate(self):
points = {
"rmc1-start": self.rmc1_start,
"rmc2-start": self.rmc2_start,
"rack": self.rack,
"assembly": self.assembly,
"assembly-approach": self.assembly_approach,
"delivery": self.delivery,
"delivery-approach": self.delivery_approach,
}
for name, marker in points.items():
if marker not in range(ROWS * COLS):
raise ValueError(f"{name}: ID {marker} вне диапазона 0..35")
if self.assembly_approach not in neighbours(self.assembly):
raise ValueError("assembly-approach должен быть соседней ячейкой")
if self.delivery_approach not in neighbours(self.delivery):
raise ValueError("delivery-approach должен быть соседней ячейкой")
if self.assembly in self.blocked or self.rack in self.blocked:
raise ValueError("Активный стеллаж и точка комплектации не должны быть в --blocked")
if self.rack == self.assembly:
raise ValueError("Стеллаж и точка комплектации должны отличаться")
def routes(self):
# Полка сдачи и чужие стеллажи считаются препятствиями для RMC2.
rmc2_blocked = self.blocked | {self.delivery}
# Для RMC1 стеллаж уже стоит в комплектации, а полка занята физически.
rmc1_blocked = self.blocked | {self.assembly, self.delivery}
to_assembly = shortest_path(self.rack, self.assembly, rmc2_blocked)
parking = to_assembly[-2]
return {
"rmc2_to_rack": shortest_path(self.rmc2_start, self.rack, rmc2_blocked),
"rmc2_to_assembly": to_assembly,
"rmc2_clear_assembly": [self.assembly, parking],
"rmc2_back_to_assembly": [parking, self.assembly],
"rmc1_to_assembly": shortest_path(
self.rmc1_start, self.assembly_approach, rmc1_blocked
),
"rmc1_to_delivery": shortest_path(
self.assembly_approach, self.delivery_approach, rmc1_blocked
),
"rmc2_return_rack": shortest_path(self.assembly, self.rack, rmc2_blocked),
"rmc1_return": shortest_path(
self.delivery_approach, self.rmc1_start, rmc1_blocked
),
"rmc2_return": shortest_path(self.rack, self.rmc2_start, rmc2_blocked),
}
@dataclass
class Anchor:
world_x: float
world_y: float
world_yaw: float
odom_x: float
odom_y: float
odom_yaw: float
def to_odom(self, world_x, world_y, world_yaw):
dx, dy = world_x - self.world_x, world_y - self.world_y
rotation = self.odom_yaw - self.world_yaw
cosine, sine = math.cos(rotation), math.sin(rotation)
return (
self.odom_x + cosine * dx - sine * dy,
self.odom_y + sine * dx + cosine * dy,
wrap_angle(self.odom_yaw + world_yaw - self.world_yaw),
)
def arguments():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", help="1/hammer, 2/wrench, 3/pliers")
parser.add_argument("--rmc1-start", type=int, default=15)
parser.add_argument("--rmc2-start", type=int, default=0)
parser.add_argument("--rack", type=int, default=34)
parser.add_argument("--assembly", type=int, default=16)
parser.add_argument("--assembly-approach", type=int, default=10)
parser.add_argument("--delivery", type=int, default=5)
parser.add_argument("--delivery-approach", type=int, default=4)
parser.add_argument("--blocked", type=parse_markers, default={31, 35})
parser.add_argument("--rmc1-start-yaw", type=float, default=math.pi)
parser.add_argument("--rmc2-start-yaw", type=float, default=math.pi)
parser.add_argument("--rack-yaw", type=float, default=math.pi)
parser.add_argument("--auto", action="store_true", help="Не ждать Enter перед стартом")
parser.add_argument("--dry-run", action="store_true", help="Показать сценарий без ROS")
parser.add_argument("--skip-arm", action="store_true", help="Только роверы и лифт")
parser.add_argument("--weights", default="module3/models/latest.pt")
parser.add_argument(
"--camera-topic", default="/RMC1/arm95/camera_gripper/image_color"
)
parser.add_argument("--conf", type=float, default=0.20)
parser.add_argument("--vision-timeout", type=float, default=60.0)
parser.add_argument("--plane-z", type=float, default=0.128)
parser.add_argument("--pick-z", type=float, default=0.210)
parser.add_argument("--approach-z", type=float, default=0.38)
parser.add_argument("--drop-arm-x", type=float, default=0.55)
parser.add_argument("--drop-arm-y", type=float, default=0.0)
parser.add_argument("--drop-arm-z", type=float, default=0.220)
parser.add_argument("--drop-arm-yaw", type=float, default=math.pi / 2)
parser.add_argument("--sensor-timeout", type=float, default=3.0)
parser.add_argument("--waypoint-timeout", type=float, default=35.0)
args = parser.parse_args()
if not 0.0 <= args.conf <= 1.0:
parser.error("--conf должен быть от 0 до 1")
if args.approach_z <= max(args.pick_z, args.drop_arm_z):
parser.error("--approach-z должен быть выше pick/drop")
if min(args.sensor_timeout, args.waypoint_timeout, args.vision_timeout) <= 0:
parser.error("таймауты должны быть положительными")
return args
def make_scenario(args):
scenario = Scenario(
args.rmc1_start,
args.rmc2_start,
args.rack,
args.assembly,
args.assembly_approach,
args.delivery,
args.delivery_approach,
set(args.blocked),
)
scenario.validate()
return scenario
def print_plan(scenario, routes, target):
print("\n=== МОДУЛЬ Д: ПЛАН ДО ЗАПУСКА ===")
print(f"Целевая деталь: {target}")
print(
f"RMC2: {scenario.rmc2_start} -> стеллаж {scenario.rack} -> "
f"комплектация {scenario.assembly} -> {scenario.rack} -> {scenario.rmc2_start}"
)
print(
f"RMC1: {scenario.rmc1_start} -> подход {scenario.assembly_approach} -> "
f"сдача через {scenario.delivery_approach} -> {scenario.rmc1_start}"
)
for name, route in routes.items():
print(f"{name}: {route}")
print("Приоритет: STOP -> безопасность -> точность -> скорость")
print("Команды: stop, resume, abort. Внешний стоп: /mvch/module5/emergency_stop")
print("====================================\n")
def build_ros_node(args, event_log):
import rclpy
from geometry_msgs.msg import Twist
from nav_msgs.msg import Odometry
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import LaserScan
from std_msgs.msg import Bool, Float64, String
if not rclpy.ok():
rclpy.init()
class WarehouseNode(Node):
def __init__(self):
super().__init__("mvch_module5")
self.lock = threading.Lock()
self.poses = {name: None for name in ROBOT_NAMES}
self.odom_seen = {name: 0.0 for name in ROBOT_NAMES}
self.front_distance = {name: math.inf for name in ROBOT_NAMES}
self.scan_seen = {name: 0.0 for name in ROBOT_NAMES}
self.last_command = {name: (0.0, 0.0, 0.0, time.monotonic()) for name in ROBOT_NAMES}
self.emergency = threading.Event()
self.abort = threading.Event()
self.lift_status = "unknown"
self.lift_updates = 0
self.cmd_publishers = {
name: self.create_publisher(Twist, f"/{name}/cmd_vel", 10)
for name in ROBOT_NAMES
}
self.lift_publisher = self.create_publisher(Float64, "/RMC2/lift", 10)
for name in ROBOT_NAMES:
self.create_subscription(
Odometry,
f"/{name}/odometry",
lambda message, robot=name: self.on_odom(robot, message),
qos_profile_sensor_data,
)
self.create_subscription(
LaserScan,
f"/{name}/scan_front",
lambda message, robot=name: self.on_scan(robot, message),
qos_profile_sensor_data,
)
self.create_subscription(String, "/RMC2/lift_status", self.on_lift, 10)
self.create_subscription(Bool, "/mvch/module5/emergency_stop", self.on_stop, 10)
self.create_subscription(String, "/mvch/module5/command", self.on_command, 10)
def on_odom(self, robot, message):
orientation = message.pose.pose.orientation
siny = 2.0 * (orientation.w * orientation.z + orientation.x * orientation.y)
cosy = 1.0 - 2.0 * (orientation.y**2 + orientation.z**2)
pose = (
float(message.pose.pose.position.x),
float(message.pose.pose.position.y),
math.atan2(siny, cosy),
)
with self.lock:
self.poses[robot] = pose
self.odom_seen[robot] = time.monotonic()
def on_scan(self, robot, message):
distances = []
for index, distance in enumerate(message.ranges):
angle = message.angle_min + index * message.angle_increment
if abs(angle) <= math.radians(24) and math.isfinite(distance):
if message.range_min <= distance <= message.range_max:
distances.append(float(distance))
with self.lock:
self.front_distance[robot] = min(distances, default=math.inf)
self.scan_seen[robot] = time.monotonic()
def on_lift(self, message):
with self.lock:
self.lift_status = str(message.data)
self.lift_updates += 1
def on_stop(self, message):
if message.data:
self.emergency.set()
event_log.write("EMERGENCY_STOP", source="topic")
else:
self.emergency.clear()
event_log.write("RESUME", source="topic")
def on_command(self, message):
command = message.data.strip().lower()
if command == "stop":
self.emergency.set()
event_log.write("EMERGENCY_STOP", source="command")
elif command == "resume":
self.emergency.clear()
event_log.write("RESUME", source="command")
elif command in {"abort", "quit"}:
self.abort.set()
def pose(self, robot):
with self.lock:
return self.poses[robot], self.odom_seen[robot]
def scan(self, robot):
with self.lock:
return self.front_distance[robot], self.scan_seen[robot]
def command(self, robot, x=0.0, y=0.0, yaw=0.0, immediate=False):
now = time.monotonic()
old_x, old_y, old_yaw, old_time = self.last_command[robot]
if immediate:
next_x, next_y, next_yaw = x, y, yaw
else:
dt = min(0.15, max(0.02, now - old_time))
linear_step, angular_step = 0.65 * dt, 1.2 * dt
next_x = old_x + clamp(x - old_x, linear_step)
next_y = old_y + clamp(y - old_y, linear_step)
next_yaw = old_yaw + clamp(yaw - old_yaw, angular_step)
message = Twist()
message.linear.x = next_x
message.linear.y = next_y if robot == "RMC1" else 0.0
message.angular.z = next_yaw
self.cmd_publishers[robot].publish(message)
self.last_command[robot] = (next_x, next_y, next_yaw, now)
def stop_all(self):
for _ in range(3):
for robot in ROBOT_NAMES:
self.command(robot, immediate=True)
time.sleep(0.04)
def set_lift(self, raised, allow_already=False):
height = 0.1 if raised else 0.0
event_log.write("LIFT_COMMAND", state="up" if raised else "down", height=height)
with self.lock:
updates_before = self.lift_updates
for _ in range(5):
self.lift_publisher.publish(Float64(data=height))
time.sleep(0.1)
started = time.monotonic()
success = False
while time.monotonic() - started < 6.0:
with self.lock:
status = self.lift_status
received_update = self.lift_updates > updates_before
# Драйвер публикует status только при его изменении. Если лифт уже
# был внизу до нашей подписки, команда down не создаст сообщения.
if time.monotonic() - started >= 1.1 and received_update and "success" in status.lower():
success = True
break
if time.monotonic() - started >= 1.3 and allow_already and not received_update:
event_log.write(
"LIFT_ALREADY_AT_TARGET",
state="up" if raised else "down",
status=status,
)
success = True
break
time.sleep(0.1)
if not success:
raise RuntimeError(f"RMC2: лифт не завершил движение, status={status}")
event_log.write("LIFT_DONE", state="up" if raised else "down", status=status)
return WarehouseNode(), rclpy, MultiThreadedExecutor
class Motion:
def __init__(self, node, args, event_log, anchors):
self.node = node
self.args = args
self.log = event_log
self.anchors = anchors
def wait_ready(self):
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
odometry_ready = all(
self.node.pose(robot)[0] is not None for robot in ROBOT_NAMES
)
lidar_ready = all(self.node.scan(robot)[1] > 0.0 for robot in ROBOT_NAMES)
if odometry_ready and lidar_ready:
return
time.sleep(0.1)
missing_odom = [robot for robot in ROBOT_NAMES if self.node.pose(robot)[0] is None]
missing_lidar = [robot for robot in ROBOT_NAMES if self.node.scan(robot)[1] == 0.0]
raise RuntimeError(
f"Датчики не готовы; odometry={missing_odom or 'ok'}, "
f"scan_front={missing_lidar or 'ok'}"
)
def wait_if_stopped(self):
announced = False
while self.node.emergency.is_set():
self.node.stop_all()
if not announced:
self.log.write("PAUSED")
announced = True
if self.node.abort.is_set():
raise RuntimeError("Остановлено оператором")
time.sleep(0.1)
def drive_route(
self, robot, name, route, final_world_yaw, carrying=False,
rack_entry=False, rack_exit=False,
):
self.log.write("ROUTE", robot=robot, stage=name, markers=route, carrying=carrying)
for index, marker in enumerate(route[1:], start=1):
x, y = marker_xy(marker)
if index == len(route) - 1:
world_yaw = final_world_yaw
else:
nx, ny = marker_xy(route[index + 1])
world_yaw = math.atan2(ny - y, nx - x)
target = self.anchors[robot].to_odom(x, y, world_yaw)
self.drive_to(
robot,
marker,
target,
carrying=carrying,
allow_rack_entry=(rack_entry and index == len(route) - 1)
or (rack_exit and index == 1),
)
self.log.write("ROUTE_DONE", robot=robot, stage=name, marker=route[-1])
def drive_to(self, robot, marker, target, carrying=False, allow_rack_entry=False):
started = time.monotonic()
obstacle_since = None
while time.monotonic() - started < self.args.waypoint_timeout:
if self.node.abort.is_set():
raise RuntimeError("Остановлено оператором")
self.wait_if_stopped()
pose, seen = self.node.pose(robot)
if pose is None or time.monotonic() - seen > self.args.sensor_timeout:
self.node.command(robot, immediate=True)
raise RuntimeError(f"{robot}: потеря odometry")
x, y, yaw = pose
tx, ty, target_yaw = target
dx, dy = tx - x, ty - y
distance = math.hypot(dx, dy)
front, scan_seen = self.node.scan(robot)
fresh_scan = time.monotonic() - scan_seen <= self.args.sensor_timeout
stop_distance = 0.34 if robot == "RMC2" else 0.30
# Стеллаж имеет размер около 0.95 м и стоит центром на ArUco.
# На финальном въезде RMC2 штатно едет под его платформу.
rack_zone = allow_rack_entry and distance < 1.10
blocked = fresh_scan and front < stop_distance and not rack_zone
if blocked:
self.node.command(robot, immediate=True)
if obstacle_since is None:
obstacle_since = time.monotonic()
self.log.write(
"OBSTACLE_STOP", robot=robot, marker=marker, distance=round(front, 3)
)
if time.monotonic() - obstacle_since > 12.0:
raise RuntimeError(f"{robot}: препятствие не освободило путь")
time.sleep(0.08)
continue
obstacle_since = None
yaw_error = wrap_angle(target_yaw - yaw)
if distance < 0.09:
if abs(yaw_error) < 0.10:
self.node.command(robot, immediate=True)
self.log.write(
"WAYPOINT", robot=robot, marker=marker, error=round(distance, 3)
)
return
self.node.command(robot, yaw=clamp(yaw_error * 1.5, 0.42 if carrying else 0.55))
time.sleep(0.05)
continue
if robot == "RMC1":
cosine, sine = math.cos(yaw), math.sin(yaw)
body_x = cosine * dx + sine * dy
body_y = -sine * dx + cosine * dy
speed = 0.23 if carrying else 0.32
self.node.command(
robot,
x=clamp(body_x * 0.75, speed),
y=clamp(body_y * 0.75, speed),
yaw=clamp(yaw_error * 1.2, 0.40),
)
else:
heading_error = wrap_angle(math.atan2(dy, dx) - yaw)
if abs(heading_error) > 0.18:
self.node.command(
robot, yaw=clamp(heading_error * 1.45, 0.42 if carrying else 0.58)
)
else:
maximum = 0.18 if carrying else 0.27
self.node.command(
robot,
x=min(maximum, max(0.07, distance * 0.55)),
yaw=clamp(heading_error * 1.2, 0.30),
)
time.sleep(0.05)
self.node.command(robot, immediate=True)
raise RuntimeError(f"{robot}: таймаут движения к marker {marker}")
class ArmWorkflow:
"""Тонкая обвязка проверенного зрения и MoveIt из модуля В."""
def __init__(self, args, event_log, executor, target):
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
try:
import cv2
import numpy as np
import yaml
from ament_index_python.packages import get_package_share_directory
from cv_bridge import CvBridge
from geometry_msgs.msg import PoseStamped
from moveit.planning import MoveItPy
from moveit_configs_utils import MoveItConfigsBuilder
from rclpy.duration import Duration
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from rclpy.time import Time
from sensor_msgs.msg import CameraInfo, Image
from tf2_ros import Buffer, TransformListener
from tf_transformations import quaternion_from_euler
from ultralytics import YOLO
from module3.module3 import (
Arm,
build_vision_node,
card_near_center,
choose_target,
estimate_pose,
find_targets,
)
except ImportError as error:
raise RuntimeError(
f"Нет зависимости {error.name}: запустите из module3/.venv "
"после source ROS или используйте --skip-arm"
) from error
if int(np.__version__.split(".", 1)[0]) >= 2:
raise RuntimeError("Для cv_bridge нужен numpy<2 в module3/.venv")
weights = Path(args.weights).expanduser()
if not weights.is_absolute():
weights = ROOT / weights
if not weights.is_file():
raise RuntimeError(f"Не найдены веса YOLO: {weights}")
self.args = args
self.log = event_log
self.executor = executor
self.target = target
self.cv2 = cv2
self.np = np
self.find_targets = find_targets
self.choose_target = choose_target
self.estimate_pose = estimate_pose
self.card_near_center = card_near_center
imports = (
Node,
CvBridge,
Image,
CameraInfo,
qos_profile_sensor_data,
Buffer,
TransformListener,
Duration,
Time,
)
self.vision = build_vision_node(SimpleNamespace(topic=args.camera_topic), imports)
executor.add_node(self.vision)
self.model = YOLO(str(weights))
self.log.write("VISION_READY", target=target, weights=str(weights))
package_dir = Path(get_package_share_directory("ar_webots_fms_ros2"))
moveit_config = (
MoveItConfigsBuilder("arm95")
.robot_description(file_path=str(package_dir / "resource/urdf/arm95_webots.urdf"))
.trajectory_execution(
file_path=str(package_dir / "resource/config/moveit_controllers.yaml")
)
.moveit_cpp(file_path=str(package_dir / "resource/config/moveit_cpp.yaml"))
.to_moveit_configs()
.to_dict()
)
moveit_config["use_sim_time"] = True
with NamedTemporaryFile("w", suffix=".yaml", delete=False) as stream:
yaml.safe_dump({"/**": {"ros__parameters": moveit_config}}, stream)
params_file = stream.name
try:
self.arm = Arm(
MoveItPy,
params_file,
PoseStamped,
quaternion_from_euler,
event_log,
)
finally:
Path(params_file).unlink(missing_ok=True)
self.log.write("MOVEIT_READY")
def detect(self):
started = time.monotonic()
last_report = 0.0
seen = {}
while time.monotonic() - started < self.args.vision_timeout:
frame = self.vision.latest_frame()
if frame is None:
time.sleep(0.05)
continue
result = self.model.predict(
source=frame,
conf=self.args.conf,
imgsz=512,
device="cpu",
verbose=False,
)[0]
if result.boxes is not None:
for box in result.boxes:
name = str(result.names[int(box.cls.item())]).lower()
seen[name] = max(seen.get(name, 0.0), float(box.conf.item()))
detections = self.find_targets(
result, frame, self.target, self.cv2, self.np
)
detection = self.choose_target(self.vision, detections, self.args.plane_z)
if detection is not None:
self.log.write(
"DETECTION",
target=self.target,
confidence=round(detection.confidence, 4),
)
return detection
if time.monotonic() - last_report > 5.0:
self.log.write(
"VISION_WAIT",
target=self.target,
seen={key: round(value, 3) for key, value in seen.items()},
)
last_report = time.monotonic()
raise RuntimeError(
f"Класс {self.target} не найден за {self.args.vision_timeout:.0f} с; "
f"видел: {seen or 'ничего'}"
)
def pick(self):
self.log.write("ARM_STAGE", stage="pick_start")
self.arm.open()
self.arm.initial("транспортировочное положение перед захватом")
detection = self.detect()
x, y, yaw = self.estimate_pose(self.vision, detection, self.args.plane_z)
self.log.write("PICK_TARGET", x=round(x, 4), y=round(y, 4), yaw=round(yaw, 4))
self.arm.pose(x, y, self.args.approach_z, yaw, "подход над деталью")
# Камера приблизилась: уточняем положение по контуру карточки.
time.sleep(0.6)
frame = self.vision.latest_frame()
if frame is not None:
try:
close = self.card_near_center(frame, self.target, self.cv2, self.np)
refined_x, refined_y, refined_yaw = self.estimate_pose(
self.vision, close, self.args.plane_z
)
correction = math.hypot(refined_x - x, refined_y - y)
if correction <= 0.12:
x, y, yaw = refined_x, refined_y, refined_yaw
self.log.write("PICK_REFINED", correction=round(correction, 4))
except Exception as error:
self.log.write("PICK_REFINE_SKIPPED", reason=str(error))
self.arm.pose(x, y, self.args.approach_z, yaw, "точный подход")
self.arm.pose(x, y, self.args.pick_z, yaw, "опустить схват")
self.arm.close()
self.arm.pose(x, y, self.args.approach_z, yaw, "поднять деталь")
self.arm.initial("транспортировочное положение с деталью")
self.log.write("DETAIL_PICKED", target=self.target)
def place(self):
self.log.write("ARM_STAGE", stage="place_start")
x, y = self.args.drop_arm_x, self.args.drop_arm_y
yaw = self.args.drop_arm_yaw
self.arm.pose(x, y, self.args.approach_z, yaw, "подход к полке сдачи")
self.arm.pose(x, y, self.args.drop_arm_z, yaw, "опустить деталь на полку")
self.arm.open()
self.arm.pose(x, y, self.args.approach_z, yaw, "отойти от детали")
self.arm.initial("транспортировочное положение без детали")
self.log.write("DETAIL_PLACED", target=self.target)
def close(self):
try:
self.executor.remove_node(self.vision)
self.vision.destroy_node()
except Exception:
pass
def terminal_commands(node):
while not node.abort.is_set():
try:
command = input().strip().lower()
except EOFError:
return
if command == "stop":
node.emergency.set()
elif command == "resume":
node.emergency.clear()
elif command in {"abort", "quit"}:
node.abort.set()
return
def resolve_target(value):
names = {"1": "hammer", "2": "wrench", "3": "pliers"}
value = (value or "").strip().lower()
return names.get(value, value)
def run(args):
target = resolve_target(args.target)
if not target and not args.dry_run:
target = resolve_target(input("Цель (1 hammer, 2 wrench, 3 pliers): "))
if not target:
target = "hammer"
if target not in {"hammer", "wrench", "pliers"}:
raise ValueError(f"Неизвестная цель: {target}")
scenario = make_scenario(args)
routes = scenario.routes()
print_plan(scenario, routes, target)
if args.dry_run:
return 0
if not args.auto:
input("Проверьте точки и маршруты. Enter — начать: ")
event_log = EventLog()
event_log.write("MISSION_START", target=target, log=str(event_log.path))
mission_started = time.monotonic()
node, rclpy, MultiThreadedExecutor = build_ros_node(args, event_log)
executor = MultiThreadedExecutor(num_threads=3)
executor.add_node(node)
spin_thread = threading.Thread(target=executor.spin, daemon=True)
spin_thread.start()
exit_code = 0
arm_workflow = None
try:
initial_motion = Motion(node, args, event_log, {})
initial_motion.wait_ready()
anchors = {}
for robot, start_marker, start_yaw in (
("RMC1", scenario.rmc1_start, args.rmc1_start_yaw),
("RMC2", scenario.rmc2_start, args.rmc2_start_yaw),
):
pose, _ = node.pose(robot)
world_x, world_y = marker_xy(start_marker)
anchors[robot] = Anchor(world_x, world_y, start_yaw, *pose)
event_log.write("LOCALIZED", robot=robot, marker=start_marker, odom=pose)
motion = Motion(node, args, event_log, anchors)
threading.Thread(target=terminal_commands, args=(node,), daemon=True).start()
parking = routes["rmc2_clear_assembly"][-1]
assembly_entry_yaw = face_marker(parking, scenario.assembly)
node.set_lift(False, allow_already=True)
motion.drive_route(
"RMC2", "to_rack", routes["rmc2_to_rack"], args.rack_yaw, rack_entry=True
)
node.set_lift(True)
motion.drive_route(
"RMC2",
"rack_to_assembly",
routes["rmc2_to_assembly"],
assembly_entry_yaw,
carrying=True,
rack_exit=True,
)
node.set_lift(False)
motion.drive_route(
"RMC2",
"clear_assembly",
routes["rmc2_clear_assembly"],
face_marker(parking, scenario.assembly),
rack_exit=True,
)
assembly_yaw = face_marker(scenario.assembly_approach, scenario.assembly)
motion.drive_route(
"RMC1", "to_assembly", routes["rmc1_to_assembly"], assembly_yaw
)
if args.skip_arm:
event_log.write("ARM_SKIPPED", stage="pick", reason="--skip-arm")
else:
arm_workflow = ArmWorkflow(args, event_log, executor, target)
arm_workflow.pick()
delivery_yaw = face_marker(scenario.delivery_approach, scenario.delivery)
motion.drive_route(
"RMC1",
"to_delivery",
routes["rmc1_to_delivery"],
delivery_yaw,
carrying=not args.skip_arm,
)
if args.skip_arm:
event_log.write("ARM_SKIPPED", stage="place", reason="--skip-arm")
else:
arm_workflow.place()
motion.drive_route(
"RMC2",
"back_to_assembly",
routes["rmc2_back_to_assembly"],
assembly_entry_yaw,
rack_entry=True,
)
node.set_lift(True)
motion.drive_route(
"RMC2",
"assembly_to_rack",
routes["rmc2_return_rack"],
args.rack_yaw,
carrying=True,
rack_entry=True,
rack_exit=True,
)
node.set_lift(False)
motion.drive_route(
"RMC1", "return_start", routes["rmc1_return"], args.rmc1_start_yaw
)
motion.drive_route(
"RMC2", "return_start", routes["rmc2_return"], args.rmc2_start_yaw,
rack_exit=True,
)
event_log.write(
"MISSION_FINISHED",
target=target,
elapsed_seconds=round(time.monotonic() - mission_started, 2),
)
print("\nMISSION FINISHED: оба РМК вернулись на старт")
except KeyboardInterrupt:
event_log.write("MISSION_ABORTED", reason="Ctrl+C")
exit_code = 130
except Exception as error:
event_log.write("MISSION_FAILED", reason=str(error))
exit_code = 1
finally:
node.abort.set()
node.stop_all()
if arm_workflow is not None:
arm_workflow.close()
if not executor.shutdown(timeout_sec=2.0):
event_log.write("SHUTDOWN_TIMEOUT", component="ROS executor")
# rclpy/MoveItPy Jazzy иногда зависают или падают в C++-деструкторах.
# Нулевые скорости и логи уже отправлены; ОС безопасно освободит ресурсы.
sys.stdout.flush()
sys.stderr.flush()
os._exit(exit_code)
return exit_code
def main():
try:
raise SystemExit(run(arguments()))
except (ValueError, ImportError) as error:
raise SystemExit(str(error)) from error
if __name__ == "__main__":
main()
View File