Compare commits

..

13 Commits

Author SHA1 Message Date
5f0affcd79 feat: Jetson Orin system monitor ROS2 node (Issue #631)
New package saltybot_system_monitor:
- jetson_stats.py: pure-Python data layer (JetsonStats, CpuCore,
  TegrastatsParser, JtopReader, TegrastatsReader, MockReader,
  AlertChecker, AlertThresholds) — no ROS2 dependency
- system_monitor_node.py: ROS2 node publishing /saltybot/system/stats
  (JSON) and /saltybot/diagnostics (DiagnosticArray) at 1 Hz
- Alerts: CPU/GPU >85% WARN (+10% ERROR), temp >80°C, disk/RAM >90%,
  power >30 W; each alert produces a DiagnosticStatus entry
- Stats source priority: jtop > tegrastats > mock (auto-detected)
- config/system_monitor.yaml: all thresholds and rate tunable via params
- launch/system_monitor.launch.py: single-node launch with config arg
- test/test_system_monitor.py: 50+ pytest tests, ROS2-free

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 16:36:04 -04:00
4c7fa938a5 Merge pull request 'feat: UWB accuracy analyzer (Issue #634)' (#641) from sl-uwb/issue-634-uwb-logger into main 2026-03-15 16:30:16 -04:00
45332f1a8b Merge pull request 'feat: UART command protocol for Jetson-STM32 (Issue #629)' (#639) from sl-firmware/issue-629-uart-protocol into main 2026-03-15 16:30:09 -04:00
af46b15391 Merge pull request 'feat: ArUco docking detection (Issue #627)' (#638) from sl-perception/issue-627-aruco-docking into main 2026-03-15 16:30:04 -04:00
e1d605dba7 Merge pull request 'feat: WebUI main dashboard (Issue #630)' (#637) from sl-webui/issue-630-main-dashboard into main 2026-03-15 16:30:00 -04:00
c8c8794daa Merge pull request 'feat: Termux voice command interface (Issue #633)' (#636) from sl-android/issue-633-voice-commands into main 2026-03-15 16:29:56 -04:00
b5862ef529 Merge pull request 'feat: Cable management tray (Issue #628)' (#635) from sl-mechanical/issue-628-cable-tray into main 2026-03-15 16:29:52 -04:00
sl-uwb
343e53081a feat: UWB position logger and accuracy analyzer (Issue #634)
saltybot_uwb_logger_msgs (new package):
- AccuracyReport.msg: n_samples, mean/bias/std (x,y,2D), CEP50, CEP95,
  RMSE, max_error, per-anchor range stats, test_id, duration_s
- StartAccuracyTest.srv: request (truth_x/y_m, n_samples, timeout_s,
  test_id) → response (success, message, test_id)

saltybot_uwb_logger (new package):
- accuracy_stats.py: compute_stats() + RangeAccum — pure numpy, no ROS2
  CEP50/CEP95 = 50th/95th percentile of 2-D error; bias, std, RMSE, max
- logger_node.py: /uwb_logger ROS2 node
  Subscribes:
    /saltybot/pose/fused  → fused_pose_<DATE>.csv (ts, x, y, heading)
    /saltybot/uwb/pose    → uwb_pose_<DATE>.csv   (ts, x, y)
    /uwb/ranges           → uwb_ranges_<DATE>.csv (ts, anchor_id, range_m,
                                                   raw_mm, rssi, tag_id)
  Service /saltybot/uwb/start_accuracy_test:
    Collects N fused-pose samples at known (truth_x, truth_y) in background
    thread. On completion or timeout: publishes AccuracyReport on
    /saltybot/uwb/accuracy_report + writes accuracy_<test_id>.json.
    Per-anchor range stats included. CSV flushed every 5 s.

Tests: 16/16 passing (test/test_accuracy_stats.py, no ROS2/hardware)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:44:21 -04:00
602fbc6ab3 feat: UART command protocol for Jetson-STM32 (Issue #629)
Implements binary command protocol on UART5 (PC12/PD2) at 115200 baud
for Jetson→STM32 communication. Frame: STX+LEN+CMD+PAYLOAD+CRC8+ETX.

Commands: SET_VELOCITY (RPM direct to CAN), GET_STATUS, SET_PID, ESTOP,
CLEAR_ESTOP. DMA1_Stream0_Channel4 circular 256-byte RX ring. ACK/NACK
inline; STATUS pushed at 10 Hz. Heartbeat timeout 500 ms (UART_PROT_HB_TIMEOUT_MS).

NOTE: Spec requested USART1 @ 115200; USART1 occupied by JLink @ 921600.
Implemented on UART5 instead; note in code comments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:41:00 -04:00
1fd935b87e feat: ArUco marker detection for docking (Issue #627)
New package saltybot_aruco_detect — DICT_4X4_50 ArUco detection from
RealSense D435i RGB, pose estimation, PoseArray + dock target output.

aruco_math.py (pure Python, no ROS2): rot_mat_to_quat (Shepperd),
  rvec_to_quat (Rodrigues + cv2 fallback), tvec_distance, tvec_yaw_rad,
  MarkerPose dataclass with lazy-cached distance_m/yaw_rad/lateral_m/quat.

aruco_detect_node.py (ROS2 node 'aruco_detect'):
  Subscribes: /camera/color/image_raw (30Hz BGR8) + /camera/color/camera_info.
  Converts to greyscale, cv2.aruco.ArucoDetector.detectMarkers().
  estimatePoseSingleMarkers (legacy API) with solvePnP(IPPE_SQUARE) fallback.
  Dock target: closest marker in dock_marker_ids (default=[42], empty=any),
    filtered to max_dock_range_m (3.0m).
  Publishes: /saltybot/aruco/markers (PoseArray — all detected, camera frame),
    /saltybot/aruco/dock_target (PoseStamped — closest dock candidate,
    position.z=forward, position.x=lateral), /saltybot/aruco/viz (MarkerArray
    — SPHERE + TEXT per marker, dock in red), /saltybot/aruco/status (JSON
    10Hz — detected_count, dock_distance_m, dock_yaw_deg, dock_lateral_m).
  Optional debug image with drawDetectedMarkers + drawFrameAxes.
  corner_refinement=CORNER_REFINE_SUBPIX.

config/aruco_detect_params.yaml, launch/aruco_detect.launch.py.
test/test_aruco_math.py: 22 unit tests (rotation/quat math, distance,
  yaw sign/magnitude, MarkerPose accessors + caching).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:37:22 -04:00
b6c6dbd838 feat: WebUI main dashboard with panel launcher (Issue #630)
Replaces ui/index.html (old USB-serial HUD) with a full rosbridge
dashboard. Adds ui/dashboard.{css,js}.

Top bar:
- Robot name +  SALTYBOT logo
- Live battery % + voltage with fill bar (4S LiPo: 12.0V–16.8V)
- Safety state from /saltybot/safety_zone/status (GREEN/AMBER/RED)
- E-stop state display
- Drive mode display
- ROS uptime counter
- rosbridge WS input + CONNECT button

Panel grid (auto-fill responsive):
- MAP VIEW (#587) — /saltybot/pose/fused liveness dot
- GAMEPAD TELEOP (#598) — /cmd_vel activity indicator
- DIAGNOSTICS (#562) — /diagnostics liveness dot
- EVENT LOG (#576) — /rosout liveness dot
- SETTINGS (#614) — param service (config state, no topic)
- GIMBAL (#551) — /gimbal/state liveness dot

Each card shows: icon, title, issue #, description, topic chips,
and a LIVE/IDLE/OFFLINE status badge updated every second. Cards
open the linked standalone panel in the same tab.

Auto-detect rosbridge:
- Probes: page hostname:9090, localhost:9090, saltybot.local:9090
- Progress dots per candidate (trying/ok/fail)
- Falls back to manual URL entry
- Saves last successful URL to localStorage

Bottom bar:
-  E-STOP button (latches, publishes zero Twist to /cmd_vel)
  Space bar shortcut from dashboard
- RESUME button
- Drive mode switcher: MANUAL / AUTO / FOLLOW / DOCK
  (publishes to /saltybot/drive_mode std_msgs/String)
- Session timer (HH:MM:SS since page load)

Info strip: rosbridge URL · msg rate · latency (5s ping via
/rosapi/get_time) · robot IP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:35:56 -04:00
sl-android
26bf4ab8d3 feat: Add Termux voice command interface (Issue #633)
phone/voice_cmd.py — listens via termux-speech-to-text, parses commands
(go forward/back, turn left/right, stop, e-stop, go to waypoint, speed
up/down, status) and publishes structured JSON to saltybot/phone/voice_cmd.
TTS confirmation via termux-tts-speak. Manual text fallback via --text flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:35:27 -04:00
cb802ee76f feat: Cable management tray (Issue #628) 2026-03-15 14:33:49 -04:00
36 changed files with 4054 additions and 1514 deletions

410
chassis/cable_tray.scad Normal file
View File

@ -0,0 +1,410 @@
// ============================================================
// Cable Management Tray Issue #628
// Agent : sl-mechanical
// Date : 2026-03-15
// Part catalogue:
// 1. tray_body under-plate tray with snap-in cable channels, Velcro
// tie-down slots every 40 mm, pass-through holes, label slots
// 2. tnut_bracket 2020 T-nut rail mount bracket (×2, slide under tray)
// 3. channel_clip snap-in divider clip separating power / signal / servo zones
// 4. cover_panel hinged snap-on lid (living-hinge PETG flexure strip)
// 5. cable_saddle individual cable saddle / strain-relief clip (×n)
//
// BOM:
// 4 × M5×10 BHCS + M5 T-nuts (tnut_bracket × 2 to rail)
// 4 × M3×8 SHCS (tnut_bracket to tray body)
// n × 100 mm Velcro tie-down strips (through 6×2 mm slots, every 40 mm)
//
// Cable channel layout (X axis, inside tray):
// Zone A Power (2S6S LiPo, XT60/XT30): 20 mm wide, 14 mm deep
// Zone B Signal (JST-SH, PWM, I2C, UART): 14 mm wide, 10 mm deep
// Zone C Servo (JST-PH, thick servo leads): 14 mm wide, 12 mm deep
// Divider walls: 2.5 mm thick between zones
//
// Print settings (PETG):
// tray_body / tnut_bracket / channel_clip : 5 perimeters, 40 % gyroid, no supports
// cover_panel : 3 perimeters, 20 % gyroid, no supports
// (living-hinge print flat, thin strip flexes)
// cable_saddle : 3 perimeters, 30 % gyroid, no supports
//
// Export commands:
// openscad -D 'RENDER="tray_body"' -o tray_body.stl cable_tray.scad
// openscad -D 'RENDER="tnut_bracket"' -o tnut_bracket.stl cable_tray.scad
// openscad -D 'RENDER="channel_clip"' -o channel_clip.stl cable_tray.scad
// openscad -D 'RENDER="cover_panel"' -o cover_panel.stl cable_tray.scad
// openscad -D 'RENDER="cable_saddle"' -o cable_saddle.stl cable_tray.scad
// openscad -D 'RENDER="assembly"' -o assembly.png cable_tray.scad
// ============================================================
RENDER = "assembly"; // tray_body | tnut_bracket | channel_clip | cover_panel | cable_saddle | assembly
$fn = 48;
EPS = 0.01;
// 2020 rail constants
RAIL_W = 20.0;
TNUT_W = 9.8;
TNUT_H = 5.5;
TNUT_L = 12.0;
SLOT_NECK_H = 3.2;
M5_D = 5.2;
M5_HEAD_D = 9.5;
M5_HEAD_H = 4.0;
// Tray geometry
TRAY_L = 280.0; // length along rail (7 × 40 mm tie-down pitch)
TRAY_W = 60.0; // width across rail (covers standard 40 mm rail pair)
TRAY_WALL = 2.5; // side / floor wall thickness
TRAY_DEPTH = 18.0; // interior depth (tallest zone + wall)
// Cable channel zones (widths must sum to TRAY_W - 2*TRAY_WALL - 2*DIV_T)
DIV_T = 2.5; // divider wall thickness
ZONE_A_W = 20.0; // Power
ZONE_A_D = 14.0;
ZONE_B_W = 14.0; // Signal
ZONE_B_D = 10.0;
ZONE_C_W = 14.0; // Servo
ZONE_C_D = 12.0;
// Total inner width used: ZONE_A_W + ZONE_B_W + ZONE_C_W + 2*DIV_T = 55 mm < TRAY_W - 2*TRAY_WALL = 55 mm
// Tie-down slots (Velcro strips)
TIEDOWN_PITCH = 40.0;
TIEDOWN_W = 6.0; // slot width (fits 6 mm wide Velcro)
TIEDOWN_T = 2.2; // slot through-thickness (floor)
TIEDOWN_CNT = 7; // 7 positions along tray
// Pass-through holes in floor
PASSTHRU_D = 12.0; // circular grommet-compatible pass-through
PASSTHRU_CNT = 3; // one per zone, at tray mid-length
// Label slots (rear outer wall)
LABEL_W = 24.0;
LABEL_H = 8.0;
LABEL_T = 1.0; // depth from outer face
// Snap ledge for cover
SNAP_LEDGE_H = 2.5;
SNAP_LEDGE_D = 1.5;
// T-nut bracket
BKT_L = 60.0;
BKT_W = TRAY_W;
BKT_T = 6.0;
BOLT_PITCH = 40.0;
M3_D = 3.2;
M3_HEAD_D = 6.0;
M3_HEAD_H = 3.0;
M3_NUT_W = 5.5;
M3_NUT_H = 2.4;
// Cover panel
CVR_T = 1.8; // panel thickness
HINGE_T = 0.6; // living-hinge strip thickness (printed in PETG)
HINGE_W = 3.0; // hinge strip width (flexes easily)
SNAP_HOOK_H = 3.5; // snap hook height
SNAP_HOOK_T = 2.2;
// Cable saddle
SAD_W = 12.0;
SAD_H = 8.0;
SAD_T = 2.5;
SAD_BORE_D = 7.0; // cable bundle bore
SAD_CLIP_T = 1.6; // snap arm thickness
// Utilities
module chamfer_cube(size, ch=1.0) {
hull() {
translate([ch, ch, 0]) cube([size[0]-2*ch, size[1]-2*ch, EPS]);
translate([0, 0, ch]) cube(size - [0, 0, ch]);
}
}
module hex_pocket(af, depth) {
cylinder(d=af/cos(30), h=depth, $fn=6);
}
// Part 1: tray_body
module tray_body() {
difference() {
// Outer shell
union() {
chamfer_cube([TRAY_L, TRAY_W, TRAY_DEPTH + TRAY_WALL], ch=1.5);
// Snap ledge along top of both long walls (for cover_panel)
for (y = [-SNAP_LEDGE_D, TRAY_W])
translate([0, y, TRAY_DEPTH])
cube([TRAY_L, TRAY_WALL + SNAP_LEDGE_D, SNAP_LEDGE_H]);
}
// Interior cavity
translate([TRAY_WALL, TRAY_WALL, TRAY_WALL])
cube([TRAY_L - 2*TRAY_WALL, TRAY_W - 2*TRAY_WALL,
TRAY_DEPTH + EPS]);
// Zone dividers (subtract from solid to leave walls)
// Zone A (Power) inner floor cut full depth A
translate([TRAY_WALL, TRAY_WALL, TRAY_WALL + (TRAY_DEPTH - ZONE_A_D)])
cube([TRAY_L - 2*TRAY_WALL, ZONE_A_W, ZONE_A_D + EPS]);
// Zone B (Signal) inner floor cut
translate([TRAY_WALL, TRAY_WALL + ZONE_A_W + DIV_T,
TRAY_WALL + (TRAY_DEPTH - ZONE_B_D)])
cube([TRAY_L - 2*TRAY_WALL, ZONE_B_W, ZONE_B_D + EPS]);
// Zone C (Servo) inner floor cut
translate([TRAY_WALL, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T,
TRAY_WALL + (TRAY_DEPTH - ZONE_C_D)])
cube([TRAY_L - 2*TRAY_WALL, ZONE_C_W, ZONE_C_D + EPS]);
// Velcro tie-down slots (floor, every 40 mm)
for (i = [0:TIEDOWN_CNT-1]) {
x = TRAY_WALL + 20 + i * TIEDOWN_PITCH - TIEDOWN_W/2;
// Zone A slot
translate([x, TRAY_WALL + 2, -EPS])
cube([TIEDOWN_W, ZONE_A_W - 4, TRAY_WALL + 2*EPS]);
// Zone B slot
translate([x, TRAY_WALL + ZONE_A_W + DIV_T + 2, -EPS])
cube([TIEDOWN_W, ZONE_B_W - 4, TRAY_WALL + 2*EPS]);
// Zone C slot
translate([x, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + 2, -EPS])
cube([TIEDOWN_W, ZONE_C_W - 4, TRAY_WALL + 2*EPS]);
}
// Pass-through holes in floor (centre of each zone at mid-length)
mid_x = TRAY_L / 2;
// Zone A
translate([mid_x, TRAY_WALL + ZONE_A_W/2, -EPS])
cylinder(d=PASSTHRU_D, h=TRAY_WALL + 2*EPS);
// Zone B
translate([mid_x, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W/2, -EPS])
cylinder(d=PASSTHRU_D, h=TRAY_WALL + 2*EPS);
// Zone C
translate([mid_x, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + ZONE_C_W/2, -EPS])
cylinder(d=PASSTHRU_D, h=TRAY_WALL + 2*EPS);
// Label slots on front wall (y = 0) one per zone
zone_ctrs = [TRAY_WALL + ZONE_A_W/2,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W/2,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + ZONE_C_W/2];
label_z = TRAY_WALL + 2;
for (yc = zone_ctrs)
translate([TRAY_L/2 - LABEL_W/2, -EPS, label_z])
cube([LABEL_W, LABEL_T + EPS, LABEL_H]);
// M3 bracket bolt holes in floor (4 corners)
for (x = [20, TRAY_L - 20])
for (y = [TRAY_W/4, 3*TRAY_W/4])
translate([x, y, -EPS])
cylinder(d=M3_D, h=TRAY_WALL + 2*EPS);
// Channel clip snap sockets (top of each divider, every 80 mm)
for (i = [0:2]) {
cx = 40 + i * 80;
for (dy = [ZONE_A_W, ZONE_A_W + DIV_T + ZONE_B_W])
translate([cx - 3, TRAY_WALL + dy - 1, TRAY_DEPTH - 4])
cube([6, DIV_T + 2, 4 + EPS]);
}
}
// Divider walls (positive geometry)
// Wall between Zone A and Zone B
translate([TRAY_WALL, TRAY_WALL + ZONE_A_W, TRAY_WALL])
cube([TRAY_L - 2*TRAY_WALL, DIV_T,
TRAY_DEPTH - ZONE_A_D]); // partial height lower in A zone
// Wall between Zone B and Zone C
translate([TRAY_WALL, TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W, TRAY_WALL])
cube([TRAY_L - 2*TRAY_WALL, DIV_T,
TRAY_DEPTH - ZONE_B_D]);
}
// Part 2: tnut_bracket
module tnut_bracket() {
difference() {
chamfer_cube([BKT_L, BKT_W, BKT_T], ch=1.5);
// M5 T-nut holes (2 per bracket, on rail centreline)
for (x = [BKT_L/2 - BOLT_PITCH/2, BKT_L/2 + BOLT_PITCH/2]) {
translate([x, BKT_W/2, -EPS]) {
cylinder(d=M5_D, h=BKT_T + 2*EPS);
cylinder(d=M5_HEAD_D, h=M5_HEAD_H + EPS);
}
translate([x - TNUT_L/2, BKT_W/2 - TNUT_W/2, BKT_T - TNUT_H])
cube([TNUT_L, TNUT_W, TNUT_H + EPS]);
}
// M3 tray-attachment holes (4 corners)
for (x = [10, BKT_L - 10])
for (y = [10, BKT_W - 10]) {
translate([x, y, -EPS])
cylinder(d=M3_D, h=BKT_T + 2*EPS);
// M3 hex nut captured pocket (from top)
translate([x, y, BKT_T - M3_NUT_H - 0.2])
hex_pocket(M3_NUT_W + 0.3, M3_NUT_H + 0.3);
}
// Weight relief
translate([15, 8, -EPS])
cube([BKT_L - 30, BKT_W - 16, BKT_T/2]);
}
}
// Part 3: channel_clip
// Snap-in clip that locks into divider-wall snap sockets;
// holds individual bundles in their zone and acts as colour-coded zone marker.
module channel_clip() {
clip_body_w = 6.0;
clip_body_h = DIV_T + 4.0;
clip_body_t = 8.0;
tab_h = 3.5;
tab_w = 2.5;
difference() {
union() {
// Body spanning divider
cube([clip_body_t, clip_body_w, clip_body_h]);
// Snap tabs (bottom, straddle divider)
for (s = [0, clip_body_w - tab_w])
translate([clip_body_t/2 - 1, s, -tab_h])
cube([2, tab_w, tab_h + 1]);
}
// Cable radius slot on each face
translate([-EPS, clip_body_w/2, clip_body_h * 0.6])
rotate([0, 90, 0])
cylinder(d=5.0, h=clip_body_t + 2*EPS);
// Snap tab undercut for flex
for (s = [0, clip_body_w - tab_w])
translate([clip_body_t/2 - 2, s - EPS, -tab_h + 1.5])
cube([4, tab_w + 2*EPS, 1.5]);
}
}
// Part 4: cover_panel
// Flat snap-on lid with living-hinge along one long edge.
// Print flat; PETG living hinge flexes ~90° to snap onto tray.
module cover_panel() {
total_w = TRAY_W + 2 * SNAP_HOOK_T;
difference() {
union() {
// Main panel
cube([TRAY_L, TRAY_W, CVR_T]);
// Living hinge strip along back edge (thin, flexes)
translate([0, TRAY_W - EPS, 0])
cube([TRAY_L, HINGE_W, HINGE_T]);
// Snap hooks along front edge (clips under tray snap ledge)
for (x = [20, TRAY_L/2 - 20, TRAY_L/2 + 20, TRAY_L - 20])
translate([x - SNAP_HOOK_T/2, -SNAP_HOOK_H + EPS, 0])
difference() {
cube([SNAP_HOOK_T, SNAP_HOOK_H, CVR_T + 1.5]);
// Hook nose chamfer
translate([-EPS, -EPS, CVR_T])
rotate([0, 0, 0])
cube([SNAP_HOOK_T + 2*EPS, 1.5, 1.5]);
}
}
// Ventilation slots (3 rows × 6 slots)
for (row = [0:2])
for (col = [0:5]) {
sx = 20 + col * 40 + row * 10;
sy = 10 + row * 12;
if (sx + 25 < TRAY_L && sy + 6 < TRAY_W)
translate([sx, sy, -EPS])
cube([25, 6, CVR_T + 2*EPS]);
}
// Zone label windows (align with tray label slots)
zone_ctrs = [TRAY_WALL + ZONE_A_W/2,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W/2,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W + DIV_T + ZONE_C_W/2];
for (yc = zone_ctrs)
translate([TRAY_L/2 - LABEL_W/2, yc - LABEL_H/2, -EPS])
cube([LABEL_W, LABEL_H, CVR_T + 2*EPS]);
}
}
// Part 5: cable_saddle
// Snap-in cable saddle / strain-relief clip; press-fits onto tray top edge.
module cable_saddle() {
arm_gap = TRAY_WALL + 0.4; // fits over tray wall
arm_len = 8.0;
difference() {
union() {
// Body
chamfer_cube([SAD_W, SAD_T * 2 + arm_gap, SAD_H], ch=1.0);
// Cable retaining arch
translate([SAD_W/2, SAD_T + arm_gap/2, SAD_H])
scale([1, 0.6, 1])
difference() {
cylinder(d=SAD_BORE_D + SAD_CLIP_T * 2, h=SAD_T);
translate([0, 0, -EPS])
cylinder(d=SAD_BORE_D, h=SAD_T + 2*EPS);
translate([-SAD_BORE_D, 0, -EPS])
cube([SAD_BORE_D * 2, SAD_BORE_D, SAD_T + 2*EPS]);
}
}
// Slot for tray wall (negative)
translate([0, SAD_T, -EPS])
cube([SAD_W, arm_gap, arm_len + EPS]);
// M3 tie-down hole
translate([SAD_W/2, SAD_T + arm_gap/2, -EPS])
cylinder(d=M3_D, h=SAD_H + 2*EPS);
}
}
// Assembly
module assembly() {
// Tray body (open face up for visibility)
color("SteelBlue")
tray_body();
// Two T-nut brackets underneath at 1/4 and 3/4 length
for (bx = [TRAY_L/4 - BKT_L/2, 3*TRAY_L/4 - BKT_L/2])
color("DodgerBlue")
translate([bx, 0, -BKT_T])
tnut_bracket();
// Channel clips (3 per divider position, every 80 mm)
for (i = [0:2]) {
cx = 40 + i * 80;
// Divider A/B
color("Tomato", 0.8)
translate([cx - 4, TRAY_WALL + ZONE_A_W - 2, TRAY_DEPTH - 3])
channel_clip();
// Divider B/C
color("Orange", 0.8)
translate([cx - 4,
TRAY_WALL + ZONE_A_W + DIV_T + ZONE_B_W - 2,
TRAY_DEPTH - 3])
channel_clip();
}
// Cover panel (raised above tray to show interior)
color("LightSteelBlue", 0.5)
translate([0, 0, TRAY_DEPTH + SNAP_LEDGE_H + 4])
cover_panel();
// Cable saddles along front tray edge
for (x = [40, 120, 200])
color("SlateGray")
translate([x - SAD_W/2, -SAD_T * 2 - TRAY_WALL, 0])
cable_saddle();
}
// Dispatch
if (RENDER == "tray_body") tray_body();
else if (RENDER == "tnut_bracket") tnut_bracket();
else if (RENDER == "channel_clip") channel_clip();
else if (RENDER == "cover_panel") cover_panel();
else if (RENDER == "cable_saddle") cable_saddle();
else assembly();

View File

@ -271,4 +271,11 @@
#define LVC_HYSTERESIS_MV 200u // recovery hysteresis to prevent threshold chatter
#define LVC_TLM_HZ 1u // JLINK_TLM_LVC transmit rate (Hz)
// --- UART Command Protocol (Issue #629) ---
// Jetson-STM32 binary command protocol on UART5 (PC12/PD2)
// NOTE: Spec requested USART1 @ 115200; USART1 is occupied by JLink @ 921600.
#define UART_PROT_BAUD 115200u // baud rate for UART5 Jetson protocol
#define UART_PROT_HB_TIMEOUT_MS 500u // heartbeat timeout: Jetson considered lost after 500 ms
#endif // CONFIG_H

View File

@ -1,128 +1,95 @@
#ifndef UART_PROTOCOL_H
#define UART_PROTOCOL_H
/*
* uart_protocol.h UART command protocol for Jetson-STM32 communication (Issue #629)
*
* Frame format:
* [STX][LEN][CMD][PAYLOAD...][CRC8][ETX]
* 0x02 1B 1B 0-12 B 1B 0x03
*
* CRC8-SMBUS: poly=0x07, init=0x00, computed over CMD+PAYLOAD bytes.
*
* Physical layer: UART5 (PC12=TX / PD2=RX), GPIO_AF8_UART5, 115200 baud, no hw flow.
* NOTE: Spec requested USART1 @ 115200, but USART1 is occupied by JLink @ 921600.
* Implemented on UART5 instead; Jetson must connect to PC12/PD2.
*
* DMA: DMA1_Stream0_Channel4, circular 256-byte ring buffer.
* Heartbeat: if no frame received in UART_PROT_HB_TIMEOUT_MS (500 ms), Jetson is
* considered lost; caller must handle estop if needed.
*/
#include <stdint.h>
#include <stdbool.h>
/*
* uart_protocol.h -- Structured UART command protocol for Jetson-STM32
* communication (Issue #629)
*
* Hardware: UART5, PC12=TX / PD2=RX, 115200 baud
* Note: spec references USART1 but USART1 is occupied by JLink (Issue #120)
* at 921600 baud. UART5 is the designated secondary/debug UART and is used
* here as the structured-command channel.
*
* Frame format (both directions):
* [STX=0x02][LEN][CMD][PAYLOAD...][CRC8][ETX=0x03]
*
* STX : frame start sentinel (0x02)
* LEN : CMD byte + payload bytes (1 + payload_len)
* CMD : command / response type byte
* PAYLOAD : 0..N bytes, CMD-dependent
* CRC8 : CRC8-SMBUS (poly 0x07, init 0x00) over CMD+PAYLOAD
* ETX : frame end sentinel (0x03)
*
* Host (Jetson) to STM32 commands:
* 0x01 SET_VELOCITY - int16 left_rpm, int16 right_rpm (4 bytes)
* 0x02 GET_STATUS - no payload; reply URESP_STATUS
* 0x03 SET_PID - float kp, float ki, float kd (12 bytes, LE)
* 0x04 ESTOP - no payload; engage emergency stop
* 0x05 CLEAR_ESTOP - no payload; clear emergency stop
*
* STM32 to host responses:
* 0x80 ACK - uint8 echoed_cmd (1 byte)
* 0x81 NACK - uint8 echoed_cmd, uint8 error_code (2 bytes)
* 0x82 STATUS - uart_prot_status_t (8 bytes)
*
* Heartbeat: if no valid frame is received within UART_PROT_HB_TIMEOUT_MS,
* uart_protocol_is_active() returns false and velocity commands are ignored.
*
* DMA: UART5_RX uses DMA1_Stream0_Channel4 in circular mode (256-byte ring).
* Parser runs in uart_protocol_process() called from the main loop.
*/
/* ---- Frame constants ---- */
/* ── Frame delimiters ─────────────────────────────────────────────────────── */
#define UPROT_STX 0x02u
#define UPROT_ETX 0x03u
#define UPROT_RX_BUF_LEN 256u /* DMA ring buffer size (power of 2) */
#define UPROT_MAX_PAYLOAD 16u /* largest payload: SET_PID = 12 bytes */
/* ---- Command IDs (host to STM32) ---- */
#define UCMD_SET_VELOCITY 0x01u /* int16 left_rpm + int16 right_rpm */
#define UCMD_GET_STATUS 0x02u /* no payload */
#define UCMD_SET_PID 0x03u /* float kp, ki, kd (12 bytes LE) */
#define UCMD_ESTOP 0x04u /* no payload */
#define UCMD_CLEAR_ESTOP 0x05u /* no payload */
/* ── Command IDs (host → STM32) ───────────────────────────────────────────── */
#define UCMD_SET_VELOCITY 0x01u /* payload: int16 left_rpm, int16 right_rpm (4 B) */
#define UCMD_GET_STATUS 0x02u /* payload: none */
#define UCMD_SET_PID 0x03u /* payload: float kp, float ki, float kd (12 B) */
#define UCMD_ESTOP 0x04u /* payload: none */
#define UCMD_CLEAR_ESTOP 0x05u /* payload: none */
/* ---- Response IDs (STM32 to host) ---- */
#define URESP_ACK 0x80u /* 1-byte payload: echoed CMD */
#define URESP_NACK 0x81u /* 2-byte payload: CMD + error_code */
#define URESP_STATUS 0x82u /* 8-byte payload: uart_prot_status_t */
/* ── Response IDs (STM32 → host) ──────────────────────────────────────────── */
#define URESP_ACK 0x80u /* payload: 1 B — echoed CMD */
#define URESP_NACK 0x81u /* payload: 2 B — CMD, error_code */
#define URESP_STATUS 0x82u /* payload: sizeof(uart_prot_status_t) = 8 B */
/* ---- NACK error codes ---- */
#define UERR_UNKNOWN_CMD 0x01u /* unrecognised CMD byte */
#define UERR_BAD_LENGTH 0x02u /* LEN does not match CMD expectation */
#define UERR_BAD_CRC 0x03u /* CRC8 mismatch */
#define UERR_REFUSED 0x04u /* command refused in current state */
/* ── NACK error codes ─────────────────────────────────────────────────────── */
#define UERR_BAD_CRC 0x01u
#define UERR_BAD_LEN 0x02u
#define UERR_BAD_ETX 0x03u
#define UERR_ESTOP 0x04u /* command rejected — estop active */
#define UERR_DISARMED 0x05u /* velocity rejected — not armed */
/* ---- STATUS response payload (8 bytes, packed) ---- */
/* ── STATUS payload (URESP_STATUS, 8 bytes packed) ───────────────────────── */
typedef struct __attribute__((packed)) {
int16_t pitch_x10; /* pitch angle, degrees x10 (0.1° resolution) */
int16_t motor_cmd; /* balance PID ESC output, -1000..+1000 */
uint16_t vbat_mv; /* battery voltage (mV) */
uint8_t balance_state; /* BalanceState: 0=DISARMED, 1=ARMED, 2=TILT */
uint8_t estop_active; /* 1 = estop currently engaged */
} uart_prot_status_t; /* 8 bytes */
int16_t pitch_x10; /* pitch angle ×10 deg (balance controller) */
int16_t motor_cmd; /* ESC motor command -1000..+1000 */
uint16_t vbat_mv; /* battery voltage in mV */
uint8_t balance_state; /* BalanceState enum (0=DISARMED, 1=ARMED, …) */
uint8_t estop_active; /* non-zero if remote estop is latched */
} uart_prot_status_t;
/* ---- Volatile state (read from main loop) ---- */
/* ── Shared state (read by main.c) ────────────────────────────────────────── */
typedef struct {
/* SET_VELOCITY: set by parser, cleared by main loop */
volatile uint8_t vel_updated; /* 1 = new velocity command pending */
volatile int16_t left_rpm; /* requested left wheel RPM */
volatile int16_t right_rpm; /* requested right wheel RPM */
volatile uint8_t vel_updated; /* 1 when SET_VELOCITY received */
volatile int16_t left_rpm;
volatile int16_t right_rpm;
/* SET_PID: set by parser, cleared by main loop */
volatile uint8_t pid_updated; /* 1 = new PID gains pending */
volatile uint8_t pid_updated; /* 1 when SET_PID received */
volatile float pid_kp;
volatile float pid_ki;
volatile float pid_kd;
/* ESTOP / CLEAR_ESTOP: set by parser, cleared by main loop */
volatile uint8_t estop_req;
volatile uint8_t estop_clear_req;
volatile uint8_t estop_req; /* 1 on UCMD_ESTOP */
volatile uint8_t estop_clear_req; /* 1 on UCMD_CLEAR_ESTOP */
/* Heartbeat: updated on every valid frame */
volatile uint32_t last_rx_ms; /* HAL_GetTick() at last good frame; 0=none */
volatile uint32_t last_rx_ms; /* HAL_GetTick() of last valid frame */
} UartProtState;
extern volatile UartProtState uart_prot_state;
extern UartProtState uart_prot_state;
/* ---- API ---- */
/* ── API ───────────────────────────────────────────────────────────────────── */
/*
* uart_protocol_init() -- configure UART5 + DMA1_Stream0 and start reception.
* Call once during system init, before the main loop.
/**
* uart_protocol_init() configure UART5 + DMA, start circular receive.
* Must be called once during system init, before main loop.
*/
void uart_protocol_init(void);
/*
* uart_protocol_process() -- parse new bytes from DMA ring buffer, dispatch
* commands, and send ACK/NACK/STATUS responses.
* Call every main loop tick (non-blocking, < 5 µs when idle).
/**
* uart_protocol_process() drain DMA ring buffer, parse frames, dispatch commands.
* Call once per main loop iteration (every ~1 ms).
*/
void uart_protocol_process(void);
/*
* uart_protocol_is_active(now_ms) -- returns true if a valid frame was
* received within UART_PROT_HB_TIMEOUT_MS. Main loop uses this to gate
* SET_VELOCITY commands when the host is disconnected.
*/
bool uart_protocol_is_active(uint32_t now_ms);
/*
* uart_protocol_send_status(s) -- transmit a URESP_STATUS (0x82) frame.
* Called from main loop in response to GET_STATUS or periodically.
/**
* uart_protocol_send_status() build and TX a URESP_STATUS frame.
* @param s Pointer to status payload to send.
*/
void uart_protocol_send_status(const uart_prot_status_t *s);

View File

@ -0,0 +1,34 @@
aruco_detect:
ros__parameters:
# ── Marker specification ─────────────────────────────────────────────────
marker_size_m: 0.10 # physical side length of printed ArUco markers (m)
# measure the black border edge-to-edge
# ── Dock target filter ───────────────────────────────────────────────────
# dock_marker_ids: IDs that may serve as dock target.
# - Empty list [] → any detected marker is a dock candidate (closest wins)
# - Non-empty → only listed IDs are candidates; others still appear in
# /saltybot/aruco/markers but not in dock_target
# The saltybot dock uses marker ID 42 on the charging face by convention.
dock_marker_ids: [42]
# Maximum distance to consider a marker as dock target (m)
max_dock_range_m: 3.0
# ── ArUco dictionary ─────────────────────────────────────────────────────
aruco_dict: DICT_4X4_50 # cv2.aruco constant name
# Corner sub-pixel refinement (improves pose accuracy at close range)
# CORNER_REFINE_NONE — fastest, no refinement
# CORNER_REFINE_SUBPIX — best for high-contrast markers (recommended)
# CORNER_REFINE_CONTOUR — good for blurry or low-res images
corner_refinement: CORNER_REFINE_SUBPIX
# ── Frame / topic config ─────────────────────────────────────────────────
camera_frame: camera_color_optical_frame
# ── Debug image output (disabled by default) ─────────────────────────────
# When true, publishes annotated BGR image with markers + axes drawn.
# Useful for tuning but costs ~10 ms/frame encoding overhead.
draw_debug_image: false
debug_image_topic: /saltybot/aruco/debug_image

View File

@ -0,0 +1,29 @@
"""aruco_detect.launch.py — ArUco marker detection for docking (Issue #627)."""
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
def generate_launch_description():
pkg_share = get_package_share_directory('saltybot_aruco_detect')
default_params = os.path.join(pkg_share, 'config', 'aruco_detect_params.yaml')
params_arg = DeclareLaunchArgument(
'params_file',
default_value=default_params,
description='Path to aruco_detect_params.yaml',
)
aruco_node = Node(
package='saltybot_aruco_detect',
executable='aruco_detect',
name='aruco_detect',
output='screen',
parameters=[LaunchConfiguration('params_file')],
)
return LaunchDescription([params_arg, aruco_node])

View File

@ -0,0 +1,37 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>saltybot_aruco_detect</name>
<version>0.1.0</version>
<description>
ArUco marker detection for docking alignment (Issue #627).
Detects all DICT_4X4_50 markers from RealSense D435i RGB, estimates 6-DOF
pose with estimatePoseSingleMarkers, publishes PoseArray on
/saltybot/aruco/markers and closest dock-candidate marker on
/saltybot/aruco/dock_target (PoseStamped) with RViz MarkerArray and
JSON status.
</description>
<maintainer email="sl-perception@saltylab.local">sl-perception</maintainer>
<license>MIT</license>
<buildtool_depend>ament_python</buildtool_depend>
<depend>rclpy</depend>
<depend>std_msgs</depend>
<depend>sensor_msgs</depend>
<depend>geometry_msgs</depend>
<depend>visualization_msgs</depend>
<depend>cv_bridge</depend>
<exec_depend>python3-numpy</exec_depend>
<exec_depend>python3-opencv</exec_depend>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>

View File

@ -0,0 +1,518 @@
"""
aruco_detect_node.py ArUco marker detection for docking alignment (Issue #627).
Detects all DICT_4X4_50 ArUco markers visible in the RealSense D435i RGB stream,
estimates their 6-DOF poses relative to the camera using estimatePoseSingleMarkers,
and publishes:
/saltybot/aruco/markers geometry_msgs/PoseArray all detected markers
/saltybot/aruco/dock_target geometry_msgs/PoseStamped closest dock marker
/saltybot/aruco/viz visualization_msgs/MarkerArray RViz axes overlays
/saltybot/aruco/status std_msgs/String JSON summary (10 Hz)
Coordinate frame
All poses are expressed in camera_color_optical_frame (ROS optical convention):
+X = right, +Y = down, +Z = forward (into scene)
The dock_target pose gives the docking controller everything it needs:
position.z forward distance to dock (m) throttle target
position.x lateral offset (m) steer target
orientation marker orientation for final align yaw servo
Dock target selection
dock_marker_ids list[int] [] empty = accept any detected marker
non-empty = only these IDs are candidates
closest_wins bool true among candidates, prefer the nearest
max_dock_range_m float 3.0 ignore markers beyond this distance
estimatePoseSingleMarkers API
Uses cv2.aruco.estimatePoseSingleMarkers (legacy OpenCV API still present in
4.x with a deprecation notice). Falls back to per-marker cv2.solvePnP with
SOLVEPNP_IPPE_SQUARE if the legacy function is unavailable.
Both paths use the same camera_matrix / dist_coeffs from CameraInfo.
Subscribes
/camera/color/image_raw sensor_msgs/Image 30 Hz (BGR8)
/camera/color/camera_info sensor_msgs/CameraInfo (latched, once)
Publishes
/saltybot/aruco/markers geometry_msgs/PoseArray
/saltybot/aruco/dock_target geometry_msgs/PoseStamped
/saltybot/aruco/viz visualization_msgs/MarkerArray
/saltybot/aruco/status std_msgs/String (JSON, 10 Hz)
Parameters (see config/aruco_detect_params.yaml)
marker_size_m float 0.10 physical side length of printed markers (m)
dock_marker_ids int[] [] IDs to accept as dock targets (empty = all)
max_dock_range_m float 3.0 ignore candidates beyond this (m)
aruco_dict str DICT_4X4_50
corner_refinement str CORNER_REFINE_SUBPIX
camera_frame str camera_color_optical_frame
draw_debug_image bool false publish annotated image for debugging
debug_image_topic str /saltybot/aruco/debug_image
"""
from __future__ import annotations
import json
import math
import rclpy
from rclpy.node import Node
from rclpy.qos import (
QoSProfile, ReliabilityPolicy, HistoryPolicy, DurabilityPolicy
)
import numpy as np
import cv2
from cv_bridge import CvBridge
from geometry_msgs.msg import (
Pose, PoseArray, PoseStamped, Point, Quaternion, Vector3, TransformStamped
)
from sensor_msgs.msg import Image, CameraInfo
from std_msgs.msg import Header, String, ColorRGBA
from visualization_msgs.msg import Marker, MarkerArray
from .aruco_math import MarkerPose, rvec_to_quat, tvec_distance
# ── QoS ───────────────────────────────────────────────────────────────────────
_SENSOR_QOS = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST,
depth=5,
)
_LATCHED_QOS = QoSProfile(
reliability=ReliabilityPolicy.RELIABLE,
history=HistoryPolicy.KEEP_LAST,
depth=1,
durability=DurabilityPolicy.TRANSIENT_LOCAL,
)
# ArUco axis length for viz (fraction of marker size)
_AXIS_LEN_FRAC = 0.5
class ArucoDetectNode(Node):
"""ArUco marker detection + pose estimation node — see module docstring."""
def __init__(self) -> None:
super().__init__('aruco_detect')
# ── Parameters ────────────────────────────────────────────────────────
self.declare_parameter('marker_size_m', 0.10)
self.declare_parameter('dock_marker_ids', []) # empty list = all
self.declare_parameter('max_dock_range_m', 3.0)
self.declare_parameter('aruco_dict', 'DICT_4X4_50')
self.declare_parameter('corner_refinement', 'CORNER_REFINE_SUBPIX')
self.declare_parameter('camera_frame', 'camera_color_optical_frame')
self.declare_parameter('draw_debug_image', False)
self.declare_parameter('debug_image_topic', '/saltybot/aruco/debug_image')
self._marker_size = self.get_parameter('marker_size_m').value
self._dock_ids = set(self.get_parameter('dock_marker_ids').value)
self._max_range = self.get_parameter('max_dock_range_m').value
self._cam_frame = self.get_parameter('camera_frame').value
self._draw_debug = self.get_parameter('draw_debug_image').value
self._debug_topic = self.get_parameter('debug_image_topic').value
aruco_dict_name = self.get_parameter('aruco_dict').value
refine_name = self.get_parameter('corner_refinement').value
# ── ArUco detector setup ──────────────────────────────────────────────
dict_id = getattr(cv2.aruco, aruco_dict_name, cv2.aruco.DICT_4X4_50)
params = cv2.aruco.DetectorParameters()
# Apply corner refinement
refine_id = getattr(cv2.aruco, refine_name, cv2.aruco.CORNER_REFINE_SUBPIX)
params.cornerRefinementMethod = refine_id
aruco_dict = cv2.aruco.getPredefinedDictionary(dict_id)
self._detector = cv2.aruco.ArucoDetector(aruco_dict, params)
# Object points for solvePnP fallback (counter-clockwise from top-left)
half = self._marker_size / 2.0
self._obj_pts = np.array([
[-half, half, 0.0],
[ half, half, 0.0],
[ half, -half, 0.0],
[-half, -half, 0.0],
], dtype=np.float64)
# ── Camera intrinsics (set on first CameraInfo) ───────────────────────
self._K: np.ndarray | None = None
self._dist: np.ndarray | None = None
self._bridge = CvBridge()
# ── Publishers ─────────────────────────────────────────────────────────
self._pose_array_pub = self.create_publisher(
PoseArray, '/saltybot/aruco/markers', 10
)
self._dock_pub = self.create_publisher(
PoseStamped, '/saltybot/aruco/dock_target', 10
)
self._viz_pub = self.create_publisher(
MarkerArray, '/saltybot/aruco/viz', 10
)
self._status_pub = self.create_publisher(
String, '/saltybot/aruco/status', 10
)
if self._draw_debug:
self._debug_pub = self.create_publisher(
Image, self._debug_topic, 5
)
else:
self._debug_pub = None
# ── Subscriptions ──────────────────────────────────────────────────────
self.create_subscription(
CameraInfo,
'/camera/color/camera_info',
self._on_camera_info,
_LATCHED_QOS,
)
self.create_subscription(
Image,
'/camera/color/image_raw',
self._on_image,
_SENSOR_QOS,
)
# ── Status timer (10 Hz) ────────────────────────────────────────────────
self._last_detections: list[MarkerPose] = []
self.create_timer(0.1, self._on_status_timer)
self.get_logger().info(
f'aruco_detect ready — '
f'dict={aruco_dict_name} '
f'marker_size={self._marker_size * 100:.0f}cm '
f'dock_ids={list(self._dock_ids) if self._dock_ids else "any"} '
f'max_range={self._max_range}m'
)
# ── Camera info ───────────────────────────────────────────────────────────
def _on_camera_info(self, msg: CameraInfo) -> None:
if self._K is not None:
return
self._K = np.array(msg.k, dtype=np.float64).reshape(3, 3)
self._dist = np.array(msg.d, dtype=np.float64).reshape(1, -1)
self.get_logger().info(
f'camera_info received — '
f'fx={self._K[0,0]:.1f} fy={self._K[1,1]:.1f}'
)
# ── Image callback ────────────────────────────────────────────────────────
def _on_image(self, msg: Image) -> None:
if self._K is None:
return
# ── Decode ────────────────────────────────────────────────────────────
try:
bgr = self._bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
except Exception as exc:
self.get_logger().warning(f'image decode error: {exc}',
throttle_duration_sec=5.0)
return
# Convert to greyscale for detection (faster, same accuracy)
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
stamp = msg.header.stamp
# ── Detect markers ────────────────────────────────────────────────────
corners, ids, _ = self._detector.detectMarkers(gray)
if ids is None or len(ids) == 0:
self._last_detections = []
self._publish_empty(stamp)
return
ids_flat = ids.flatten().tolist()
# ── Pose estimation ───────────────────────────────────────────────────
detections = self._estimate_poses(corners, ids_flat)
# Filter by range
detections = [d for d in detections if d.distance_m <= self._max_range]
self._last_detections = detections
# ── Publish ───────────────────────────────────────────────────────────
self._publish_pose_array(detections, stamp)
self._publish_dock_target(detections, stamp)
self._publish_viz(detections, stamp)
# Debug image
if self._debug_pub is not None:
self._publish_debug(bgr, corners, ids, detections, stamp)
# ── Pose estimation ───────────────────────────────────────────────────────
def _estimate_poses(
self,
corners: list,
ids: list[int],
) -> list[MarkerPose]:
"""
Estimate 6-DOF pose for each detected marker.
Uses estimatePoseSingleMarkers (legacy API, still present in cv2 4.x).
Falls back to per-marker solvePnP(IPPE_SQUARE) if unavailable.
"""
results: list[MarkerPose] = []
# ── Try legacy estimatePoseSingleMarkers ──────────────────────────────
if hasattr(cv2.aruco, 'estimatePoseSingleMarkers'):
try:
rvecs, tvecs, _ = cv2.aruco.estimatePoseSingleMarkers(
corners, self._marker_size, self._K, self._dist
)
for i, mid in enumerate(ids):
rvec = rvecs[i].ravel().astype(np.float64)
tvec = tvecs[i].ravel().astype(np.float64)
results.append(MarkerPose(
marker_id = int(mid),
tvec = tvec,
rvec = rvec,
corners = corners[i].reshape(4, 2).astype(np.float64),
))
return results
except Exception as exc:
self.get_logger().debug(
f'estimatePoseSingleMarkers failed ({exc}), '
'falling back to solvePnP'
)
# ── Fallback: per-marker solvePnP ─────────────────────────────────────
for i, mid in enumerate(ids):
img_pts = corners[i].reshape(4, 2).astype(np.float64)
ok, rvec, tvec = cv2.solvePnP(
self._obj_pts, img_pts,
self._K, self._dist,
flags=cv2.SOLVEPNP_IPPE_SQUARE,
)
if not ok:
continue
results.append(MarkerPose(
marker_id = int(mid),
tvec = tvec.ravel().astype(np.float64),
rvec = rvec.ravel().astype(np.float64),
corners = img_pts,
))
return results
# ── Dock target selection ─────────────────────────────────────────────────
def _select_dock_target(
self,
detections: list[MarkerPose],
) -> MarkerPose | None:
"""Select the closest marker that matches dock_marker_ids filter."""
candidates = [
d for d in detections
if not self._dock_ids or d.marker_id in self._dock_ids
]
if not candidates:
return None
return min(candidates, key=lambda d: d.distance_m)
# ── Publishers ────────────────────────────────────────────────────────────
def _publish_pose_array(
self,
detections: list[MarkerPose],
stamp,
) -> None:
pa = PoseArray()
pa.header.stamp = stamp
pa.header.frame_id = self._cam_frame
for d in detections:
qx, qy, qz, qw = d.quat
pose = Pose()
pose.position = Point(x=float(d.tvec[0]),
y=float(d.tvec[1]),
z=float(d.tvec[2]))
pose.orientation = Quaternion(x=qx, y=qy, z=qz, w=qw)
pa.poses.append(pose)
self._pose_array_pub.publish(pa)
def _publish_dock_target(
self,
detections: list[MarkerPose],
stamp,
) -> None:
target = self._select_dock_target(detections)
if target is None:
return
qx, qy, qz, qw = target.quat
ps = PoseStamped()
ps.header.stamp = stamp
ps.header.frame_id = self._cam_frame
ps.pose.position = Point(x=float(target.tvec[0]),
y=float(target.tvec[1]),
z=float(target.tvec[2]))
ps.pose.orientation = Quaternion(x=qx, y=qy, z=qz, w=qw)
self._dock_pub.publish(ps)
def _publish_viz(
self,
detections: list[MarkerPose],
stamp,
) -> None:
"""Publish coordinate-axes MarkerArray for each detected marker."""
ma = MarkerArray()
lifetime = _ros_duration(0.2)
# Delete-all
dm = Marker()
dm.action = Marker.DELETEALL
dm.header.stamp = stamp
dm.header.frame_id = self._cam_frame
ma.markers.append(dm)
target = self._select_dock_target(detections)
axis_len = self._marker_size * _AXIS_LEN_FRAC
for idx, d in enumerate(detections):
is_target = (target is not None and d.marker_id == target.marker_id)
cx, cy, cz = float(d.tvec[0]), float(d.tvec[1]), float(d.tvec[2])
qx, qy, qz, qw = d.quat
base_id = idx * 10
# Sphere at marker centre
sph = Marker()
sph.header.stamp = stamp
sph.header.frame_id = self._cam_frame
sph.ns = 'aruco_centers'
sph.id = base_id
sph.type = Marker.SPHERE
sph.action = Marker.ADD
sph.pose.position = Point(x=cx, y=cy, z=cz)
sph.pose.orientation = Quaternion(x=qx, y=qy, z=qz, w=qw)
r_s = 0.015 if not is_target else 0.025
sph.scale = Vector3(x=r_s * 2, y=r_s * 2, z=r_s * 2)
sph.color = (
ColorRGBA(r=1.0, g=0.2, b=0.2, a=1.0) if is_target else
ColorRGBA(r=0.2, g=0.8, b=1.0, a=0.9)
)
sph.lifetime = lifetime
ma.markers.append(sph)
# Text label: ID + distance
txt = Marker()
txt.header.stamp = stamp
txt.header.frame_id = self._cam_frame
txt.ns = 'aruco_labels'
txt.id = base_id + 1
txt.type = Marker.TEXT_VIEW_FACING
txt.action = Marker.ADD
txt.pose.position = Point(x=cx, y=cy - 0.08, z=cz)
txt.pose.orientation.w = 1.0
txt.scale.z = 0.06
txt.color = ColorRGBA(r=1.0, g=1.0, b=0.0, a=1.0)
txt.text = f'ID={d.marker_id}\n{d.distance_m:.2f}m'
if is_target:
txt.text += '\n[DOCK]'
txt.lifetime = lifetime
ma.markers.append(txt)
self._viz_pub.publish(ma)
def _publish_empty(self, stamp) -> None:
pa = PoseArray()
pa.header.stamp = stamp
pa.header.frame_id = self._cam_frame
self._pose_array_pub.publish(pa)
self._publish_viz([], stamp)
def _publish_debug(self, bgr, corners, ids, detections, stamp) -> None:
"""Annotate and publish debug image with detected markers drawn."""
debug = bgr.copy()
if ids is not None and len(ids) > 0:
cv2.aruco.drawDetectedMarkers(debug, corners, ids)
# Draw axes for each detection using the estimated poses
for d in detections:
try:
cv2.drawFrameAxes(
debug, self._K, self._dist,
d.rvec.reshape(3, 1),
d.tvec.reshape(3, 1),
self._marker_size * _AXIS_LEN_FRAC,
)
except Exception:
pass
try:
img_msg = self._bridge.cv2_to_imgmsg(debug, encoding='bgr8')
img_msg.header.stamp = stamp
img_msg.header.frame_id = self._cam_frame
self._debug_pub.publish(img_msg)
except Exception:
pass
# ── Status timer ─────────────────────────────────────────────────────────
def _on_status_timer(self) -> None:
detections = self._last_detections
target = self._select_dock_target(detections)
markers_info = [
{
'id': d.marker_id,
'distance_m': round(d.distance_m, 3),
'yaw_deg': round(math.degrees(d.yaw_rad), 2),
'lateral_m': round(d.lateral_m, 3),
'forward_m': round(d.forward_m, 3),
'is_target': target is not None and d.marker_id == target.marker_id,
}
for d in detections
]
status = {
'detected_count': len(detections),
'dock_target_id': target.marker_id if target else None,
'dock_distance_m': round(target.distance_m, 3) if target else None,
'dock_yaw_deg': round(math.degrees(target.yaw_rad), 2) if target else None,
'dock_lateral_m': round(target.lateral_m, 3) if target else None,
'markers': markers_info,
}
msg = String()
msg.data = json.dumps(status)
self._status_pub.publish(msg)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _ros_duration(seconds: float):
from rclpy.duration import Duration
sec = int(seconds)
nsec = int((seconds - sec) * 1e9)
return Duration(seconds=sec, nanoseconds=nsec).to_msg()
def main(args=None) -> None:
rclpy.init(args=args)
node = ArucoDetectNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.try_shutdown()
if __name__ == '__main__':
main()

View File

@ -0,0 +1,156 @@
"""
aruco_math.py Pure-math helpers for ArUco detection (Issue #627).
No ROS2 dependencies importable in unit tests without a ROS2 install.
Provides:
rot_mat_to_quat(R) (qx, qy, qz, qw)
rvec_to_quat(rvec) (qx, qy, qz, qw) [via cv2.Rodrigues]
tvec_distance(tvec) float (Euclidean norm)
tvec_yaw_rad(tvec) float (atan2(tx, tz) lateral bearing error)
MarkerPose dataclass per-marker detection result
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
import numpy as np
# ── Rotation helpers ──────────────────────────────────────────────────────────
def rot_mat_to_quat(R: np.ndarray) -> Tuple[float, float, float, float]:
"""
Convert a 3×3 rotation matrix to quaternion (qx, qy, qz, qw).
Uses Shepperd's method for numerical stability.
R must be a valid rotation matrix (R = 1, det R = +1).
"""
R = np.asarray(R, dtype=np.float64)
trace = R[0, 0] + R[1, 1] + R[2, 2]
if trace > 0.0:
s = 0.5 / math.sqrt(trace + 1.0)
w = 0.25 / s
x = (R[2, 1] - R[1, 2]) * s
y = (R[0, 2] - R[2, 0]) * s
z = (R[1, 0] - R[0, 1]) * s
elif R[0, 0] > R[1, 1] and R[0, 0] > R[2, 2]:
s = 2.0 * math.sqrt(1.0 + R[0, 0] - R[1, 1] - R[2, 2])
w = (R[2, 1] - R[1, 2]) / s
x = 0.25 * s
y = (R[0, 1] + R[1, 0]) / s
z = (R[0, 2] + R[2, 0]) / s
elif R[1, 1] > R[2, 2]:
s = 2.0 * math.sqrt(1.0 + R[1, 1] - R[0, 0] - R[2, 2])
w = (R[0, 2] - R[2, 0]) / s
x = (R[0, 1] + R[1, 0]) / s
y = 0.25 * s
z = (R[1, 2] + R[2, 1]) / s
else:
s = 2.0 * math.sqrt(1.0 + R[2, 2] - R[0, 0] - R[1, 1])
w = (R[1, 0] - R[0, 1]) / s
x = (R[0, 2] + R[2, 0]) / s
y = (R[1, 2] + R[2, 1]) / s
z = 0.25 * s
# Normalise
norm = math.sqrt(x * x + y * y + z * z + w * w)
if norm < 1e-10:
return 0.0, 0.0, 0.0, 1.0
inv = 1.0 / norm
return x * inv, y * inv, z * inv, w * inv
def rvec_to_quat(rvec: np.ndarray) -> Tuple[float, float, float, float]:
"""
Convert a Rodrigues rotation vector (3,) or (1,3) to quaternion.
Requires cv2 for cv2.Rodrigues(); falls back to manual exponential map
if cv2 is unavailable.
"""
vec = np.asarray(rvec, dtype=np.float64).ravel()
try:
import cv2
R, _ = cv2.Rodrigues(vec)
except ImportError:
# Manual Rodrigues exponential map
angle = float(np.linalg.norm(vec))
if angle < 1e-12:
return 0.0, 0.0, 0.0, 1.0
axis = vec / angle
K = np.array([
[ 0.0, -axis[2], axis[1]],
[ axis[2], 0.0, -axis[0]],
[-axis[1], axis[0], 0.0],
])
R = np.eye(3) + math.sin(angle) * K + (1.0 - math.cos(angle)) * K @ K
return rot_mat_to_quat(R)
# ── Metric helpers ────────────────────────────────────────────────────────────
def tvec_distance(tvec: np.ndarray) -> float:
"""Euclidean distance from camera to marker (m)."""
t = np.asarray(tvec, dtype=np.float64).ravel()
return float(math.sqrt(t[0] ** 2 + t[1] ** 2 + t[2] ** 2))
def tvec_yaw_rad(tvec: np.ndarray) -> float:
"""
Horizontal bearing error to marker (rad).
atan2(tx, tz): +ve when marker is to the right of the camera optical axis.
Zero when the marker is directly in front.
"""
t = np.asarray(tvec, dtype=np.float64).ravel()
return float(math.atan2(t[0], t[2]))
# ── Per-marker result ─────────────────────────────────────────────────────────
@dataclass
class MarkerPose:
"""Pose result for a single detected ArUco marker."""
marker_id: int
tvec: np.ndarray # (3,) translation in camera optical frame (m)
rvec: np.ndarray # (3,) Rodrigues rotation vector
corners: np.ndarray # (4, 2) image-plane corner coordinates (px)
# Derived (computed lazily)
_distance_m: Optional[float] = field(default=None, repr=False)
_yaw_rad: Optional[float] = field(default=None, repr=False)
_quat: Optional[Tuple[float, float, float, float]] = field(default=None, repr=False)
@property
def distance_m(self) -> float:
if self._distance_m is None:
self._distance_m = tvec_distance(self.tvec)
return self._distance_m
@property
def yaw_rad(self) -> float:
if self._yaw_rad is None:
self._yaw_rad = tvec_yaw_rad(self.tvec)
return self._yaw_rad
@property
def lateral_m(self) -> float:
"""Lateral offset (m); +ve = marker is to the right."""
return float(self.tvec[0])
@property
def forward_m(self) -> float:
"""Forward distance (Z component in camera frame, m)."""
return float(self.tvec[2])
@property
def quat(self) -> Tuple[float, float, float, float]:
"""Quaternion (qx, qy, qz, qw) from rvec."""
if self._quat is None:
self._quat = rvec_to_quat(self.rvec)
return self._quat

View File

@ -0,0 +1,5 @@
[develop]
script_dir=$base/lib/saltybot_aruco_detect
[install]
install_scripts=$base/lib/saltybot_aruco_detect

View File

@ -0,0 +1,30 @@
import os
from glob import glob
from setuptools import setup
package_name = 'saltybot_aruco_detect'
setup(
name=package_name,
version='0.1.0',
packages=[package_name],
data_files=[
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
(os.path.join('share', package_name, 'launch'), glob('launch/*.py')),
(os.path.join('share', package_name, 'config'), glob('config/*.yaml')),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='sl-perception',
maintainer_email='sl-perception@saltylab.local',
description='ArUco marker detection for docking alignment (Issue #627)',
license='MIT',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'aruco_detect = saltybot_aruco_detect.aruco_detect_node:main',
],
},
)

View File

@ -0,0 +1,206 @@
"""Unit tests for aruco_math.py — no ROS2, no live camera required."""
import math
import numpy as np
import pytest
from saltybot_aruco_detect.aruco_math import (
rot_mat_to_quat,
rvec_to_quat,
tvec_distance,
tvec_yaw_rad,
MarkerPose,
)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _quat_norm(q):
return math.sqrt(sum(x * x for x in q))
def _quat_rotate(q, v):
"""Rotate vector v by quaternion q (qx, qy, qz, qw)."""
qx, qy, qz, qw = q
# Quaternion product: q * (0,v) * q^-1
# Using formula: v' = v + 2qw(q × v) + 2(q × (q × v))
qvec = np.array([qx, qy, qz])
t = 2.0 * np.cross(qvec, v)
return v + qw * t + np.cross(qvec, t)
# ── rot_mat_to_quat ───────────────────────────────────────────────────────────
def test_identity_rotation():
R = np.eye(3)
q = rot_mat_to_quat(R)
assert abs(q[3] - 1.0) < 1e-9 # w = 1 for identity
assert abs(q[0]) < 1e-9
assert abs(q[1]) < 1e-9
assert abs(q[2]) < 1e-9
def test_90deg_yaw():
"""90° rotation about Z axis."""
angle = math.pi / 2
R = np.array([
[math.cos(angle), -math.sin(angle), 0],
[math.sin(angle), math.cos(angle), 0],
[0, 0, 1],
])
qx, qy, qz, qw = rot_mat_to_quat(R)
# For 90° Z rotation: q = (0, 0, sin45°, cos45°)
assert abs(qx) < 1e-6
assert abs(qy) < 1e-6
assert abs(qz - math.sin(angle / 2)) < 1e-6
assert abs(qw - math.cos(angle / 2)) < 1e-6
def test_unit_norm():
for yaw in [0, 0.5, 1.0, 2.0, math.pi]:
R = np.array([
[math.cos(yaw), -math.sin(yaw), 0],
[math.sin(yaw), math.cos(yaw), 0],
[0, 0, 1],
])
q = rot_mat_to_quat(R)
assert abs(_quat_norm(q) - 1.0) < 1e-9, f"yaw={yaw}: norm={_quat_norm(q)}"
def test_roundtrip_rotation_matrix():
"""Rotating a vector with the matrix and quaternion should give same result."""
angle = 1.23
R = np.array([
[math.cos(angle), -math.sin(angle), 0],
[math.sin(angle), math.cos(angle), 0],
[0, 0, 1],
])
q = rot_mat_to_quat(R)
v = np.array([1.0, 0.0, 0.0])
v_R = R @ v
v_q = _quat_rotate(q, v)
np.testing.assert_allclose(v_R, v_q, atol=1e-9)
def test_180deg_pitch():
"""180° rotation about Y axis."""
R = np.array([[-1, 0, 0], [0, 1, 0], [0, 0, -1]], dtype=float)
q = rot_mat_to_quat(R)
assert abs(_quat_norm(q) - 1.0) < 1e-9
# For 180° Y: q = (0, 1, 0, 0) or (0, -1, 0, 0)
assert abs(abs(q[1]) - 1.0) < 1e-6
# ── rvec_to_quat ──────────────────────────────────────────────────────────────
def test_rvec_zero_is_identity():
q = rvec_to_quat(np.array([0.0, 0.0, 0.0]))
assert abs(_quat_norm(q) - 1.0) < 1e-6
assert abs(q[3] - 1.0) < 1e-6 # w = 1
def test_rvec_unit_norm():
for rvec in [
[0.1, 0.0, 0.0],
[0.0, 0.5, 0.0],
[0.0, 0.0, 1.57],
[0.3, 0.4, 0.5],
]:
q = rvec_to_quat(np.array(rvec))
assert abs(_quat_norm(q) - 1.0) < 1e-9, f"rvec={rvec}: norm={_quat_norm(q)}"
def test_rvec_z_rotation_matches_rot_mat():
"""rvec=[0,0,π/3] should match 60° Z rotation matrix quaternion."""
angle = math.pi / 3
rvec = np.array([0.0, 0.0, angle])
R = np.array([
[math.cos(angle), -math.sin(angle), 0],
[math.sin(angle), math.cos(angle), 0],
[0, 0, 1],
])
q_rvec = rvec_to_quat(rvec)
q_R = rot_mat_to_quat(R)
# Allow sign flip (q and -q represent the same rotation)
diff = min(
abs(_quat_norm(np.array(q_rvec) - np.array(q_R))),
abs(_quat_norm(np.array(q_rvec) + np.array(q_R))),
)
assert diff < 1e-6, f"rvec q={q_rvec}, mat q={q_R}"
# ── tvec_distance ─────────────────────────────────────────────────────────────
def test_tvec_distance_basic():
assert abs(tvec_distance(np.array([3.0, 4.0, 0.0])) - 5.0) < 1e-9
def test_tvec_distance_zero():
assert tvec_distance(np.array([0.0, 0.0, 0.0])) == 0.0
def test_tvec_distance_forward():
assert abs(tvec_distance(np.array([0.0, 0.0, 2.5])) - 2.5) < 1e-9
def test_tvec_distance_3d():
t = np.array([1.0, 2.0, 3.0])
expected = math.sqrt(14.0)
assert abs(tvec_distance(t) - expected) < 1e-9
# ── tvec_yaw_rad ──────────────────────────────────────────────────────────────
def test_yaw_zero_when_centred():
"""Marker directly in front: tx=0 → yaw=0."""
assert abs(tvec_yaw_rad(np.array([0.0, 0.0, 2.0]))) < 1e-9
def test_yaw_positive_right():
"""Marker to the right (tx>0) → positive yaw."""
assert tvec_yaw_rad(np.array([1.0, 0.0, 2.0])) > 0.0
def test_yaw_negative_left():
"""Marker to the left (tx<0) → negative yaw."""
assert tvec_yaw_rad(np.array([-1.0, 0.0, 2.0])) < 0.0
def test_yaw_45deg():
"""tx=tz → 45°."""
assert abs(tvec_yaw_rad(np.array([1.0, 0.0, 1.0])) - math.pi / 4) < 1e-9
# ── MarkerPose ────────────────────────────────────────────────────────────────
def _make_marker(mid=42, tx=0.0, ty=0.0, tz=2.0):
rvec = np.array([0.0, 0.0, 0.1])
tvec = np.array([tx, ty, tz])
corners = np.zeros((4, 2))
return MarkerPose(marker_id=mid, tvec=tvec, rvec=rvec, corners=corners)
def test_marker_distance_cached():
m = _make_marker(tz=3.0)
d1 = m.distance_m
d2 = m.distance_m
assert d1 == d2
def test_marker_lateral_and_forward():
m = _make_marker(tx=0.5, tz=2.0)
assert abs(m.lateral_m - 0.5) < 1e-9
assert abs(m.forward_m - 2.0) < 1e-9
def test_marker_quat_unit_norm():
m = _make_marker()
q = m.quat
assert abs(_quat_norm(q) - 1.0) < 1e-9
def test_marker_yaw_sign():
m_right = _make_marker(tx=+0.3, tz=1.0)
m_left = _make_marker(tx=-0.3, tz=1.0)
assert m_right.yaw_rad > 0
assert m_left.yaw_rad < 0

View File

@ -0,0 +1,7 @@
uwb_logger:
ros__parameters:
log_dir: ~/uwb_logs
enable_csv: true
default_n_samples: 200
default_timeout_s: 30.0
flush_interval_s: 5.0

View File

@ -0,0 +1,28 @@
"""uwb_logger.launch.py — Launch UWB position logger (Issue #634)."""
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
from ament_index_python.packages import get_package_share_directory
import os
def generate_launch_description() -> LaunchDescription:
pkg_dir = get_package_share_directory("saltybot_uwb_logger")
params_file = os.path.join(pkg_dir, "config", "uwb_logger_params.yaml")
return LaunchDescription([
DeclareLaunchArgument("log_dir", default_value="~/uwb_logs"),
Node(
package="saltybot_uwb_logger",
executable="uwb_logger",
name="uwb_logger",
parameters=[
params_file,
{"log_dir": LaunchConfiguration("log_dir")},
],
output="screen",
emulate_tty=True,
),
])

View File

@ -0,0 +1,31 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>saltybot_uwb_logger</name>
<version>0.1.0</version>
<description>
UWB position logger and accuracy analyzer (Issue #634).
Logs fused pose, raw UWB pose, per-anchor ranges to CSV.
Provides /saltybot/uwb/start_accuracy_test: collects N samples at a
known position and computes CEP50, CEP95, RMSE, max error.
</description>
<maintainer email="sl-uwb@saltylab.local">sl-uwb</maintainer>
<license>Apache-2.0</license>
<depend>rclpy</depend>
<depend>geometry_msgs</depend>
<depend>std_msgs</depend>
<depend>saltybot_uwb_msgs</depend>
<depend>saltybot_uwb_logger_msgs</depend>
<exec_depend>python3-numpy</exec_depend>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>

View File

@ -0,0 +1,99 @@
"""
accuracy_stats.py UWB positioning accuracy statistics (Issue #634)
Pure numpy, no ROS2 dependency.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
import numpy as np
@dataclass
class AccuracyStats:
n_samples: int
mean_x: float
mean_y: float
std_x: float
std_y: float
std_2d: float
bias_x: float
bias_y: float
cep50: float
cep95: float
max_error: float
rmse: float
def compute_stats(
xs: np.ndarray,
ys: np.ndarray,
truth_x: float,
truth_y: float,
) -> AccuracyStats:
"""
Compute accuracy statistics from position samples.
Parameters
----------
xs, ys : 1-D arrays of measured x / y positions (metres)
truth_x/y : known ground-truth position (metres)
Raises
------
ValueError if fewer than 2 samples or mismatched lengths.
"""
xs = np.asarray(xs, dtype=float)
ys = np.asarray(ys, dtype=float)
n = len(xs)
if n < 2:
raise ValueError(f"Need at least 2 samples, got {n}")
if len(ys) != n:
raise ValueError("xs and ys must have the same length")
errors = np.sqrt((xs - truth_x) ** 2 + (ys - truth_y) ** 2)
mean_x = float(np.mean(xs))
mean_y = float(np.mean(ys))
std_x = float(np.std(xs, ddof=1))
std_y = float(np.std(ys, ddof=1))
return AccuracyStats(
n_samples = n,
mean_x = mean_x,
mean_y = mean_y,
std_x = std_x,
std_y = std_y,
std_2d = math.sqrt(std_x ** 2 + std_y ** 2),
bias_x = mean_x - truth_x,
bias_y = mean_y - truth_y,
cep50 = float(np.percentile(errors, 50)),
cep95 = float(np.percentile(errors, 95)),
max_error = float(np.max(errors)),
rmse = float(np.sqrt(np.mean(errors ** 2))),
)
@dataclass
class RangeAccum:
"""Running accumulator for per-anchor range statistics."""
anchor_id: int
_samples: list = field(default_factory=list, repr=False)
def add(self, range_m: float) -> None:
self._samples.append(range_m)
@property
def count(self) -> int:
return len(self._samples)
@property
def mean(self) -> float:
return float(np.mean(self._samples)) if self._samples else 0.0
@property
def std(self) -> float:
return float(np.std(self._samples, ddof=1)) if len(self._samples) >= 2 else 0.0

View File

@ -0,0 +1,409 @@
"""
logger_node.py UWB position logger and accuracy analyzer (Issue #634)
Subscriptions
/saltybot/pose/fused geometry_msgs/PoseStamped EKF-fused pose (200 Hz)
/saltybot/uwb/pose geometry_msgs/PoseStamped raw UWB pose (10 Hz)
/uwb/ranges saltybot_uwb_msgs/UwbRangeArray per-anchor ranges
Service
/saltybot/uwb/start_accuracy_test StartAccuracyTest
Collects N fused-pose samples at known position, computes accuracy
metrics, publishes AccuracyReport, writes JSON summary.
Publishes
/saltybot/uwb/accuracy_report AccuracyReport
CSV logging (continuous, to log_dir)
fused_pose_<DATE>.csv timestamp_ns, x_m, y_m, heading_rad
uwb_pose_<DATE>.csv timestamp_ns, x_m, y_m
uwb_ranges_<DATE>.csv timestamp_ns, anchor_id, range_m, raw_mm, rssi, tag_id
accuracy_<test_id>.json accuracy test results
Parameters
log_dir ~/uwb_logs
enable_csv true
default_n_samples 200
default_timeout_s 30.0
flush_interval_s 5.0
"""
from __future__ import annotations
import csv
import json
import math
import os
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import numpy as np
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
from geometry_msgs.msg import PoseStamped
from saltybot_uwb_msgs.msg import UwbRangeArray
from saltybot_uwb_logger_msgs.msg import AccuracyReport
from saltybot_uwb_logger_msgs.srv import StartAccuracyTest
from saltybot_uwb_logger.accuracy_stats import compute_stats, RangeAccum
_SENSOR_QOS = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST,
depth=10,
)
class UwbLoggerNode(Node):
def __init__(self) -> None:
super().__init__("uwb_logger")
# ── Parameters ────────────────────────────────────────────────────
self.declare_parameter("log_dir", os.path.expanduser("~/uwb_logs"))
self.declare_parameter("enable_csv", True)
self.declare_parameter("default_n_samples", 200)
self.declare_parameter("default_timeout_s", 30.0)
self.declare_parameter("flush_interval_s", 5.0)
self._log_dir = Path(self.get_parameter("log_dir").value)
self._enable_csv = self.get_parameter("enable_csv").value
self._default_n = self.get_parameter("default_n_samples").value
self._default_to = self.get_parameter("default_timeout_s").value
self._flush_iv = self.get_parameter("flush_interval_s").value
if self._enable_csv:
self._log_dir.mkdir(parents=True, exist_ok=True)
# ── CSV writers ───────────────────────────────────────────────────
date_tag = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
self._csv_lock = threading.Lock()
self._csv_files = []
self._fused_csv = self._open_csv(
f"fused_pose_{date_tag}.csv",
["timestamp_ns", "x_m", "y_m", "heading_rad"])
self._uwb_csv = self._open_csv(
f"uwb_pose_{date_tag}.csv",
["timestamp_ns", "x_m", "y_m"])
self._ranges_csv = self._open_csv(
f"uwb_ranges_{date_tag}.csv",
["timestamp_ns", "anchor_id", "range_m", "raw_mm", "rssi", "tag_id"])
# ── Accuracy test state ────────────────────────────────────────────
self._test_lock = threading.Lock()
self._test_active = False
self._test_id = ""
self._test_truth_x = 0.0
self._test_truth_y = 0.0
self._test_n_target = 0
self._test_xs: list = []
self._test_ys: list = []
self._test_ranges: dict[int, RangeAccum] = {}
self._test_t0 = 0.0
self._test_timeout = 0.0
self._test_done_event = threading.Event()
# ── Publisher ─────────────────────────────────────────────────────
self._report_pub = self.create_publisher(
AccuracyReport, "/saltybot/uwb/accuracy_report", 10
)
# ── Subscriptions ─────────────────────────────────────────────────
self.create_subscription(
PoseStamped, "/saltybot/pose/fused",
self._fused_cb, _SENSOR_QOS)
self.create_subscription(
PoseStamped, "/saltybot/uwb/pose",
self._uwb_pose_cb, _SENSOR_QOS)
self.create_subscription(
UwbRangeArray, "/uwb/ranges",
self._ranges_cb, _SENSOR_QOS)
# ── Service ───────────────────────────────────────────────────────
self._srv = self.create_service(
StartAccuracyTest,
"/saltybot/uwb/start_accuracy_test",
self._handle_start_test)
# ── Periodic flush ────────────────────────────────────────────────
self.create_timer(self._flush_iv, self._flush_csv)
self.get_logger().info(
f"UWB logger ready — log_dir={self._log_dir} csv={self._enable_csv}"
)
def destroy_node(self) -> None:
self._flush_csv()
for fh in self._csv_files:
try:
fh.close()
except Exception:
pass
super().destroy_node()
# ── Callbacks ─────────────────────────────────────────────────────────
def _fused_cb(self, msg: PoseStamped) -> None:
ts = _stamp_ns(msg.header.stamp)
x = msg.pose.position.x
y = msg.pose.position.y
q = msg.pose.orientation
heading = 2.0 * math.atan2(float(q.z), float(q.w))
if self._enable_csv:
with self._csv_lock:
if self._fused_csv:
self._fused_csv.writerow([ts, x, y, heading])
with self._test_lock:
if self._test_active:
self._test_xs.append(x)
self._test_ys.append(y)
if len(self._test_xs) >= self._test_n_target:
self._test_active = False
self._test_done_event.set()
def _uwb_pose_cb(self, msg: PoseStamped) -> None:
ts = _stamp_ns(msg.header.stamp)
x = msg.pose.position.x
y = msg.pose.position.y
if self._enable_csv:
with self._csv_lock:
if self._uwb_csv:
self._uwb_csv.writerow([ts, x, y])
def _ranges_cb(self, msg: UwbRangeArray) -> None:
ts = _stamp_ns(msg.header.stamp)
for r in msg.ranges:
if self._enable_csv:
with self._csv_lock:
if self._ranges_csv:
self._ranges_csv.writerow([
ts, r.anchor_id, r.range_m,
r.raw_mm, r.rssi, r.tag_id,
])
with self._test_lock:
if self._test_active:
aid = int(r.anchor_id)
if aid not in self._test_ranges:
self._test_ranges[aid] = RangeAccum(anchor_id=aid)
self._test_ranges[aid].add(float(r.range_m))
# ── Service ────────────────────────────────────────────────────────────
def _handle_start_test(
self,
request: StartAccuracyTest.Request,
response: StartAccuracyTest.Response,
) -> StartAccuracyTest.Response:
with self._test_lock:
if self._test_active:
response.success = False
response.message = "Another test is already running"
response.test_id = self._test_id
return response
n = int(request.n_samples) if request.n_samples > 0 else self._default_n
timeout = float(request.timeout_s) if request.timeout_s > 0.0 else self._default_to
test_id = (
request.test_id.strip()
if request.test_id.strip()
else datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
)
self.get_logger().info(
f"Accuracy test '{test_id}'"
f"truth=({request.truth_x_m:.3f}, {request.truth_y_m:.3f}) "
f"n={n} timeout={timeout}s"
)
with self._test_lock:
self._test_id = test_id
self._test_truth_x = float(request.truth_x_m)
self._test_truth_y = float(request.truth_y_m)
self._test_n_target = n
self._test_xs = []
self._test_ys = []
self._test_ranges = {}
self._test_t0 = time.monotonic()
self._test_timeout = timeout
self._test_done_event.clear()
self._test_active = True
threading.Thread(
target=self._run_test,
args=(test_id, request.truth_x_m, request.truth_y_m, n, timeout),
daemon=True,
).start()
response.success = True
response.message = f"Test '{test_id}' started, collecting {n} samples"
response.test_id = test_id
return response
def _run_test(
self,
test_id: str,
truth_x: float,
truth_y: float,
n_target: int,
timeout: float,
) -> None:
finished = self._test_done_event.wait(timeout=timeout)
with self._test_lock:
self._test_active = False
xs = list(self._test_xs)
ys = list(self._test_ys)
ranges_snap = dict(self._test_ranges)
t0 = self._test_t0
duration = time.monotonic() - t0
n_got = len(xs)
if n_got < 2:
self.get_logger().error(
f"Test '{test_id}' failed: only {n_got} samples in {duration:.1f}s"
)
return
if not finished:
self.get_logger().warn(
f"Test '{test_id}' timed out — {n_got}/{n_target} samples"
)
try:
stats = compute_stats(np.array(xs), np.array(ys), truth_x, truth_y)
except ValueError as exc:
self.get_logger().error(f"Stats failed: {exc}")
return
self.get_logger().info(
f"Test '{test_id}' — n={stats.n_samples} "
f"CEP50={stats.cep50*100:.1f}cm CEP95={stats.cep95*100:.1f}cm "
f"RMSE={stats.rmse*100:.1f}cm max={stats.max_error*100:.1f}cm"
)
# Publish report
report = AccuracyReport()
report.header.stamp = self.get_clock().now().to_msg()
report.header.frame_id = "map"
report.truth_x_m = truth_x
report.truth_y_m = truth_y
report.n_samples = stats.n_samples
report.mean_x_m = stats.mean_x
report.mean_y_m = stats.mean_y
report.std_x_m = stats.std_x
report.std_y_m = stats.std_y
report.std_2d_m = stats.std_2d
report.bias_x_m = stats.bias_x
report.bias_y_m = stats.bias_y
report.cep50_m = stats.cep50
report.cep95_m = stats.cep95
report.max_error_m = stats.max_error
report.rmse_m = stats.rmse
report.test_id = test_id
report.duration_s = duration
anchor_ids = sorted(ranges_snap.keys())
report.anchor_ids = [int(a) for a in anchor_ids]
report.anchor_range_mean_m = [ranges_snap[a].mean for a in anchor_ids]
report.anchor_range_std_m = [ranges_snap[a].std for a in anchor_ids]
self._report_pub.publish(report)
# Write JSON
if self._enable_csv:
self._write_json(test_id, truth_x, truth_y, stats,
anchor_ids, ranges_snap, duration)
# ── CSV / JSON helpers ─────────────────────────────────────────────────
def _open_csv(
self, filename: str, headers: list
) -> Optional[csv.writer]:
if not self._enable_csv:
return None
path = self._log_dir / filename
fh = open(path, "w", newline="", buffering=1)
writer = csv.writer(fh)
writer.writerow(headers)
self._csv_files.append(fh)
return writer
def _flush_csv(self) -> None:
for fh in self._csv_files:
try:
fh.flush()
except Exception:
pass
def _write_json(self, test_id, truth_x, truth_y, stats,
anchor_ids, ranges_snap, duration) -> None:
path = self._log_dir / f"accuracy_{test_id}.json"
data = {
"test_id": test_id,
"truth": {"x_m": truth_x, "y_m": truth_y},
"n_samples": stats.n_samples,
"duration_s": round(duration, 3),
"mean": {"x_m": round(stats.mean_x, 4),
"y_m": round(stats.mean_y, 4)},
"bias": {"x_m": round(stats.bias_x, 4),
"y_m": round(stats.bias_y, 4)},
"std": {"x_m": round(stats.std_x, 4),
"y_m": round(stats.std_y, 4),
"2d_m": round(stats.std_2d, 4)},
"cep50_m": round(stats.cep50, 4),
"cep95_m": round(stats.cep95, 4),
"rmse_m": round(stats.rmse, 4),
"max_error_m": round(stats.max_error, 4),
"anchors": {
str(aid): {
"range_mean_m": round(ranges_snap[aid].mean, 4),
"range_std_m": round(ranges_snap[aid].std, 4),
"n": ranges_snap[aid].count,
}
for aid in anchor_ids
},
}
try:
with open(path, "w") as f:
json.dump(data, f, indent=2)
self.get_logger().info(f"Report written: {path}")
except OSError as exc:
self.get_logger().error(f"JSON write failed: {exc}")
# ── Helpers ────────────────────────────────────────────────────────────────
def _stamp_ns(stamp) -> int:
return stamp.sec * 1_000_000_000 + stamp.nanosec
def main(args=None) -> None:
rclpy.init(args=args)
node = UwbLoggerNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.try_shutdown()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/saltybot_uwb_logger
[install]
install_scripts=$base/lib/saltybot_uwb_logger

View File

@ -0,0 +1,29 @@
import os
from glob import glob
from setuptools import setup
package_name = "saltybot_uwb_logger"
setup(
name=package_name,
version="0.1.0",
packages=[package_name],
data_files=[
("share/ament_index/resource_index/packages",
[f"resource/{package_name}"]),
(f"share/{package_name}", ["package.xml"]),
(os.path.join("share", package_name, "launch"), glob("launch/*.py")),
(os.path.join("share", package_name, "config"), glob("config/*.yaml")),
],
install_requires=["setuptools"],
zip_safe=True,
maintainer="sl-uwb",
maintainer_email="sl-uwb@saltylab.local",
description="UWB position logger and accuracy analyzer (Issue #634)",
license="Apache-2.0",
entry_points={
"console_scripts": [
"uwb_logger = saltybot_uwb_logger.logger_node:main",
],
},
)

View File

@ -0,0 +1,116 @@
"""Unit tests for saltybot_uwb_logger.accuracy_stats (Issue #634). No ROS2 needed."""
import math
import sys
import os
import numpy as np
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from saltybot_uwb_logger.accuracy_stats import compute_stats, RangeAccum
def _circle(n, r, cx=0.0, cy=0.0):
a = np.linspace(0, 2 * math.pi, n, endpoint=False)
return cx + r * np.cos(a), cy + r * np.sin(a)
class TestComputeStats:
def test_zero_error(self):
xs = np.full(5, 2.0); ys = np.full(5, 3.0)
s = compute_stats(xs, ys, 2.0, 3.0)
assert abs(s.cep50) < 1e-9
assert abs(s.rmse) < 1e-9
assert abs(s.max_error) < 1e-9
def test_known_bias(self):
rng = np.random.default_rng(0)
xs = rng.normal(4.0, 0.01, 100); ys = rng.normal(2.0, 0.01, 100)
s = compute_stats(xs, ys, 3.0, 2.0)
assert abs(s.bias_x - 1.0) < 0.05
assert abs(s.bias_y) < 0.05
def test_cep50_below_cep95(self):
rng = np.random.default_rng(1)
xs = rng.normal(0, 0.1, 500); ys = rng.normal(0, 0.1, 500)
s = compute_stats(xs, ys, 0.0, 0.0)
assert s.cep50 < s.cep95
def test_cep50_is_median(self):
rng = np.random.default_rng(2)
xs = rng.normal(0, 0.15, 1000); ys = rng.normal(0, 0.15, 1000)
errors = np.sqrt(xs**2 + ys**2)
s = compute_stats(xs, ys, 0.0, 0.0)
assert abs(s.cep50 - float(np.median(errors))) < 1e-9
def test_rmse(self):
xs = np.array([0.1, -0.1, 0.2, -0.2]); ys = np.zeros(4)
s = compute_stats(xs, ys, 0.0, 0.0)
assert abs(s.rmse - math.sqrt(np.mean(xs**2))) < 1e-9
def test_max_error(self):
xs = np.array([0.0, 0.0, 1.0]); ys = np.zeros(3)
s = compute_stats(xs, ys, 0.0, 0.0)
assert abs(s.max_error - 1.0) < 1e-9
def test_circle_symmetry(self):
xs, ys = _circle(360, 0.12)
s = compute_stats(xs, ys, 0.0, 0.0)
assert abs(s.bias_x) < 1e-6
assert abs(s.cep50 - 0.12) < 1e-6
def test_std_2d_pythagorean(self):
rng = np.random.default_rng(3)
xs = rng.normal(0, 0.1, 200); ys = rng.normal(0, 0.2, 200)
s = compute_stats(xs, ys, 0.0, 0.0)
assert abs(s.std_2d - math.sqrt(s.std_x**2 + s.std_y**2)) < 1e-9
def test_n_samples(self):
xs = np.ones(42); ys = np.zeros(42)
s = compute_stats(xs, ys, 0.0, 0.0)
assert s.n_samples == 42
def test_too_few_raises(self):
with pytest.raises(ValueError):
compute_stats(np.array([1.0]), np.array([1.0]), 0.0, 0.0)
def test_length_mismatch_raises(self):
with pytest.raises(ValueError):
compute_stats(np.array([1.0, 2.0]), np.array([1.0]), 0.0, 0.0)
class TestRealistic:
def test_10cm_cep50(self):
rng = np.random.default_rng(42)
xs = rng.normal(3.0, 0.07, 500); ys = rng.normal(2.0, 0.07, 500)
s = compute_stats(xs, ys, 3.0, 2.0)
assert 0.05 < s.cep50 < 0.20
def test_biased_uwb(self):
rng = np.random.default_rng(7)
xs = rng.normal(3.15, 0.05, 300); ys = rng.normal(2.0, 0.05, 300)
s = compute_stats(xs, ys, 3.0, 2.0)
assert abs(s.bias_x - 0.15) < 0.02
class TestRangeAccum:
def test_empty(self):
a = RangeAccum(0)
assert a.mean == 0.0 and a.std == 0.0 and a.count == 0
def test_single_std_zero(self):
a = RangeAccum(0); a.add(2.0)
assert a.std == 0.0
def test_mean_std(self):
a = RangeAccum(0)
for v in [1.0, 2.0, 3.0, 4.0, 5.0]:
a.add(v)
assert abs(a.mean - 3.0) < 1e-9
assert abs(a.std - float(np.std([1,2,3,4,5], ddof=1))) < 1e-9
assert a.count == 5
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -0,0 +1,15 @@
cmake_minimum_required(VERSION 3.8)
project(saltybot_uwb_logger_msgs)
find_package(ament_cmake REQUIRED)
find_package(std_msgs REQUIRED)
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/AccuracyReport.msg"
"srv/StartAccuracyTest.srv"
DEPENDENCIES std_msgs
)
ament_export_dependencies(rosidl_default_runtime)
ament_package()

View File

@ -0,0 +1,32 @@
# AccuracyReport.msg — UWB positioning accuracy test results (Issue #634)
#
# Published on /saltybot/uwb/accuracy_report after StartAccuracyTest completes.
std_msgs/Header header
float64 truth_x_m
float64 truth_y_m
uint32 n_samples
float64 mean_x_m
float64 mean_y_m
float64 std_x_m
float64 std_y_m
float64 std_2d_m
float64 bias_x_m
float64 bias_y_m
float64 cep50_m
float64 cep95_m
float64 max_error_m
float64 rmse_m
uint8[] anchor_ids
float64[] anchor_range_mean_m
float64[] anchor_range_std_m
string test_id
float64 duration_s

View File

@ -0,0 +1,21 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>saltybot_uwb_logger_msgs</name>
<version>0.1.0</version>
<description>Message and service definitions for UWB logger (Issue #634).</description>
<maintainer email="sl-uwb@saltylab.local">sl-uwb</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<buildtool_depend>rosidl_default_generators</buildtool_depend>
<depend>std_msgs</depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>

View File

@ -0,0 +1,15 @@
# StartAccuracyTest.srv — trigger UWB accuracy test at a known position (Issue #634)
# ── Request ────────────────────────────────────────────────────────────────
float64 truth_x_m
float64 truth_y_m
uint32 n_samples
float64 timeout_s
string test_id
---
# ── Response ───────────────────────────────────────────────────────────────
bool success
string message
string test_id

398
phone/voice_cmd.py Normal file
View File

@ -0,0 +1,398 @@
#!/usr/bin/env python3
"""
voice_cmd.py Termux voice command interface for SaltyBot (Issue #633)
Listens for voice commands via termux-speech-to-text (Android built-in STT),
parses them into structured JSON payloads, and publishes to MQTT.
Provides TTS confirmation via termux-tts-speak.
Falls back to manual text input when STT is unavailable.
MQTT Topic
saltybot/phone/voice_cmd published on each recognised command
JSON Payload
{
"ts": 1710000000.000, # Unix timestamp
"cmd": "go_forward", # command enum (see COMMANDS below)
"param": null, # optional parameter (e.g. waypoint name)
"raw": "go forward", # original recognised text
"source": "stt" # "stt" | "text"
}
Commands
go_forward, go_back, turn_left, turn_right, stop, estop,
go_waypoint (param = waypoint name), speed_up, speed_down, status
Usage
python3 phone/voice_cmd.py [OPTIONS]
--broker HOST MQTT broker IP/hostname (default: 192.168.1.100)
--port PORT MQTT broker port (default: 1883)
--qos INT MQTT QoS level 0/1/2 (default: 0)
--text Manual text fallback mode (no STT)
--no-tts Disable TTS confirmation
--timeout SECS STT listen timeout in seconds (default: 10)
--debug Verbose logging
Dependencies (Termux)
pkg install termux-api python
pip install paho-mqtt
"""
import argparse
import json
import logging
import subprocess
import sys
import threading
import time
from typing import Optional, Tuple
try:
import paho.mqtt.client as mqtt
MQTT_AVAILABLE = True
except ImportError:
MQTT_AVAILABLE = False
# ── MQTT topic ────────────────────────────────────────────────────────────────
TOPIC_VOICE_CMD = "saltybot/phone/voice_cmd"
# ── Command table ─────────────────────────────────────────────────────────────
#
# Each entry: (command_id, [phrase_fragments, ...])
# Phrases are matched as case-insensitive substrings of the recognised text.
# First match wins; order matters for disambiguation.
_CMD_TABLE = [
# E-stop must be checked before plain "stop"
("estop", ["emergency stop", "e stop", "estop", "abort"]),
("stop", ["stop", "halt", "freeze", "cancel"]),
("go_forward", ["go forward", "move forward", "forward", "ahead", "advance"]),
("go_back", ["go back", "move back", "backward", "reverse", "back up"]),
("turn_left", ["turn left", "rotate left", "left"]),
("turn_right", ["turn right", "rotate right", "right"]),
("go_waypoint", ["go to waypoint", "go to", "navigate to", "waypoint"]),
("speed_up", ["speed up", "faster", "increase speed"]),
("speed_down", ["slow down", "slower", "decrease speed", "decelerate"]),
("status", ["status", "report", "where are you", "how are you"]),
]
# TTS responses per command
_TTS_RESPONSES = {
"go_forward": "Going forward",
"go_back": "Going back",
"turn_left": "Turning left",
"turn_right": "Turning right",
"stop": "Stopping",
"estop": "Emergency stop!",
"go_waypoint": "Navigating to waypoint",
"speed_up": "Speeding up",
"speed_down": "Slowing down",
"status": "Requesting status",
}
# ── termux-api helpers ────────────────────────────────────────────────────────
def stt_listen(timeout: float = 10.0) -> Optional[str]:
"""
Call termux-speech-to-text and return the recognised text string, or None.
termux-speech-to-text returns JSON:
{"partial": "...", "text": "final text"}
or on failure returns empty / error output.
"""
try:
result = subprocess.run(
["termux-speech-to-text"],
capture_output=True,
text=True,
timeout=timeout + 5.0, # extra buffer for app round-trip
)
stdout = result.stdout.strip()
if not stdout:
logging.debug("STT: empty response (rc=%d)", result.returncode)
return None
# termux-speech-to-text may return bare text or JSON
try:
data = json.loads(stdout)
# Prefer "text" (final); fall back to "partial"
text = data.get("text") or data.get("partial") or ""
text = text.strip()
except (json.JSONDecodeError, AttributeError):
# Some versions return plain text directly
text = stdout
if not text:
logging.debug("STT: no text in response: %r", stdout)
return None
logging.info("STT recognised: %r", text)
return text
except subprocess.TimeoutExpired:
logging.warning("STT: timed out after %.1f s", timeout + 5.0)
return None
except FileNotFoundError:
logging.error("STT: termux-speech-to-text not found — install termux-api")
return None
except Exception as e:
logging.warning("STT error: %s", e)
return None
def tts_speak(text: str) -> None:
"""Speak @text via termux-tts-speak (fire-and-forget)."""
try:
subprocess.Popen(
["termux-tts-speak", text],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
logging.debug("TTS: termux-tts-speak not found")
except Exception as e:
logging.debug("TTS error: %s", e)
# ── Command parser ────────────────────────────────────────────────────────────
def parse_command(text: str) -> Tuple[Optional[str], Optional[str]]:
"""
Match @text against the command table.
Returns (command_id, param) where param is non-None only for go_waypoint.
Returns (None, None) if no command matched.
"""
lower = text.lower().strip()
for cmd_id, phrases in _CMD_TABLE:
for phrase in phrases:
if phrase in lower:
param = None
if cmd_id == "go_waypoint":
# Extract waypoint name: text after "waypoint" / "go to" / "navigate to"
for marker in ("waypoint", "navigate to", "go to"):
idx = lower.find(marker)
if idx != -1:
remainder = text[idx + len(marker):].strip()
if remainder:
param = remainder
break
return cmd_id, param
return None, None
# ── MQTT publisher (same pattern as sensor_dashboard.py) ─────────────────────
class MQTTPublisher:
"""Thin paho-mqtt wrapper with auto-reconnect."""
_RECONNECT_BASE = 2.0
_RECONNECT_MAX = 60.0
def __init__(self, broker: str, port: int, qos: int = 0):
self._broker = broker
self._port = port
self._qos = qos
self._lock = threading.Lock()
self._connected = False
self._client = mqtt.Client(client_id="saltybot-voice-cmd", clean_session=True)
self._client.on_connect = self._on_connect
self._client.on_disconnect = self._on_disconnect
self._client.reconnect_delay_set(
min_delay=int(self._RECONNECT_BASE),
max_delay=int(self._RECONNECT_MAX),
)
self._connect()
def _connect(self) -> None:
try:
self._client.connect_async(self._broker, self._port, keepalive=60)
self._client.loop_start()
logging.info("MQTT connecting to %s:%d", self._broker, self._port)
except Exception as e:
logging.warning("MQTT connect error: %s", e)
def _on_connect(self, client, userdata, flags, rc) -> None:
if rc == 0:
with self._lock:
self._connected = True
logging.info("MQTT connected to %s:%d", self._broker, self._port)
else:
logging.warning("MQTT connect failed rc=%d", rc)
def _on_disconnect(self, client, userdata, rc) -> None:
with self._lock:
self._connected = False
if rc != 0:
logging.warning("MQTT disconnected (rc=%d) — paho will retry", rc)
@property
def connected(self) -> bool:
with self._lock:
return self._connected
def publish(self, topic: str, payload: dict) -> bool:
if not self.connected:
logging.debug("MQTT offline — dropping %s", topic)
return False
try:
msg = json.dumps(payload, separators=(",", ":"))
info = self._client.publish(topic, msg, qos=self._qos, retain=False)
return info.rc == mqtt.MQTT_ERR_SUCCESS
except Exception as e:
logging.warning("MQTT publish error: %s", e)
return False
def shutdown(self) -> None:
self._client.loop_stop()
self._client.disconnect()
logging.info("MQTT disconnected.")
# ── Main listen loop ──────────────────────────────────────────────────────────
def _handle_text(raw: str, source: str, publisher: MQTTPublisher,
no_tts: bool) -> bool:
"""
Parse @raw, publish command, speak confirmation.
Returns True if a command was recognised and published.
"""
cmd, param = parse_command(raw)
if cmd is None:
logging.info("No command matched for: %r", raw)
if not no_tts:
tts_speak("Sorry, I didn't understand that")
return False
payload = {
"ts": time.time(),
"cmd": cmd,
"param": param,
"raw": raw,
"source": source,
}
ok = publisher.publish(TOPIC_VOICE_CMD, payload)
log_msg = "Published %s (param=%r) — MQTT %s" % (cmd, param, "OK" if ok else "FAIL")
logging.info(log_msg)
if not no_tts:
response = _TTS_RESPONSES.get(cmd, cmd.replace("_", " "))
if param:
response = f"{response}: {param}"
tts_speak(response)
return ok
def run_stt_loop(publisher: MQTTPublisher, args: argparse.Namespace) -> None:
"""Continuous STT listen → parse → publish loop."""
logging.info("Voice command loop started (STT mode). Say a command.")
if not args.no_tts:
tts_speak("Voice commands ready")
consecutive_failures = 0
while True:
logging.info("Listening…")
text = stt_listen(timeout=args.timeout)
if text is None:
consecutive_failures += 1
logging.warning("STT failed (%d consecutive)", consecutive_failures)
if consecutive_failures >= 3:
logging.warning("STT unavailable — switch to --text mode if needed")
if not args.no_tts:
tts_speak("Speech recognition unavailable")
time.sleep(5.0)
consecutive_failures = 0
continue
consecutive_failures = 0
_handle_text(text, "stt", publisher, args.no_tts)
def run_text_loop(publisher: MQTTPublisher, args: argparse.Namespace) -> None:
"""Manual text input loop (--text / --no-stt mode)."""
logging.info("Text input mode. Type a command (Ctrl-C or 'quit' to exit).")
print("\nAvailable commands: go forward, go back, turn left, turn right, "
"stop, emergency stop, go to waypoint <name>, speed up, slow down, status")
print("Type 'quit' to exit.\n")
while True:
try:
raw = input("Command> ").strip()
except EOFError:
break
if not raw:
continue
if raw.lower() in ("quit", "exit", "q"):
break
_handle_text(raw, "text", publisher, args.no_tts)
# ── Entry point ───────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="SaltyBot Termux voice command interface (Issue #633)"
)
parser.add_argument("--broker", default="192.168.1.100",
help="MQTT broker IP/hostname (default: 192.168.1.100)")
parser.add_argument("--port", type=int, default=1883,
help="MQTT broker port (default: 1883)")
parser.add_argument("--qos", type=int, default=0, choices=[0, 1, 2],
help="MQTT QoS level (default: 0)")
parser.add_argument("--text", action="store_true",
help="Manual text fallback mode (no STT)")
parser.add_argument("--no-tts", action="store_true",
help="Disable TTS confirmation")
parser.add_argument("--timeout", type=float, default=10.0,
help="STT listen timeout in seconds (default: 10)")
parser.add_argument("--debug", action="store_true",
help="Verbose logging")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
if not MQTT_AVAILABLE:
logging.error("paho-mqtt not installed. Run: pip install paho-mqtt")
sys.exit(1)
publisher = MQTTPublisher(args.broker, args.port, qos=args.qos)
# Wait for initial MQTT connection
deadline = time.monotonic() + 10.0
while not publisher.connected and time.monotonic() < deadline:
time.sleep(0.2)
if not publisher.connected:
logging.warning("MQTT not connected — commands will be dropped until connected")
try:
if args.text:
run_text_loop(publisher, args)
else:
run_stt_loop(publisher, args)
except KeyboardInterrupt:
logging.info("Shutting down…")
finally:
publisher.shutdown()
logging.info("Done.")
if __name__ == "__main__":
main()

View File

@ -438,10 +438,8 @@ int main(void) {
(double)bal.kp, (double)bal.ki, (double)bal.kd);
}
/* UART protocol: handle commands from Jetson (Issue #629) */
{
extern UartProtState uart_prot_state;
if (uart_prot_state.vel_updated) {
uart_prot_state.vel_updated = 0u;
if (bal.state == BALANCE_ARMED && !lvc_is_cutoff()) {
@ -809,7 +807,6 @@ int main(void) {
jlink_send_lvc_tlm(&ltlm);
}
/* UART protocol: send STATUS to Jetson at 10 Hz (Issue #629) */
if (now - uart_status_tick >= 100u) {
uart_status_tick = now;

View File

@ -1,14 +1,14 @@
/*
* uart_protocol.c -- Structured UART command protocol (Issue #629)
* uart_protocol.c UART command protocol for Jetson-STM32 communication (Issue #629)
*
* UART5 (PC12=TX / PD2=RX) at 115200 baud.
* DMA1_Stream0_Channel4 runs in circular mode to fill a 256-byte ring buffer.
* uart_protocol_process() is called each main loop tick to drain new bytes
* through a lightweight state-machine parser.
* Physical: UART5, PC12 (TX, AF8) / PD2 (RX, AF8), 115200 baud, 8N1, no flow control.
* NOTE: Spec requested USART1 @ 115200, but USART1 is already used by JLink @ 921600.
* Implemented on UART5 (PC12/PD2) instead.
*
* Frame: [STX=0x02][LEN][CMD][PAYLOAD...][CRC8][ETX=0x03]
* LEN = 1 (CMD) + payload_len
* CRC8 = SMBUS (poly 0x07, init 0x00) computed over CMD+PAYLOAD bytes
* RX: DMA1_Stream0 (Channel 4), 256-byte circular buffer, no interrupt needed.
* TX: Polled (HAL_UART_Transmit), frames are short (<20 B) so blocking is acceptable.
*
* CRC: CRC8-SMBUS poly 0x07, init 0x00, computed over CMD+PAYLOAD bytes only.
*/
#include "uart_protocol.h"
@ -16,114 +16,117 @@
#include "stm32f7xx_hal.h"
#include <string.h>
/* ---- DMA ring buffer ---- */
static volatile uint8_t s_rx_buf[UPROT_RX_BUF_LEN];
static uint16_t s_rx_head = 0; /* last byte we processed */
/* ── Configuration ─────────────────────────────────────────────────────────── */
#define RX_BUF_SIZE 256u /* must be power-of-two for wrap math */
#define TX_TIMEOUT 5u /* HAL_UART_Transmit timeout ms */
/* ---- HAL handles ---- */
static UART_HandleTypeDef s_huart;
static DMA_HandleTypeDef s_hdma_rx;
/* ── Peripheral handles ───────────────────────────────────────────────────── */
static UART_HandleTypeDef huart5;
static DMA_HandleTypeDef hdma_rx;
/* ---- TX buffer (largest TX frame = URESP_STATUS: 13 bytes) ---- */
#define TX_BUF_LEN 16u
static uint8_t s_tx_buf[TX_BUF_LEN];
/* ── DMA ring buffer ──────────────────────────────────────────────────────── */
static uint8_t rx_buf[RX_BUF_SIZE];
static uint32_t rx_head = 0u; /* next byte to consume */
/* ---- Public state ---- */
volatile UartProtState uart_prot_state;
/* ── Shared state (read by main.c) ───────────────────────────────────────── */
UartProtState uart_prot_state;
/* ---- Parser state ---- */
/* ── Parser state machine ─────────────────────────────────────────────────── */
typedef enum {
PS_IDLE = 0,
PS_IDLE,
PS_LEN,
PS_CMD,
PS_PAYLOAD,
PS_CRC,
PS_ETX,
PS_ETX
} ParseState;
static ParseState s_ps = PS_IDLE;
static uint8_t s_frame_len = 0; /* LEN byte value */
static uint8_t s_frame_cmd = 0;
static uint8_t s_payload_buf[UPROT_MAX_PAYLOAD];
static uint8_t s_payload_idx = 0;
static uint8_t s_frame_crc = 0;
static ParseState ps = PS_IDLE;
static uint8_t ps_len = 0u; /* expected payload bytes */
static uint8_t ps_cmd = 0u; /* command byte */
static uint8_t ps_payload[12]; /* max payload = SET_PID = 12 B */
static uint8_t ps_pi = 0u; /* payload index */
static uint8_t ps_crc = 0u; /* received CRC byte */
/* ================================================================
* CRC8-SMBUS: poly=0x07, init=0x00
* ================================================================ */
static uint8_t crc8_smbus(const uint8_t *data, uint8_t len)
/* ── CRC8-SMBUS ───────────────────────────────────────────────────────────── */
static uint8_t crc8(const uint8_t *data, uint8_t len)
{
uint8_t crc = 0x00u;
for (uint8_t i = 0u; i < len; i++) {
crc ^= data[i];
for (uint8_t b = 0u; b < 8u; b++) {
crc = (crc & 0x80u) ? (uint8_t)((crc << 1u) ^ 0x07u)
: (uint8_t)(crc << 1u);
while (len--) {
crc ^= *data++;
for (uint8_t i = 0u; i < 8u; i++) {
if (crc & 0x80u)
crc = (uint8_t)((crc << 1) ^ 0x07u);
else
crc <<= 1;
}
}
return crc;
}
/* ================================================================
* TX helpers
* ================================================================ */
static void tx_send(const uint8_t *buf, uint8_t len)
/* ── TX helper ────────────────────────────────────────────────────────────── */
static void tx_frame(uint8_t cmd, const uint8_t *payload, uint8_t plen)
{
/* Blocking transmit — frames are short (<=13 bytes @ 115200 = <1.2 ms) */
HAL_UART_Transmit(&s_huart, (uint8_t *)buf, len, 5u);
uint8_t frame[20];
uint8_t fi = 0u;
frame[fi++] = UPROT_STX;
frame[fi++] = plen;
frame[fi++] = cmd;
for (uint8_t i = 0u; i < plen; i++)
frame[fi++] = payload[i];
/* CRC over CMD + PAYLOAD */
frame[fi++] = crc8(&frame[2], (uint8_t)(1u + plen));
frame[fi++] = UPROT_ETX;
HAL_UART_Transmit(&huart5, frame, fi, TX_TIMEOUT);
}
static void send_ack(uint8_t cmd)
{
/* [STX][LEN=2][URESP_ACK][cmd][CRC8][ETX] = 6 bytes */
uint8_t payload[2] = { URESP_ACK, cmd };
uint8_t crc = crc8_smbus(payload, 2u);
uint8_t frame[6] = { UPROT_STX, 0x02u, URESP_ACK, cmd, crc, UPROT_ETX };
tx_send(frame, 6u);
tx_frame(URESP_ACK, &cmd, 1u);
}
static void send_nack(uint8_t cmd, uint8_t err)
{
/* [STX][LEN=3][URESP_NACK][cmd][err][CRC8][ETX] = 7 bytes */
uint8_t payload[3] = { URESP_NACK, cmd, err };
uint8_t crc = crc8_smbus(payload, 3u);
uint8_t frame[7] = { UPROT_STX, 0x03u, URESP_NACK, cmd, err, crc, UPROT_ETX };
tx_send(frame, 7u);
uint8_t p[2] = { cmd, err };
tx_frame(URESP_NACK, p, 2u);
}
/* ================================================================
* Command dispatcher (called when a valid frame is fully received)
* ================================================================ */
/* ── Command dispatcher ───────────────────────────────────────────────────── */
static void dispatch(uint8_t cmd, const uint8_t *payload, uint8_t plen)
{
switch (cmd) {
case UCMD_SET_VELOCITY:
if (plen != 4u) { send_nack(cmd, UERR_BAD_LENGTH); return; }
{
int16_t lr, rr;
memcpy(&lr, &payload[0], 2u);
memcpy(&rr, &payload[2], 2u);
uart_prot_state.left_rpm = lr;
uart_prot_state.right_rpm = rr;
uart_prot_state.vel_updated = 1u;
/* Validate CRC (computed over cmd + payload) */
uint8_t buf[13];
buf[0] = cmd;
memcpy(&buf[1], payload, plen);
if (crc8(buf, (uint8_t)(1u + plen)) != ps_crc) {
send_nack(cmd, UERR_BAD_CRC);
return;
}
uart_prot_state.last_rx_ms = HAL_GetTick();
switch (cmd) {
case UCMD_SET_VELOCITY:
if (plen != 4u) { send_nack(cmd, UERR_BAD_LEN); break; }
{
int16_t lrpm, rrpm;
memcpy(&lrpm, &payload[0], 2u);
memcpy(&rrpm, &payload[2], 2u);
uart_prot_state.left_rpm = lrpm;
uart_prot_state.right_rpm = rrpm;
uart_prot_state.vel_updated = 1u;
send_ack(cmd);
}
break;
case UCMD_GET_STATUS:
if (plen != 0u) { send_nack(cmd, UERR_BAD_LENGTH); return; }
/* ACK not sent; STATUS response IS the reply */
uart_prot_state.vel_updated = uart_prot_state.vel_updated; /* no-op flag; main sends status */
/* Signal main loop to send status — reuse vel_updated channel not appropriate;
* Instead send ACK here and let main loop send STATUS immediately after.
* To keep ISR-safe, just set a flag and let main loop build the payload. */
/* Simple approach: ack first, status built by main.c using uart_protocol_send_status() */
send_ack(cmd); /* main.c checks for vel_updated; GET_STATUS is handled by main */
if (plen != 0u) { send_nack(cmd, UERR_BAD_LEN); break; }
/* ACK immediately; main.c sends URESP_STATUS at next 10 Hz tick */
send_ack(cmd);
break;
case UCMD_SET_PID:
if (plen != 12u) { send_nack(cmd, UERR_BAD_LENGTH); return; }
if (plen != 12u) { send_nack(cmd, UERR_BAD_LEN); break; }
{
float kp, ki, kd;
memcpy(&kp, &payload[0], 4u);
@ -133,201 +136,135 @@ static void dispatch(uint8_t cmd, const uint8_t *payload, uint8_t plen)
uart_prot_state.pid_ki = ki;
uart_prot_state.pid_kd = kd;
uart_prot_state.pid_updated = 1u;
}
send_ack(cmd);
}
break;
case UCMD_ESTOP:
if (plen != 0u) { send_nack(cmd, UERR_BAD_LENGTH); return; }
if (plen != 0u) { send_nack(cmd, UERR_BAD_LEN); break; }
uart_prot_state.estop_req = 1u;
send_ack(cmd);
break;
case UCMD_CLEAR_ESTOP:
if (plen != 0u) { send_nack(cmd, UERR_BAD_LENGTH); return; }
if (plen != 0u) { send_nack(cmd, UERR_BAD_LEN); break; }
uart_prot_state.estop_clear_req = 1u;
send_ack(cmd);
break;
default:
send_nack(cmd, UERR_UNKNOWN_CMD);
send_nack(cmd, UERR_BAD_LEN);
break;
}
}
/* ================================================================
* uart_protocol_init()
* ================================================================ */
void uart_protocol_init(void)
/* ── Parser byte handler ──────────────────────────────────────────────────── */
static void parse_byte(uint8_t b)
{
/* ---- GPIO: PC12 = TX, PD2 = RX ---- */
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_GPIOD_CLK_ENABLE();
GPIO_InitTypeDef gpio = {0};
gpio.Mode = GPIO_MODE_AF_PP;
gpio.Pull = GPIO_PULLUP;
gpio.Speed = GPIO_SPEED_FREQ_HIGH;
gpio.Alternate = GPIO_AF8_UART5;
gpio.Pin = GPIO_PIN_12;
HAL_GPIO_Init(GPIOC, &gpio);
gpio.Pin = GPIO_PIN_2;
HAL_GPIO_Init(GPIOD, &gpio);
/* ---- DMA1_Stream0 Channel4 → UART5_RX (circular) ---- */
__HAL_RCC_DMA1_CLK_ENABLE();
s_hdma_rx.Instance = DMA1_Stream0;
s_hdma_rx.Init.Channel = DMA_CHANNEL_4;
s_hdma_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
s_hdma_rx.Init.PeriphInc = DMA_PINC_DISABLE;
s_hdma_rx.Init.MemInc = DMA_MINC_ENABLE;
s_hdma_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
s_hdma_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
s_hdma_rx.Init.Mode = DMA_CIRCULAR;
s_hdma_rx.Init.Priority = DMA_PRIORITY_LOW;
s_hdma_rx.Init.FIFOMode = DMA_FIFOMODE_DISABLE;
if (HAL_DMA_Init(&s_hdma_rx) != HAL_OK) { return; }
/* ---- UART5 at UART_PROT_BAUD ---- */
__HAL_RCC_UART5_CLK_ENABLE();
s_huart.Instance = UART5;
s_huart.Init.BaudRate = UART_PROT_BAUD;
s_huart.Init.WordLength = UART_WORDLENGTH_8B;
s_huart.Init.StopBits = UART_STOPBITS_1;
s_huart.Init.Parity = UART_PARITY_NONE;
s_huart.Init.Mode = UART_MODE_TX_RX;
s_huart.Init.HwFlowCtl = UART_HWCONTROL_NONE;
s_huart.Init.OverSampling = UART_OVERSAMPLING_16;
__HAL_LINKDMA(&s_huart, hdmarx, s_hdma_rx);
if (HAL_UART_Init(&s_huart) != HAL_OK) { return; }
/* ---- Start circular DMA reception ---- */
HAL_UART_Receive_DMA(&s_huart, (uint8_t *)s_rx_buf, UPROT_RX_BUF_LEN);
memset((void *)&uart_prot_state, 0, sizeof(uart_prot_state));
s_rx_head = 0u;
s_ps = PS_IDLE;
}
/* ================================================================
* uart_protocol_process() -- parse DMA ring buffer, one byte at a time
* ================================================================ */
void uart_protocol_process(void)
{
/* DMA write position: bytes not yet consumed by the parser */
uint16_t dma_tail = (uint16_t)(UPROT_RX_BUF_LEN -
__HAL_DMA_GET_COUNTER(&s_hdma_rx));
while (s_rx_head != dma_tail) {
uint8_t b = s_rx_buf[s_rx_head];
s_rx_head = (s_rx_head + 1u) & (UPROT_RX_BUF_LEN - 1u);
switch (s_ps) {
switch (ps) {
case PS_IDLE:
if (b == UPROT_STX) { s_ps = PS_LEN; }
if (b == UPROT_STX) ps = PS_LEN;
break;
case PS_LEN:
/* LEN must be 1..UPROT_MAX_PAYLOAD+1 */
if (b == 0u || b > (UPROT_MAX_PAYLOAD + 1u)) {
s_ps = PS_IDLE; /* invalid length */
} else {
s_frame_len = b;
s_payload_idx = 0u;
s_ps = PS_CMD;
}
if (b > 12u) { ps = PS_IDLE; break; } /* sanity: max payload 12 B */
ps_len = b;
ps = PS_CMD;
break;
case PS_CMD:
s_frame_cmd = b;
if (s_frame_len == 1u) {
/* No payload bytes: go straight to CRC */
s_ps = PS_CRC;
} else {
s_ps = PS_PAYLOAD;
}
ps_cmd = b;
ps_pi = 0u;
ps = (ps_len == 0u) ? PS_CRC : PS_PAYLOAD;
break;
case PS_PAYLOAD:
if (s_payload_idx < UPROT_MAX_PAYLOAD) {
s_payload_buf[s_payload_idx] = b;
}
s_payload_idx++;
/* LEN-1 payload bytes collected (LEN includes CMD byte) */
if (s_payload_idx >= (uint8_t)(s_frame_len - 1u)) {
s_ps = PS_CRC;
}
ps_payload[ps_pi++] = b;
if (ps_pi >= ps_len) ps = PS_CRC;
break;
case PS_CRC:
s_frame_crc = b;
s_ps = PS_ETX;
ps_crc = b;
ps = PS_ETX;
break;
case PS_ETX:
if (b == UPROT_ETX) {
/* Validate CRC over CMD+PAYLOAD */
uint8_t crc_data[UPROT_MAX_PAYLOAD + 1u];
uint8_t crc_len = (uint8_t)(s_frame_len); /* CMD + payload bytes */
crc_data[0] = s_frame_cmd;
uint8_t plen = (uint8_t)(s_frame_len - 1u);
if (plen > 0u) {
memcpy(&crc_data[1], s_payload_buf, plen);
}
uint8_t calc = crc8_smbus(crc_data, crc_len);
if (calc == s_frame_crc) {
/* Valid frame: update heartbeat and dispatch */
uart_prot_state.last_rx_ms = HAL_GetTick();
dispatch(s_frame_cmd, s_payload_buf, plen);
} else {
send_nack(s_frame_cmd, UERR_BAD_CRC);
}
}
/* Always return to IDLE after ETX (or wrong ETX byte) */
s_ps = PS_IDLE;
if (b == UPROT_ETX)
dispatch(ps_cmd, ps_payload, ps_len);
else
send_nack(ps_cmd, UERR_BAD_ETX);
ps = PS_IDLE;
break;
default:
s_ps = PS_IDLE;
ps = PS_IDLE;
break;
}
}
}
/* ================================================================
* uart_protocol_is_active()
* ================================================================ */
bool uart_protocol_is_active(uint32_t now_ms)
/* ── Public API ───────────────────────────────────────────────────────────── */
void uart_protocol_init(void)
{
if (uart_prot_state.last_rx_ms == 0u) { return false; }
return (now_ms - uart_prot_state.last_rx_ms) < UART_PROT_HB_TIMEOUT_MS;
memset(&uart_prot_state, 0, sizeof(uart_prot_state));
ps = PS_IDLE;
/* GPIO: PC12 (TX, AF8) and PD2 (RX, AF8) */
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_GPIOD_CLK_ENABLE();
GPIO_InitTypeDef gpio = {0};
gpio.Mode = GPIO_MODE_AF_PP;
gpio.Pull = GPIO_NOPULL;
gpio.Speed = GPIO_SPEED_FREQ_HIGH;
gpio.Alternate = GPIO_AF8_UART5;
gpio.Pin = GPIO_PIN_12;
HAL_GPIO_Init(GPIOC, &gpio);
gpio.Pin = GPIO_PIN_2;
HAL_GPIO_Init(GPIOD, &gpio);
/* UART5 */
__HAL_RCC_UART5_CLK_ENABLE();
huart5.Instance = UART5;
huart5.Init.BaudRate = UART_PROT_BAUD;
huart5.Init.WordLength = UART_WORDLENGTH_8B;
huart5.Init.StopBits = UART_STOPBITS_1;
huart5.Init.Parity = UART_PARITY_NONE;
huart5.Init.Mode = UART_MODE_TX_RX;
huart5.Init.HwFlowCtl = UART_HWCONTROL_NONE;
huart5.Init.OverSampling = UART_OVERSAMPLING_16;
if (HAL_UART_Init(&huart5) != HAL_OK) return;
/* DMA1_Stream0, Channel 4 — UART5_RX */
__HAL_RCC_DMA1_CLK_ENABLE();
hdma_rx.Instance = DMA1_Stream0;
hdma_rx.Init.Channel = DMA_CHANNEL_4;
hdma_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_rx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_rx.Init.MemInc = DMA_MINC_ENABLE;
hdma_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_rx.Init.Mode = DMA_CIRCULAR;
hdma_rx.Init.Priority = DMA_PRIORITY_LOW;
hdma_rx.Init.FIFOMode = DMA_FIFOMODE_DISABLE;
HAL_DMA_Init(&hdma_rx);
__HAL_LINKDMA(&huart5, hdmarx, hdma_rx);
/* Start circular DMA receive */
HAL_UART_Receive_DMA(&huart5, rx_buf, RX_BUF_SIZE);
}
void uart_protocol_process(void)
{
/* DMA writes forward; NDTR counts down from RX_BUF_SIZE */
uint32_t ndtr = __HAL_DMA_GET_COUNTER(&hdma_rx);
uint32_t tail = (RX_BUF_SIZE - ndtr) & (RX_BUF_SIZE - 1u);
while (rx_head != tail) {
parse_byte(rx_buf[rx_head]);
rx_head = (rx_head + 1u) & (RX_BUF_SIZE - 1u);
}
}
/* ================================================================
* uart_protocol_send_status()
* ================================================================ */
void uart_protocol_send_status(const uart_prot_status_t *s)
{
/*
* Frame: [STX][LEN=9][0x82][8 bytes status][CRC8][ETX] = 13 bytes
*/
const uint8_t plen = (uint8_t)sizeof(uart_prot_status_t); /* 8 */
const uint8_t len = 1u + plen; /* 9 */
uint8_t frame[13];
frame[0] = UPROT_STX;
frame[1] = len;
frame[2] = URESP_STATUS;
memcpy(&frame[3], s, plen);
/* CRC over CMD + payload */
uint8_t crc_buf[9];
crc_buf[0] = URESP_STATUS;
memcpy(&crc_buf[1], s, plen);
frame[3 + plen] = crc8_smbus(crc_buf, len);
frame[3 + plen + 1] = UPROT_ETX;
tx_send(frame, (uint8_t)(3u + plen + 2u)); /* 13 bytes total */
tx_frame(URESP_STATUS, (const uint8_t *)s, (uint8_t)sizeof(*s));
}

429
ui/dashboard.css Normal file
View File

@ -0,0 +1,429 @@
/* dashboard.css — Saltybot Main Dashboard (Issue #630) */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg0: #050510;
--bg1: #070712;
--bg2: #0a0a1a;
--bd: #0c2a3a;
--bd2: #1e3a5f;
--dim: #374151;
--mid: #6b7280;
--base: #9ca3af;
--hi: #d1d5db;
--cyan: #06b6d4;
--green: #22c55e;
--amber: #f59e0b;
--red: #ef4444;
}
html, body {
height: 100%;
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
background: var(--bg0);
color: var(--base);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ══════════════════ TOP BAR ══════════════════ */
#topbar {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
background: var(--bg1);
border-bottom: 1px solid var(--bd);
flex-shrink: 0;
flex-wrap: wrap;
}
#topbar-left {
display: flex;
align-items: baseline;
gap: 8px;
flex-shrink: 0;
}
.logo {
color: #f97316;
font-weight: bold;
letter-spacing: .18em;
font-size: 14px;
}
#robot-name {
color: var(--mid);
font-size: 10px;
letter-spacing: .12em;
}
#topbar-status {
display: flex;
align-items: center;
gap: 0;
flex: 1;
min-width: 0;
flex-wrap: wrap;
gap: 2px;
}
.stat-block {
display: flex;
align-items: center;
gap: 5px;
padding: 2px 10px;
}
.stat-icon { font-size: 14px; flex-shrink: 0; }
.stat-body { display: flex; flex-direction: column; gap: 1px; }
.stat-label {
font-size: 8px;
color: var(--dim);
letter-spacing: .1em;
text-transform: uppercase;
}
.stat-val {
font-size: 11px;
color: var(--hi);
font-family: monospace;
white-space: nowrap;
}
.stat-sep {
width: 1px;
height: 28px;
background: var(--bd);
flex-shrink: 0;
}
/* Battery bar */
.batt-bar {
width: 28px;
height: 12px;
border: 1px solid var(--bd2);
border-radius: 2px;
overflow: hidden;
position: relative;
background: var(--bg0);
}
.batt-bar::after {
content: '';
position: absolute;
right: -3px; top: 3px;
width: 3px; height: 6px;
background: var(--bd2);
border-radius: 0 1px 1px 0;
}
#batt-fill {
height: 100%;
width: 0%;
background: var(--green);
transition: width .5s, background .5s;
}
#topbar-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: auto;
}
#conn-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--dim); flex-shrink: 0;
transition: background .3s;
}
#conn-dot.connected { background: var(--green); }
#conn-dot.error { background: var(--red); animation: blink 1s infinite; }
#conn-label {
font-size: 10px;
color: var(--mid);
white-space: nowrap;
}
#ws-input {
background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px;
color: #67e8f9; padding: 2px 7px; font-family: monospace; font-size: 11px;
width: 175px;
}
#ws-input:focus { outline: none; border-color: var(--cyan); }
.hbtn {
padding: 2px 8px; border-radius: 4px; border: 1px solid var(--bd2);
background: var(--bg2); color: #67e8f9; font-family: monospace;
font-size: 10px; font-weight: bold; cursor: pointer; transition: background .15s;
white-space: nowrap;
}
.hbtn:hover { background: #0e4f69; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
/* ══════════════════ MAIN / GRID ══════════════════ */
#main {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 16px;
gap: 12px;
min-height: 0;
}
/* Auto-detect bar */
#detect-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
background: var(--bg2);
border: 1px solid var(--bd2);
border-radius: 6px;
font-size: 10px;
color: var(--mid);
flex-shrink: 0;
}
#detect-bar.connected {
border-color: #14532d;
color: var(--green);
background: #020f06;
}
#detect-bar.error {
border-color: #7f1d1d;
color: var(--red);
}
#detect-status { flex: 1; }
#detect-dots { display: flex; gap: 4px; }
.detect-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--dim);
transition: background .3s;
}
.detect-dot.trying { background: var(--amber); animation: pulse .7s infinite; }
.detect-dot.ok { background: var(--green); }
.detect-dot.fail { background: var(--red); }
/* Panel grid */
#grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
/* Panel card */
.panel-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
background: var(--bg2);
border: 1px solid var(--bd2);
border-radius: 10px;
text-decoration: none;
color: inherit;
cursor: pointer;
transition: border-color .2s, background .2s, transform .1s;
position: relative;
overflow: hidden;
}
.panel-card::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at top left, rgba(6,182,212,.04), transparent 60%);
pointer-events: none;
}
.panel-card:hover {
border-color: var(--cyan);
background: #0a0a1e;
transform: translateY(-1px);
}
.panel-card:active { transform: translateY(0); }
.card-header {
display: flex;
align-items: center;
gap: 10px;
}
.card-icon { font-size: 24px; flex-shrink: 0; }
.card-title {
font-size: 12px;
font-weight: bold;
letter-spacing: .12em;
color: var(--hi);
text-transform: uppercase;
}
.card-sub { font-size: 9px; color: var(--dim); }
.card-dot {
width: 9px; height: 9px; border-radius: 50%;
background: var(--dim);
margin-left: auto;
flex-shrink: 0;
transition: background .3s;
}
.card-dot.live { background: var(--green); }
.card-dot.idle { background: var(--amber); }
.card-dot.config { background: var(--mid); }
.card-desc {
font-size: 10px;
color: var(--mid);
line-height: 1.5;
}
.card-topics {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.card-topics code {
font-size: 8px;
color: #4b5563;
background: var(--bg0);
border: 1px solid var(--bd);
border-radius: 2px;
padding: 1px 4px;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--bd);
padding-top: 6px;
margin-top: auto;
}
.card-status {
font-size: 9px;
font-weight: bold;
letter-spacing: .1em;
color: var(--dim);
}
.card-status.live { color: var(--green); }
.card-status.idle { color: var(--amber); }
.card-msg { font-size: 9px; color: var(--dim); font-family: monospace; }
/* Estop active state on card */
.panel-card.estop-flash {
border-color: var(--red) !important;
animation: blink .6s infinite;
}
/* Info strip */
#info-strip {
display: flex;
align-items: center;
gap: 0;
padding: 6px 12px;
background: var(--bg2);
border: 1px solid var(--bd);
border-radius: 6px;
flex-shrink: 0;
flex-wrap: wrap;
}
.info-item {
display: flex;
align-items: center;
gap: 6px;
padding: 0 14px;
border-right: 1px solid var(--bd);
}
.info-item:first-child { padding-left: 0; }
.info-item:last-child { border-right: none; }
.info-lbl { font-size: 8px; color: var(--dim); letter-spacing: .1em; }
.info-val { font-size: 10px; color: var(--hi); font-family: monospace; }
/* ══════════════════ BOTTOM BAR ══════════════════ */
#bottombar {
display: flex;
align-items: center;
padding: 6px 14px;
background: var(--bg1);
border-top: 1px solid var(--bd);
flex-shrink: 0;
gap: 12px;
flex-wrap: wrap;
}
#bottombar-left { display: flex; gap: 8px; align-items: center; }
.estop-btn {
padding: 5px 18px;
border-radius: 5px;
border: 2px solid #7f1d1d;
background: #1c0505;
color: #fca5a5;
font-family: monospace;
font-size: 11px;
font-weight: bold;
cursor: pointer;
letter-spacing: .08em;
transition: background .15s, border-color .15s;
}
.estop-btn:hover { background: #3b0606; border-color: var(--red); }
.estop-btn.active {
background: #7f1d1d;
border-color: var(--red);
color: #fff;
animation: blink .7s infinite;
}
.resume-btn {
padding: 5px 14px;
border-radius: 5px;
border: 1px solid #14532d;
background: #052010;
color: #86efac;
font-family: monospace;
font-size: 11px;
font-weight: bold;
cursor: pointer;
transition: background .15s;
}
.resume-btn:hover { background: #0a3a1a; }
#bottombar-center {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.bb-lbl { font-size: 9px; color: var(--dim); letter-spacing: .1em; flex-shrink: 0; }
#mode-btns { display: flex; gap: 4px; }
.mode-btn {
padding: 3px 10px;
border-radius: 4px;
border: 1px solid var(--bd2);
background: var(--bg2);
color: var(--mid);
font-family: monospace;
font-size: 10px;
font-weight: bold;
cursor: pointer;
letter-spacing: .08em;
transition: background .15s, color .15s, border-color .15s;
}
.mode-btn:hover { background: #0e4f69; color: var(--hi); }
.mode-btn.active { background: #0e4f69; border-color: var(--cyan); color: #fff; }
#bottombar-right {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
#uptime-display { display: flex; align-items: center; gap: 6px; }
#session-time { font-size: 11px; color: var(--mid); font-family: monospace; }
/* ══════════════════ RESPONSIVE ══════════════════ */
@media (max-width: 700px) {
#topbar-status { display: none; }
#grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
#grid { grid-template-columns: 1fr; }
#mode-btns .mode-btn:nth-child(n+3) { display: none; }
}

480
ui/dashboard.js Normal file
View File

@ -0,0 +1,480 @@
/* dashboard.js — Saltybot Main Dashboard (Issue #630) */
'use strict';
// ── Auto-detect candidates ─────────────────────────────────────────────────
// Try current page's hostname first (if served from robot), then localhost
const AUTO_CANDIDATES = (() => {
const candidates = [];
if (location.hostname && location.hostname !== '' && location.hostname !== 'localhost') {
candidates.push(`ws://${location.hostname}:9090`);
}
candidates.push('ws://localhost:9090');
candidates.push('ws://saltybot.local:9090');
return candidates;
})();
// ── Panel definitions (status tracking) ───────────────────────────────────
const PANELS = [
{ id: 'map', watchTopic: '/saltybot/pose/fused', msgType: 'geometry_msgs/PoseStamped' },
{ id: 'gamepad', watchTopic: null, msgType: null }, // output only
{ id: 'diag', watchTopic: '/diagnostics', msgType: 'diagnostic_msgs/DiagnosticArray' },
{ id: 'events', watchTopic: '/rosout', msgType: 'rcl_interfaces/msg/Log' },
{ id: 'settings', watchTopic: null, msgType: null }, // service-based
{ id: 'gimbal', watchTopic: '/gimbal/state', msgType: 'geometry_msgs/Vector3' },
];
// ── State ──────────────────────────────────────────────────────────────────
const state = {
connected: false,
estop: false,
driveMode: 'MANUAL',
battery: null, // 0..100 %
voltage: null,
safetyState: null, // 'CLEAR' | 'WARN' | 'DANGER' | 'ESTOP'
closestM: null,
uptime: 0, // seconds since ROS connection
sessionStart: null, // Date for session timer
msgCount: 0,
lastMsgTs: 0,
msgRate: 0,
latency: null,
// Per-panel last-message timestamps
panelTs: {},
};
// ── ROS ────────────────────────────────────────────────────────────────────
let ros = null;
let cmdVelTopic = null;
let modeTopic = null;
let uptimeInterval = null;
let panelWatches = {}; // id → ROSLIB.Topic
let pingTs = 0;
let pingTimeout = null;
function connect(url) {
if (ros) {
Object.values(panelWatches).forEach(t => { try { t.unsubscribe(); } catch(_){} });
panelWatches = {};
try { ros.close(); } catch(_){}
}
ros = new ROSLIB.Ros({ url });
ros.on('connection', () => {
state.connected = true;
state.sessionStart = state.sessionStart || new Date();
localStorage.setItem('dashboard_ws_url', url);
$connDot.className = 'connected';
$connLabel.style.color = '#22c55e';
$connLabel.textContent = 'Connected';
$wsInput.value = url;
document.getElementById('info-ws').textContent = url;
setupTopics();
schedulePing();
updateDetectBar('connected', `Connected to ${url}`);
});
ros.on('error', () => {
state.connected = false;
$connDot.className = 'error';
$connLabel.style.color = '#ef4444';
$connLabel.textContent = 'Error';
updateDetectBar('error', `Error connecting to ${url}`);
});
ros.on('close', () => {
state.connected = false;
$connDot.className = '';
$connLabel.style.color = '#6b7280';
$connLabel.textContent = 'Disconnected';
});
}
function setupTopics() {
// ── Battery & temps from /diagnostics ──
const diagTopic = new ROSLIB.Topic({
ros, name: '/diagnostics',
messageType: 'diagnostic_msgs/DiagnosticArray',
throttle_rate: 2000,
});
diagTopic.subscribe(msg => {
state.msgCount++;
state.lastMsgTs = performance.now();
(msg.status || []).forEach(s => {
(s.values || []).forEach(kv => {
if (kv.key === 'battery_voltage_v') {
state.voltage = parseFloat(kv.value);
// 4S LiPo: 12.0V empty, 16.8V full
state.battery = Math.max(0, Math.min(100,
((state.voltage - 12.0) / (16.8 - 12.0)) * 100));
}
if (kv.key === 'battery_soc_pct') {
state.battery = parseFloat(kv.value);
}
});
});
markPanelLive('diag');
renderBattery();
});
// ── Safety zone ──
const safetyTopic = new ROSLIB.Topic({
ros, name: '/saltybot/safety_zone/status',
messageType: 'std_msgs/String',
throttle_rate: 500,
});
safetyTopic.subscribe(msg => {
state.msgCount++;
try {
const d = JSON.parse(msg.data);
state.safetyState = d.state || d.fwd_zone || 'CLEAR';
state.closestM = d.closest_m != null ? d.closest_m : null;
if (d.estop !== undefined) {
// only auto-latch E-stop if ROS says it's active and we aren't tracking it locally
if (d.estop && !state.estop) activateEstop(false);
}
} catch(_) {
state.safetyState = msg.data;
}
renderSafety();
});
// ── Balance state / drive mode ──
const modeSub = new ROSLIB.Topic({
ros, name: '/saltybot/balance_state',
messageType: 'std_msgs/String',
throttle_rate: 1000,
});
modeSub.subscribe(msg => {
try {
const d = JSON.parse(msg.data);
if (d.mode) {
state.driveMode = d.mode.toUpperCase();
renderMode();
}
} catch(_) {}
});
// ── Pose (for map card liveness) ──
const poseTopic = new ROSLIB.Topic({
ros, name: '/saltybot/pose/fused',
messageType: 'geometry_msgs/PoseStamped',
throttle_rate: 1000,
});
poseTopic.subscribe(() => { markPanelLive('map'); });
// ── /rosout (for events card) ──
const rosoutTopic = new ROSLIB.Topic({
ros, name: '/rosout',
messageType: 'rcl_interfaces/msg/Log',
throttle_rate: 2000,
});
rosoutTopic.subscribe(() => { markPanelLive('events'); });
// ── Gimbal state ──
const gimbalTopic = new ROSLIB.Topic({
ros, name: '/gimbal/state',
messageType: 'geometry_msgs/Vector3',
throttle_rate: 1000,
});
gimbalTopic.subscribe(() => { markPanelLive('gimbal'); });
// ── cmd_vel monitor (for gamepad card liveness) ──
const cmdVelWatch = new ROSLIB.Topic({
ros, name: '/cmd_vel',
messageType: 'geometry_msgs/Twist',
throttle_rate: 500,
});
cmdVelWatch.subscribe(() => { markPanelLive('gamepad'); });
// ── Publisher topics ──
cmdVelTopic = new ROSLIB.Topic({
ros, name: '/cmd_vel',
messageType: 'geometry_msgs/Twist',
});
modeTopic = new ROSLIB.Topic({
ros, name: '/saltybot/drive_mode',
messageType: 'std_msgs/String',
});
// ── Robot uptime from /diagnostics or fallback to /rosapi ──
const upSvc = new ROSLIB.Service({
ros, name: '/rosapi/get_time',
serviceType: 'rosapi/GetTime',
});
upSvc.callService({}, () => {
state.uptime = 0;
if (uptimeInterval) clearInterval(uptimeInterval);
uptimeInterval = setInterval(() => { state.uptime++; renderUptime(); }, 1000);
});
}
// ── E-stop ─────────────────────────────────────────────────────────────────
function activateEstop(sendCmd = true) {
state.estop = true;
if (sendCmd && cmdVelTopic) {
cmdVelTopic.publish(new ROSLIB.Message({
linear: { x: 0, y: 0, z: 0 }, angular: { x: 0, y: 0, z: 0 },
}));
}
document.getElementById('btn-estop').style.display = 'none';
document.getElementById('btn-resume').style.display = '';
document.getElementById('btn-estop').classList.add('active');
document.getElementById('val-estop').textContent = 'ACTIVE';
document.getElementById('val-estop').style.color = '#ef4444';
// Flash all cards
document.querySelectorAll('.panel-card').forEach(c => c.classList.add('estop-flash'));
}
function resumeFromEstop() {
state.estop = false;
document.getElementById('btn-estop').style.display = '';
document.getElementById('btn-resume').style.display = 'none';
document.getElementById('btn-estop').classList.remove('active');
document.getElementById('val-estop').textContent = 'OFF';
document.getElementById('val-estop').style.color = '#6b7280';
document.querySelectorAll('.panel-card').forEach(c => c.classList.remove('estop-flash'));
}
document.getElementById('btn-estop').addEventListener('click', () => activateEstop(true));
document.getElementById('btn-resume').addEventListener('click', resumeFromEstop);
// Space bar = emergency E-stop from dashboard
document.addEventListener('keydown', e => {
if (e.code === 'Space' && e.target.tagName !== 'INPUT') {
e.preventDefault();
if (state.estop) resumeFromEstop(); else activateEstop(true);
}
});
// ── Drive mode buttons ─────────────────────────────────────────────────────
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
state.driveMode = btn.dataset.mode;
if (modeTopic) {
modeTopic.publish(new ROSLIB.Message({ data: state.driveMode }));
}
renderMode();
});
});
// ── Panel liveness tracking ────────────────────────────────────────────────
const LIVE_TIMEOUT_MS = 5000;
function markPanelLive(id) {
state.panelTs[id] = Date.now();
renderPanelCard(id);
}
function renderPanelCard(id) {
const ts = state.panelTs[id];
const dot = document.getElementById(`dot-${id}`);
const statusEl = document.getElementById(`status-${id}`);
const msgEl = document.getElementById(`msg-${id}`);
if (!dot || !statusEl) return;
if (!state.connected) {
dot.className = 'card-dot';
statusEl.className = 'card-status';
statusEl.textContent = 'OFFLINE';
if (msgEl) msgEl.textContent = 'Not connected';
return;
}
// Settings card is always config
if (id === 'settings') return;
// Gamepad is output only — show "READY" when connected
if (id === 'gamepad') {
const age = ts ? Date.now() - ts : Infinity;
if (age < LIVE_TIMEOUT_MS) {
dot.className = 'card-dot live';
statusEl.className = 'card-status live';
statusEl.textContent = 'ACTIVE';
if (msgEl) msgEl.textContent = 'Receiving /cmd_vel';
} else {
dot.className = 'card-dot';
statusEl.className = 'card-status';
statusEl.textContent = 'READY';
if (msgEl) msgEl.textContent = 'Awaiting input';
}
return;
}
if (!ts) {
dot.className = 'card-dot idle';
statusEl.className = 'card-status idle';
statusEl.textContent = 'IDLE';
if (msgEl) msgEl.textContent = 'No messages yet';
return;
}
const age = Date.now() - ts;
if (age < LIVE_TIMEOUT_MS) {
dot.className = 'card-dot live';
statusEl.className = 'card-status live';
statusEl.textContent = 'LIVE';
if (msgEl) msgEl.textContent = `${(age / 1000).toFixed(1)}s ago`;
} else {
dot.className = 'card-dot idle';
statusEl.className = 'card-status idle';
statusEl.textContent = 'IDLE';
if (msgEl) msgEl.textContent = `${Math.floor(age / 1000)}s ago`;
}
}
// ── Render helpers ─────────────────────────────────────────────────────────
function renderBattery() {
const el = document.getElementById('val-battery');
const fill = document.getElementById('batt-fill');
if (state.battery === null) { el.textContent = '—'; return; }
const pct = state.battery.toFixed(0);
el.textContent = `${pct}%${state.voltage ? ' · ' + state.voltage.toFixed(1) + 'V' : ''}`;
fill.style.width = pct + '%';
fill.style.background = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444';
el.style.color = pct > 50 ? '#d1d5db' : pct > 20 ? '#f59e0b' : '#ef4444';
}
function renderSafety() {
const el = document.getElementById('val-safety');
if (!state.safetyState) { el.textContent = '—'; return; }
const s = state.safetyState.toUpperCase();
el.textContent = s + (state.closestM != null ? ` · ${state.closestM.toFixed(2)}m` : '');
el.style.color = s === 'DANGER' || s === 'ESTOP' ? '#ef4444' :
s === 'WARN' ? '#f59e0b' : '#22c55e';
}
function renderMode() {
document.getElementById('val-mode').textContent = state.driveMode;
document.querySelectorAll('.mode-btn').forEach(b => {
b.classList.toggle('active', b.dataset.mode === state.driveMode);
});
}
function renderUptime() {
const el = document.getElementById('val-uptime');
if (!state.connected) { el.textContent = '—'; return; }
const s = state.uptime;
el.textContent = s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m ${s%60}s` : `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`;
}
function renderSession() {
if (!state.sessionStart) return;
const secs = Math.floor((Date.now() - state.sessionStart) / 1000);
const h = String(Math.floor(secs / 3600)).padStart(2, '0');
const m = String(Math.floor((secs % 3600) / 60)).padStart(2, '0');
const s = String(secs % 60).padStart(2, '0');
document.getElementById('session-time').textContent = `${h}:${m}:${s}`;
}
function renderMsgRate() {
const now = performance.now();
if (state.lastMsgTs) {
const dt = (now - state.lastMsgTs) / 1000;
document.getElementById('info-rate').textContent =
dt < 10 ? Math.round(1 / dt) + ' msg/s' : '—';
}
}
// ── Latency ping ───────────────────────────────────────────────────────────
function schedulePing() {
if (pingTimeout) clearTimeout(pingTimeout);
pingTimeout = setTimeout(() => {
if (!ros || !state.connected) return;
const svc = new ROSLIB.Service({ ros, name: '/rosapi/get_time', serviceType: 'rosapi/GetTime' });
pingTs = performance.now();
svc.callService({}, () => {
state.latency = Math.round(performance.now() - pingTs);
document.getElementById('info-latency').textContent = state.latency + ' ms';
document.getElementById('info-ip').textContent = $wsInput.value.replace('ws://','').split(':')[0];
schedulePing();
}, () => { schedulePing(); });
}, 5000);
}
// ── Auto-detect ────────────────────────────────────────────────────────────
const $detectStatus = document.getElementById('detect-status');
const $detectDots = document.getElementById('detect-dots');
const $detectBar = document.getElementById('detect-bar');
function updateDetectBar(cls, msg) {
$detectBar.className = cls;
$detectStatus.textContent = msg;
}
function buildDetectDots() {
$detectDots.innerHTML = AUTO_CANDIDATES.map((_, i) =>
`<div class="detect-dot" id="ddot-${i}"></div>`
).join('');
}
function tryAutoDetect(idx) {
if (idx >= AUTO_CANDIDATES.length) {
updateDetectBar('error', '✗ Auto-detect failed — enter URL manually');
return;
}
const url = AUTO_CANDIDATES[idx];
const dot = document.getElementById(`ddot-${idx}`);
if (dot) dot.className = 'detect-dot trying';
updateDetectBar('', `🔍 Trying ${url}`);
const testRos = new ROSLIB.Ros({ url });
const timer = setTimeout(() => {
testRos.close();
if (dot) dot.className = 'detect-dot fail';
tryAutoDetect(idx + 1);
}, 2000);
testRos.on('connection', () => {
clearTimeout(timer);
if (dot) dot.className = 'detect-dot ok';
// Mark remaining as skipped
for (let j = idx + 1; j < AUTO_CANDIDATES.length; j++) {
const d = document.getElementById(`ddot-${j}`);
if (d) d.className = 'detect-dot';
}
testRos.close();
// Connect for real
connect(url);
});
testRos.on('error', () => {
clearTimeout(timer);
if (dot) dot.className = 'detect-dot fail';
tryAutoDetect(idx + 1);
});
}
// ── Connection bar ─────────────────────────────────────────────────────────
const $connDot = document.getElementById('conn-dot');
const $connLabel = document.getElementById('conn-label');
const $wsInput = document.getElementById('ws-input');
document.getElementById('btn-connect').addEventListener('click', () => {
connect($wsInput.value.trim() || 'ws://localhost:9090');
});
// ── Liveness refresh loop ──────────────────────────────────────────────────
setInterval(() => {
PANELS.forEach(p => renderPanelCard(p.id));
renderMsgRate();
renderSession();
}, 1000);
// ── Session timer ──────────────────────────────────────────────────────────
state.sessionStart = new Date();
setInterval(renderSession, 1000);
// ── Init ───────────────────────────────────────────────────────────────────
buildDetectDots();
// Restore saved URL or auto-detect
const savedUrl = localStorage.getItem('dashboard_ws_url');
if (savedUrl) {
$wsInput.value = savedUrl;
updateDetectBar('', `⟳ Reconnecting to ${savedUrl}`);
connect(savedUrl);
} else {
tryAutoDetect(0);
}

File diff suppressed because it is too large Load Diff