feat: WebSocket bridge for CAN monitor dashboard (Issue #697) #702

Merged
sl-jetson merged 1 commits from sl-webui/issue-697-websocket-bridge into main 2026-03-20 17:38:25 -04:00
3 changed files with 117 additions and 4 deletions

View File

@ -49,6 +49,9 @@ rosbridge_websocket:
"/cmd_vel", "/cmd_vel",
"/saltybot/imu", "/saltybot/imu",
"/saltybot/balance_state", "/saltybot/balance_state",
"/saltybot/barometer",
"/vesc/left/state",
"/vesc/right/state",
"/tf", "/tf",
"/tf_static"] "/tf_static"]

View File

@ -0,0 +1,70 @@
"""
can_monitor.launch.py Lightweight rosbridge server for CAN sensor dashboard (Issue #697)
Starts rosbridge_websocket on port 9090 with a whitelist limited to the five
topics consumed by can_monitor_panel.html:
/saltybot/imu sensor_msgs/Imu IMU attitude
/saltybot/balance_state std_msgs/String (JSON) balance PID state
/saltybot/barometer std_msgs/String (JSON) pressure / temp / altitude
/vesc/left/state std_msgs/String (JSON) left VESC telemetry
/vesc/right/state std_msgs/String (JSON) right VESC telemetry
Usage:
ros2 launch saltybot_bringup can_monitor.launch.py
# Override port if needed:
ros2 launch saltybot_bringup can_monitor.launch.py port:=9091
Verify:
# From a browser on the same LAN:
# var ros = new ROSLIB.Ros({ url: 'ws://<jetson-ip>:9090' });
# ros.on('connection', () => console.log('connected'));
"""
import os
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
# Topics exposed to the CAN monitor WebUI panel.
_TOPICS = [
'/saltybot/imu',
'/saltybot/balance_state',
'/saltybot/barometer',
'/vesc/left/state',
'/vesc/right/state',
]
_TOPICS_GLOB = '[' + ', '.join(f'"{t}"' for t in _TOPICS) + ']'
def generate_launch_description():
port_arg = DeclareLaunchArgument(
'port',
default_value='9090',
description='WebSocket port for rosbridge (default 9090)',
)
rosbridge = Node(
package='rosbridge_server',
executable='rosbridge_websocket',
name='rosbridge_websocket',
parameters=[{
'port': LaunchConfiguration('port'),
'host': '0.0.0.0',
'authenticate': False,
'max_message_size': 1000000, # 1 MB — no large map payloads needed
'topics_glob': _TOPICS_GLOB,
'services_glob': '[]',
'params_glob': '[]',
'unregister_timeout': 10.0,
'fragment_timeout': 600,
'delay_between_messages': 0,
}],
output='screen',
)
return LaunchDescription([port_arg, rosbridge])

View File

@ -293,11 +293,51 @@ function drawCompass(yaw) {
// ── ROS connection ──────────────────────────────────────────────────────────── // ── ROS connection ────────────────────────────────────────────────────────────
function connect(url) { // ── Auto-reconnect state ──────────────────────────────────────────────────────
const RECONNECT_BASE_MS = 2000;
const RECONNECT_MAX_MS = 30000;
const RECONNECT_FACTOR = 1.5;
let reconnectDelay = RECONNECT_BASE_MS;
let reconnectTimer = null;
let reconnectTick = null;
let reconnectTarget = null;
function cancelReconnect() {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (reconnectTick) { clearInterval(reconnectTick); reconnectTick = null; }
}
function scheduleReconnect(url) {
cancelReconnect();
let secs = Math.round(reconnectDelay / 1000);
$('conn-label').textContent = `Retry in ${secs}s…`;
reconnectTick = setInterval(() => {
secs = Math.max(0, secs - 1);
if (secs > 0) $('conn-label').textContent = `Retry in ${secs}s…`;
}, 1000);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect(url, true);
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * RECONNECT_FACTOR, RECONNECT_MAX_MS);
}
function connect(url, isAutoRetry) {
cancelReconnect();
reconnectTarget = url;
if (!isAutoRetry) reconnectDelay = RECONNECT_BASE_MS;
if (ros) { try { ros.close(); } catch(_) {} } if (ros) { try { ros.close(); } catch(_) {} }
ros = new ROSLIB.Ros({ url }); ros = new ROSLIB.Ros({ url });
ros.on('connection', () => { ros.on('connection', () => {
reconnectDelay = RECONNECT_BASE_MS;
cancelReconnect();
$('conn-dot').className = 'connected'; $('conn-dot').className = 'connected';
$('conn-label').style.color = '#22c55e'; $('conn-label').style.color = '#22c55e';
$('conn-label').textContent = 'Connected'; $('conn-label').textContent = 'Connected';
@ -315,7 +355,7 @@ function connect(url) {
ros.on('close', () => { ros.on('close', () => {
$('conn-dot').className = ''; $('conn-dot').className = '';
$('conn-label').style.color = '#6b7280'; $('conn-label').style.color = '#6b7280';
$('conn-label').textContent = 'Disconnected'; scheduleReconnect(reconnectTarget || $('ws-input').value.trim());
}); });
} }
@ -405,8 +445,8 @@ function setupTopics() {
// ── UI wiring ───────────────────────────────────────────────────────────────── // ── UI wiring ─────────────────────────────────────────────────────────────────
$('btn-connect').addEventListener('click', () => connect($('ws-input').value.trim())); $('btn-connect').addEventListener('click', () => connect($('ws-input').value.trim(), false));
$('ws-input').addEventListener('keydown', e => { if (e.key === 'Enter') connect($('ws-input').value.trim()); }); $('ws-input').addEventListener('keydown', e => { if (e.key === 'Enter') connect($('ws-input').value.trim(), false); });
// ── Auto-connect on load ────────────────────────────────────────────────────── // ── Auto-connect on load ──────────────────────────────────────────────────────