Merge pull request 'feat: remote monitoring relay (Issue #424)' (#427) from sl-jetson/issue-424-remote-monitor into main

This commit is contained in:
sl-jetson 2026-03-04 23:59:25 -05:00
commit 73263c1faa
12 changed files with 606 additions and 0 deletions

View File

@ -0,0 +1,9 @@
build/
install/
log/
*.pyc
__pycache__/
.pytest_cache/
*.egg-info/
dist/
*.egg

View File

@ -0,0 +1,6 @@
remote_monitor:
ros__parameters:
ws_port: 9091
auth_token: 'default_token'
update_rate_hz: 2
enable_msgpack: true

View File

@ -0,0 +1,23 @@
from launch import LaunchDescription
from launch_ros.actions import Node
from ament_index_python.packages import get_package_share_directory
import os
def generate_launch_description():
pkg_dir = get_package_share_directory('saltybot_remote_monitor')
config_file = os.path.join(pkg_dir, 'config', 'remote_monitor.yaml')
remote_monitor_node = Node(
package='saltybot_remote_monitor',
executable='remote_monitor',
name='remote_monitor',
parameters=[config_file],
output='screen',
respawn=True,
respawn_delay=5,
)
return LaunchDescription([
remote_monitor_node,
])

View File

@ -0,0 +1,32 @@
<?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_remote_monitor</name>
<version>0.1.0</version>
<description>
Remote monitoring WebSocket relay with mobile-friendly UI.
2Hz JSON telemetry aggregation with msgpack compression, token auth,
5min history buffer, and critical alerts (fall, low battery, node crash).
</description>
<maintainer email="seb@vayrette.com">seb</maintainer>
<license>MIT</license>
<depend>rclpy</depend>
<depend>sensor_msgs</depend>
<depend>geometry_msgs</depend>
<depend>diagnostic_msgs</depend>
<depend>std_msgs</depend>
<exec_depend>python3-aiohttp</exec_depend>
<exec_depend>python3-msgpack</exec_depend>
<exec_depend>python3-launch-ros</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,287 @@
#!/usr/bin/env python3
import asyncio
import json
import math
import threading
import time
from collections import deque
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
import msgpack
import rclpy
from aiohttp import web
from diagnostic_msgs.msg import DiagnosticArray
from geometry_msgs.msg import Twist
from rclpy.node import Node
from sensor_msgs.msg import Imu, NavSatFix
from std_msgs.msg import Float32, Bool, String
class TelemetryBuffer:
"""Circular buffer for 5-minute history at 2Hz."""
def __init__(self, duration_seconds=300):
self.max_samples = int(duration_seconds * 2)
self.buffer = deque(maxlen=self.max_samples)
def add(self, data: Dict):
self.buffer.append({**data, 'ts': time.time()})
def get_history(self):
return list(self.buffer)
class RemoteMonitorNode(Node):
"""WebSocket relay for remote monitoring with telemetry aggregation."""
def __init__(self):
super().__init__('saltybot_remote_monitor')
self.declare_parameter('ws_port', 9091)
self.declare_parameter('auth_token', 'default_token')
self.declare_parameter('update_rate_hz', 2)
self.declare_parameter('enable_msgpack', True)
self.ws_port = self.get_parameter('ws_port').value
self.auth_token = self.get_parameter('auth_token').value
self.update_rate_hz = self.get_parameter('update_rate_hz').value
self.enable_msgpack = self.get_parameter('enable_msgpack').value
self.battery = {'voltage': 0.0, 'current': 0.0, 'soc': 0.0}
self.motors = {'left': 0.0, 'right': 0.0}
self.imu = {'roll': 0.0, 'pitch': 0.0, 'yaw': 0.0}
self.gps = {'lat': 0.0, 'lon': 0.0, 'alt': 0.0}
self.health = {'cpu_temp': 0.0, 'gpu_temp': 0.0, 'ram_pct': 0.0, 'disk_pct': 0.0}
self.social = {'is_speaking': False, 'face_id': None}
self.alerts = []
self.history_buffer = TelemetryBuffer()
self.ws_clients = set()
self.loop = None
self.create_subscription(DiagnosticArray, '/diagnostics', self.diag_callback, 10)
self.create_subscription(Imu, '/saltybot/imu', self.imu_callback, 10)
self.create_subscription(NavSatFix, '/gps/fix', self.gps_callback, 10)
self.create_subscription(String, '/saltybot/balance_state', self.balance_callback, 10)
self.create_subscription(Bool, '/social/speech/is_speaking', self.social_speech_callback, 10)
self.create_subscription(String, '/social/face/active', self.social_face_callback, 10)
period = 1.0 / self.update_rate_hz
self.update_timer = self.create_timer(period, self.telemetry_update_callback)
self.ws_thread = threading.Thread(target=self.start_ws_server, daemon=True)
self.ws_thread.start()
self.get_logger().info(
f'Remote monitor initialized on port {self.ws_port}, '
f'update rate {self.update_rate_hz}Hz, msgpack={self.enable_msgpack}'
)
def diag_callback(self, msg: DiagnosticArray):
"""Parse diagnostics for battery and system health."""
for status in msg.status:
kv = {pair.key: pair.value for pair in status.values}
if kv.get('battery_voltage_v'):
self.battery = {
'voltage': float(kv.get('battery_voltage_v', 0)),
'current': float(kv.get('battery_current_a', 0)),
'soc': float(kv.get('battery_soc_pct', 0)),
}
if self.battery['soc'] < 15:
self.add_alert('CRITICAL', 'Low battery', self.battery['soc'])
if kv.get('cpu_temp_c'):
self.health = {
'cpu_temp': float(kv.get('cpu_temp_c', 0)),
'gpu_temp': float(kv.get('gpu_temp_c', 0)),
'ram_pct': float(kv.get('ram_pct', 0)),
'disk_pct': float(kv.get('disk_pct', 0)),
}
def imu_callback(self, msg: Imu):
"""Extract roll/pitch/yaw from quaternion."""
q = msg.orientation
self.imu = self.quat_to_euler(q.x, q.y, q.z, q.w)
def gps_callback(self, msg: NavSatFix):
"""Store GPS data."""
self.gps = {
'lat': msg.latitude,
'lon': msg.longitude,
'alt': msg.altitude,
}
def balance_callback(self, msg: String):
"""Parse balance state for motor commands."""
try:
state = json.loads(msg.data)
cmd = state.get('motor_cmd', 0)
norm = max(-1, min(1, cmd / 1000))
self.motors = {'left': norm, 'right': norm}
if abs(norm) > 0.9:
self.add_alert('WARNING', 'High motor load', norm)
except Exception:
pass
def social_speech_callback(self, msg: Bool):
"""Update social speaking state."""
self.social['is_speaking'] = msg.data
def social_face_callback(self, msg: String):
"""Update recognized face ID."""
self.social['face_id'] = msg.data
@staticmethod
def quat_to_euler(qx, qy, qz, qw):
"""Convert quaternion to Euler angles."""
sinr_cosp = 2 * (qw * qx + qy * qz)
cosr_cosp = 1 - 2 * (qx * qx + qy * qy)
roll = math.atan2(sinr_cosp, cosr_cosp)
sinp = 2 * (qw * qy - qz * qx)
pitch = math.asin(max(-1, min(1, sinp)))
siny_cosp = 2 * (qw * qz + qx * qy)
cosy_cosp = 1 - 2 * (qy * qy + qz * qz)
yaw = math.atan2(siny_cosp, cosy_cosp)
return {
'roll': (roll * 180) / math.pi,
'pitch': (pitch * 180) / math.pi,
'yaw': (yaw * 180) / math.pi,
}
def add_alert(self, level: str, message: str, value: Optional[float] = None):
"""Add alert with timestamp."""
alert = {
'ts': time.time(),
'level': level,
'message': message,
'value': value,
}
self.alerts.append(alert)
if len(self.alerts) > 50:
self.alerts.pop(0)
def telemetry_update_callback(self):
"""Periodic telemetry aggregation and broadcast."""
telemetry = {
'timestamp': datetime.now().isoformat(),
'battery': self.battery,
'motors': self.motors,
'imu': self.imu,
'gps': self.gps,
'health': self.health,
'social': self.social,
'alerts': self.alerts[-10:],
}
self.history_buffer.add(telemetry)
if self.ws_clients and self.loop:
try:
asyncio.run_coroutine_threadsafe(
self.broadcast_telemetry(telemetry),
self.loop
)
except Exception as e:
self.get_logger().warn(f'Broadcast error: {e}')
async def broadcast_telemetry(self, telemetry: Dict):
"""Send telemetry to all connected WebSocket clients."""
if self.enable_msgpack:
data = msgpack.packb(telemetry)
else:
data = json.dumps(telemetry).encode('utf-8')
disconnected = set()
for ws in self.ws_clients:
try:
await ws.send_bytes(data)
except Exception:
disconnected.add(ws)
self.ws_clients -= disconnected
async def websocket_handler(self, request):
"""Handle WebSocket connections."""
token = request.rel_url.query.get('token')
if token != self.auth_token:
return web.Response(status=401, text='Unauthorized')
ws = web.WebSocketResponse()
await ws.prepare(request)
self.ws_clients.add(ws)
try:
history = self.history_buffer.get_history()
if self.enable_msgpack:
await ws.send_bytes(msgpack.packb({'type': 'history', 'data': history}))
else:
await ws.send_str(json.dumps({'type': 'history', 'data': history}))
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
try:
req = json.loads(msg.data)
if req.get('cmd') == 'get_history':
history = self.history_buffer.get_history()
if self.enable_msgpack:
await ws.send_bytes(msgpack.packb({'type': 'history', 'data': history}))
else:
await ws.send_str(json.dumps({'type': 'history', 'data': history}))
except Exception as e:
self.get_logger().error(f'WebSocket error: {e}')
except Exception as e:
self.get_logger().warn(f'WebSocket connection lost: {e}')
finally:
self.ws_clients.discard(ws)
return ws
async def index_handler(self, request):
"""Serve mobile-friendly HTML UI."""
html = Path(__file__).parent.parent / 'static' / 'index.html'
if html.exists():
return web.FileResponse(html)
return web.Response(text='UI not found', status=404)
def start_ws_server(self):
"""Start aiohttp WebSocket server."""
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
app = web.Application()
app.router.add_get('/', self.index_handler)
app.router.add_get('/ws', self.websocket_handler)
runner = web.AppRunner(app)
self.loop.run_until_complete(runner.setup())
site = web.TCPSite(runner, '0.0.0.0', self.ws_port)
self.loop.run_until_complete(site.start())
self.get_logger().info(f'WebSocket server running on port {self.ws_port}')
self.loop.run_forever()
def main(args=None):
rclpy.init(args=args)
node = RemoteMonitorNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()

View File

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

View File

@ -0,0 +1,34 @@
from setuptools import setup
import os
from glob import glob
package_name = 'saltybot_remote_monitor'
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')),
(os.path.join('share', package_name, 'static'),
glob('static/*')),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='seb',
maintainer_email='seb@vayrette.com',
description='Remote monitoring WebSocket relay with mobile UI',
license='MIT',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'remote_monitor = saltybot_remote_monitor.remote_monitor_node:main',
],
},
)

View File

@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Saltybot Remote Monitor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Courier New', monospace; background: #0a0e27; color: #e0e0ff; padding: 8px; }
.header { text-align: center; margin-bottom: 12px; padding: 8px; background: #1a1f3a; border: 1px solid #06b6d4; border-radius: 4px; }
.header h1 { font-size: 18px; color: #f59e0b; margin-bottom: 4px; }
.status { font-size: 11px; color: #888; }
.status.connected { color: #22c55e; }
.status.disconnected { color: #ef4444; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; margin-bottom: 12px; }
.card { background: #1a1f3a; border: 1px solid #06b6d4; border-radius: 4px; padding: 8px; font-size: 12px; }
.card.critical { border-color: #ef4444; background: #2a1a1a; }
.card.warning { border-color: #f59e0b; background: #2a1f0a; }
.card-title { color: #06b6d4; font-weight: bold; margin-bottom: 4px; text-transform: uppercase; font-size: 10px; }
.card-value { font-size: 16px; font-weight: bold; color: #22c55e; }
.card-value.critical { color: #ef4444; }
.card-value.warning { color: #f59e0b; }
.card-unit { font-size: 10px; color: #888; margin-left: 4px; }
.alert-list { background: #1a1f3a; border: 1px solid #ef4444; border-radius: 4px; padding: 8px; margin-bottom: 12px; max-height: 120px; overflow-y: auto; }
.alert-item { padding: 4px; margin: 2px 0; border-left: 3px solid #f59e0b; font-size: 11px; color: #f59e0b; }
.alert-item.critical { border-left-color: #ef4444; color: #ef4444; }
.progress-bar { width: 100%; height: 8px; background: #0a0e27; border-radius: 2px; margin-top: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: #22c55e; transition: width 0.3s ease; }
.progress-fill.warning { background: #f59e0b; }
.progress-fill.critical { background: #ef4444; }
.timestamp { text-align: center; font-size: 10px; color: #666; margin-top: 8px; }
@media (max-width: 480px) { body { padding: 4px; } .header h1 { font-size: 16px; } .grid { grid-template-columns: repeat(2, 1fr); gap: 6px; } .card { padding: 6px; font-size: 11px; } .card-value { font-size: 14px; } }
</style>
</head>
<body>
<div class="header">
<h1>⚡ REMOTE MONITOR</h1>
<div class="status disconnected" id="status">Connecting...</div>
</div>
<div class="alert-list" id="alertList" style="display: none;"></div>
<div class="grid">
<div class="card" id="batteryCard">
<div class="card-title">Battery</div>
<div class="card-value" id="soc">0<span class="card-unit">%</span></div>
<div style="font-size: 10px; color: #888;">
<div id="voltage">0V</div>
<div id="current">0A</div>
</div>
<div class="progress-bar"><div class="progress-fill" id="socBar" style="width: 100%;"></div></div>
</div>
<div class="card">
<div class="card-title">Motors</div>
<div style="font-size: 10px;">
<div>L: <span class="card-value" id="motorL">0</span><span class="card-unit">%</span></div>
<div style="margin-top: 4px;">R: <span class="card-value" id="motorR">0</span><span class="card-unit">%</span></div>
</div>
</div>
<div class="card">
<div class="card-title">Attitude</div>
<div style="font-size: 11px;">
<div>R: <span id="roll">0</span>°</div>
<div>P: <span id="pitch">0</span>°</div>
<div>Y: <span id="yaw">0</span>°</div>
</div>
</div>
<div class="card">
<div class="card-title">GPS</div>
<div style="font-size: 10px;">
<div id="lat">Waiting...</div>
<div id="lon">--</div>
<div id="alt">-- m</div>
</div>
</div>
<div class="card" id="cpuCard">
<div class="card-title">CPU Temp</div>
<div class="card-value" id="cpuTemp">0<span class="card-unit">°C</span></div>
</div>
<div class="card" id="gpuCard">
<div class="card-title">GPU Temp</div>
<div class="card-value" id="gpuTemp">0<span class="card-unit">°C</span></div>
</div>
<div class="card">
<div class="card-title">RAM</div>
<div class="card-value" id="ramPct">0<span class="card-unit">%</span></div>
<div class="progress-bar"><div class="progress-fill" id="ramBar" style="width: 0%;"></div></div>
</div>
<div class="card">
<div class="card-title">Disk</div>
<div class="card-value" id="diskPct">0<span class="card-unit">%</span></div>
<div class="progress-bar"><div class="progress-fill" id="diskBar" style="width: 0%;"></div></div>
</div>
<div class="card">
<div class="card-title">Social</div>
<div style="font-size: 11px;">
<div>Speaking: <span id="speaking">No</span></div>
<div>Face: <span id="faceId">none</span></div>
</div>
</div>
</div>
<div class="timestamp" id="timestamp">--</div>
<script>
const TOKEN = 'default_token';
const WS_URL = `ws://${window.location.host}/ws?token=${TOKEN}`;
let ws = null;
function getStatusClass(value, thresholds) {
if (thresholds.critical && value >= thresholds.critical) return 'critical';
if (thresholds.warning && value >= thresholds.warning) return 'warning';
return '';
}
function updateUI(telemetry) {
if (!telemetry) return;
const soc = telemetry.battery?.soc || 0;
document.getElementById('soc').textContent = soc.toFixed(0);
document.getElementById('voltage').textContent = (telemetry.battery?.voltage || 0).toFixed(1) + 'V';
document.getElementById('current').textContent = (telemetry.battery?.current || 0).toFixed(1) + 'A';
document.getElementById('socBar').style.width = soc + '%';
const batteryClass = getStatusClass(soc, { critical: 10, warning: 20 });
document.getElementById('batteryCard').className = 'card ' + batteryClass;
document.getElementById('socBar').className = 'progress-fill ' + batteryClass;
document.getElementById('soc').className = 'card-value ' + batteryClass;
document.getElementById('motorL').textContent = ((telemetry.motors?.left || 0) * 100).toFixed(0);
document.getElementById('motorR').textContent = ((telemetry.motors?.right || 0) * 100).toFixed(0);
document.getElementById('roll').textContent = (telemetry.imu?.roll || 0).toFixed(0);
document.getElementById('pitch').textContent = (telemetry.imu?.pitch || 0).toFixed(0);
document.getElementById('yaw').textContent = (telemetry.imu?.yaw || 0).toFixed(0);
document.getElementById('lat').textContent = (telemetry.gps?.lat || 0).toFixed(6);
document.getElementById('lon').textContent = (telemetry.gps?.lon || 0).toFixed(6);
document.getElementById('alt').textContent = (telemetry.gps?.alt || 0).toFixed(1) + 'm';
const cpuTemp = telemetry.health?.cpu_temp || 0;
const gpuTemp = telemetry.health?.gpu_temp || 0;
document.getElementById('cpuTemp').textContent = cpuTemp.toFixed(0);
document.getElementById('gpuTemp').textContent = gpuTemp.toFixed(0);
document.getElementById('cpuCard').className = 'card ' + getStatusClass(cpuTemp, { critical: 85, warning: 70 });
document.getElementById('gpuCard').className = 'card ' + getStatusClass(gpuTemp, { critical: 85, warning: 70 });
const ramPct = telemetry.health?.ram_pct || 0;
const diskPct = telemetry.health?.disk_pct || 0;
document.getElementById('ramPct').textContent = ramPct.toFixed(0);
document.getElementById('diskPct').textContent = diskPct.toFixed(0);
document.getElementById('ramBar').style.width = ramPct + '%';
document.getElementById('diskBar').style.width = diskPct + '%';
document.getElementById('speaking').textContent = telemetry.social?.is_speaking ? 'Yes' : 'No';
document.getElementById('faceId').textContent = telemetry.social?.face_id || 'none';
document.getElementById('timestamp').textContent = new Date(telemetry.timestamp).toLocaleTimeString();
updateAlerts(telemetry.alerts || []);
}
function updateAlerts(alerts) {
const list = document.getElementById('alertList');
if (alerts.length === 0) { list.style.display = 'none'; return; }
list.style.display = 'block';
list.innerHTML = alerts.map(a => `<div class="alert-item ${a.level.toLowerCase()}">[${a.level}] ${a.message} ${a.value ? '(' + a.value.toFixed(2) + ')' : ''}</div>`).join('');
}
function connect() {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
document.getElementById('status').textContent = 'Connected';
document.getElementById('status').className = 'status connected';
ws.send(JSON.stringify({ cmd: 'get_history' }));
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'history') {
msg.data.forEach(t => updateUI(t));
} else {
updateUI(msg);
}
} catch (err) {
console.error('Decode error:', err);
}
};
ws.onclose = () => {
document.getElementById('status').textContent = 'Disconnected';
document.getElementById('status').className = 'status disconnected';
setTimeout(connect, 3000);
};
}
connect();
</script>
</body>
</html>

View File

@ -0,0 +1,30 @@
import unittest
from pathlib import Path
class TestRemoteMonitor(unittest.TestCase):
"""Basic tests for remote monitor functionality."""
def test_imports(self):
"""Test that the module can be imported."""
from saltybot_remote_monitor import remote_monitor_node
self.assertIsNotNone(remote_monitor_node)
def test_config_file_exists(self):
"""Test that config file exists."""
config_file = Path(__file__).parent.parent / 'config' / 'remote_monitor.yaml'
self.assertTrue(config_file.exists())
def test_launch_file_exists(self):
"""Test that launch file exists."""
launch_file = Path(__file__).parent.parent / 'launch' / 'remote_monitor.launch.py'
self.assertTrue(launch_file.exists())
def test_html_ui_exists(self):
"""Test that HTML UI exists."""
html_file = Path(__file__).parent.parent / 'static' / 'index.html'
self.assertTrue(html_file.exists())
if __name__ == '__main__':
unittest.main()