feat: remote monitoring WebSocket relay (Issue #424)
Implement WebSocket telemetry relay with: - 2Hz JSON aggregation (battery, motors, IMU, GPS, health, social) - Port 9091 with token authentication - msgpack compression option - 5-minute circular history buffer - Mobile-friendly responsive HTML UI - Auto-reconnect WebSocket with fallback - Critical alerts: low battery (< 15%), high temps, node crash - Real-time dashboard with telemetry gauges Features: - Battery monitoring with SOC/voltage/current - Motor command visualization (L/R duty) - IMU attitude display (roll/pitch/yaw) - CPU/GPU temperature with thresholds - RAM/Disk usage progress bars - GPS coordinates (lat/lon/alt) - Social state (speaking, face tracking) - Alert history with severity levels Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a06821a8c8
commit
16068aa3e4
9
jetson/ros2_ws/src/saltybot_remote_monitor/.gitignore
vendored
Normal file
9
jetson/ros2_ws/src/saltybot_remote_monitor/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
build/
|
||||||
|
install/
|
||||||
|
log/
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
*.egg
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
remote_monitor:
|
||||||
|
ros__parameters:
|
||||||
|
ws_port: 9091
|
||||||
|
auth_token: 'default_token'
|
||||||
|
update_rate_hz: 2
|
||||||
|
enable_msgpack: true
|
||||||
@ -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,
|
||||||
|
])
|
||||||
32
jetson/ros2_ws/src/saltybot_remote_monitor/package.xml
Normal file
32
jetson/ros2_ws/src/saltybot_remote_monitor/package.xml
Normal 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>
|
||||||
@ -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()
|
||||||
5
jetson/ros2_ws/src/saltybot_remote_monitor/setup.cfg
Normal file
5
jetson/ros2_ws/src/saltybot_remote_monitor/setup.cfg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[develop]
|
||||||
|
script_dir=$base/lib/saltybot_remote_monitor
|
||||||
|
|
||||||
|
[install]
|
||||||
|
script_dir=$base/lib/saltybot_remote_monitor
|
||||||
34
jetson/ros2_ws/src/saltybot_remote_monitor/setup.py
Normal file
34
jetson/ros2_ws/src/saltybot_remote_monitor/setup.py
Normal 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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
180
jetson/ros2_ws/src/saltybot_remote_monitor/static/index.html
Normal file
180
jetson/ros2_ws/src/saltybot_remote_monitor/static/index.html
Normal 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>
|
||||||
@ -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()
|
||||||
Loading…
x
Reference in New Issue
Block a user