first commit

This commit is contained in:
2026-04-14 17:45:47 +08:00
commit d574ba3414
5 changed files with 755 additions and 0 deletions

0
.codex Normal file
View File

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.egg-info/
dist/
build/
.venv/
venv/
env/
# JetBrains IDE
.idea/
# macOS
.DS_Store

201
README.md Normal file
View File

@@ -0,0 +1,201 @@
# xMonitor — Robot Status Monitor
A real-time web dashboard for monitoring a ROS 2 robot's motor, arm, waist, and power status.
Data is streamed from the robot controller over WebSocket and displayed in a browser.
---
## Architecture
```
Robot Controller (ROS 2) Monitor Server (PC / Server)
┌──────────────────────────┐ ┌──────────────────────────┐
│ monitor_sender.py │ WebSocket │ main_monitor.py │
│ ─────────────────────── │─────────────▶│ (FastAPI + uvicorn) │
│ Subscribes: │ /ws/robot │ │
│ • /leg/status │ │ Broadcasts to browsers │
│ • /arm/status │ │ via /ws │
│ • /waist/status │ └────────────┬─────────────┘
│ • /power/battery/status │ │ WebSocket /ws
│ • /leg/motor_status │ ▼
│ • /arm/motor_status │ ┌──────────────────────────┐
│ • /waist/motor_status │ │ Browser (any device) │
│ │ │ http://<server-ip>:8000 │
│ Sends JSON every 1 s │ └──────────────────────────┘
└──────────────────────────┘
```
---
## Requirements
### Monitor Server (PC / any host)
| Package | Purpose |
|---------|---------|
| Python ≥ 3.9 | Runtime |
| `fastapi` | Web framework |
| `uvicorn` | ASGI server |
| `websockets` | (used by sender; not needed on server) |
```bash
pip install fastapi uvicorn
```
### Robot Controller (ROS 2 node)
| Package | Purpose |
|---------|---------|
| Python ≥ 3.9 | Runtime |
| ROS 2 (Humble / Iron / …) | Middleware |
| `bodyctrl_msgs` | Custom message package |
| `websockets` | WebSocket client |
| `rclpy` | ROS 2 Python client |
```bash
pip install websockets
```
---
## Monitor Server — `main_monitor.py`
### Start
```bash
# Default: listens on all interfaces, port 8000
python main_monitor.py
# Or with uvicorn directly (supports --reload for development)
uvicorn main_monitor:app --host 0.0.0.0 --port 8000
```
### WebSocket endpoints
| Endpoint | Direction | Description |
|----------|-----------|-------------|
| `GET /` | HTTP | Returns the web dashboard HTML |
| `WS /ws` | Server → Browser | Pushes robot status to every connected browser |
| `WS /ws/robot` | Robot → Server | Receives JSON from `monitor_sender.py` |
### Web GUI
Open `http://<server-ip>:8000` in any browser.
The dashboard shows:
- **Connection status** indicator (green / red dot)
- **Last update timestamp**
- **Power panel** (two rows):
- 主电池 (Master battery): voltage (V), current (A), SOC (%)
- 副电池 (Secondary battery): voltage (V), current (A), SOC (%)
- Cards turn **orange** (warn) or **red** (alert) when voltage / SOC drops low
- **Motor status table** with columns:
`ID | Pos (rad) | Speed | Current (A) | Temp (°C) | Motor Temp | MOS Temp | Error`
- Groups: 左臂 11-14, 右臂 21-24, 腰部 31-33, 左腿 51-56, 右腿 61-66
- Temperature cells turn **yellow** when > 100 °C, **red** when > 120 °C
- Rows with errors are highlighted red; error column shows `✓` when OK
- Browser auto-reconnects every 3 s if the WebSocket drops
---
## Robot Sender — `monitor_sender.py`
Run this on the robot controller where ROS 2 is running.
### Usage
```bash
python3 monitor_sender.py --ip <server-ip> [--port <port>]
```
| Argument | Default | Description |
|----------|---------|-------------|
| `--ip` | `10.11.24.86` | IP address of the monitor server |
| `--port` | `8000` | TCP port of the monitor server |
### Examples
```bash
# Connect to server at 192.168.1.100 on default port 8000
python3 monitor_sender.py --ip 192.168.1.100
# Connect to server at 10.0.0.5 on port 9000
python3 monitor_sender.py --ip 10.0.0.5 --port 9000
```
### Subscribed ROS 2 topics
| Topic | Message Type | Description |
|-------|-------------|-------------|
| `/leg/status` | `bodyctrl_msgs/MotorStatusMsg` | Leg motor status (pos / speed / current / temp / error) |
| `/arm/status` | `bodyctrl_msgs/MotorStatusMsg` | Arm motor status |
| `/waist/status` | `bodyctrl_msgs/MotorStatusMsg` | Waist motor status |
| `/leg/motor_status` | `bodyctrl_msgs/MotorStatusMsg1` | Leg motor & MOS temperatures |
| `/arm/motor_status` | `bodyctrl_msgs/MotorStatusMsg1` | Arm motor & MOS temperatures |
| `/waist/motor_status` | `bodyctrl_msgs/MotorStatusMsg1` | Waist motor & MOS temperatures |
| `/power/battery/status` | `bodyctrl_msgs/PowerBatteryStatus` | Battery voltages, currents, SOC |
### Behaviour
- All topics are sampled once per second (1 Hz) via a ROS 2 timer, reducing network overhead.
- Data from all topics is merged into a single JSON payload and sent over WebSocket.
- Motor temperature (`motortemperature`) and MOS temperature (`mostemperature`) from `MotorStatusMsg1` are merged into the corresponding motor entry by `name` ID.
- If the WebSocket connection to the server drops, the sender automatically retries every 3 s and clears the internal queue to avoid stale data.
### JSON payload format
```json
{
"timestamp": "2026-04-05T10:00:00+00:00",
"statuses": [
{
"name": 51,
"pos": -0.2508,
"speed": -0.0051,
"current": -0.0488,
"temperature": 30.0,
"motor_temp": 28.5,
"mos_temp": 31.2,
"error": 0
}
],
"power": {
"voltage": 52.30,
"current": -2.60,
"power": 100.0,
"little_voltage": 53.10,
"little_current": -0.10,
"little_power": 94.0
}
}
```
---
## Quick-start (end-to-end)
1. **On the monitor server** (PC on the same LAN):
```bash
pip install fastapi uvicorn
python main_monitor.py
```
2. **On the robot controller** (source your ROS 2 workspace first):
```bash
source /home/ubuntu/ros2ws/install/setup.bash
python3 monitor_sender.py --ip <your-server-ip>
```
3. **Open browser**: navigate to `http://<your-server-ip>:8000`
---
## File Overview
| File | Description |
|------|-------------|
| `main_monitor.py` | FastAPI server — hosts the web GUI and relays data to browsers |
| `monitor_sender.py` | ROS 2 node — collects robot data and streams it to the server |
| `README.md` | This file |

305
main_monitor.py Normal file
View File

@@ -0,0 +1,305 @@
# python
import asyncio
from datetime import datetime, timezone
import json
from pathlib import Path
from typing import List
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
app = FastAPI()
# 简单内存管理:保存浏览器客户端
browser_clients: List[WebSocket] = []
# 可选:保存最近一条状态
last_status = None
LOG_DIR = Path(__file__).resolve().parent / "logs"
PACKET_LOG_PATH = LOG_DIR / "robot_packets.jsonl"
INDEX_HTML = """
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Robot Status Monitor</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; background: #f5f5f5; }
h2 { margin-bottom: 5px; }
.ts { color: #666; font-size: 0.9em; margin-bottom: 10px; }
table { border-collapse: collapse; width: 100%; max-width: 1000px; margin-top: 10px; }
th { background: #4a90d9; color: white; padding: 10px 8px; text-align: center; }
td { border: 1px solid #ccc; padding: 8px; text-align: center; font-family: monospace; }
tr.even { background: #fff; }
tr.odd { background: #f0f0f0; }
tr.error { background: #ffe0e0 !important; }
.group-header { background: #ddd; font-weight: bold; text-align: left; padding-left: 12px; }
.group-header.arm { background: #d0e8ff; }
.group-header.waist { background: #d6f5d6; }
.ok { color: green; }
.bad { color: red; font-weight: bold; }
.temp-yellow { color: #e6a800; font-weight: bold; }
.temp-red { color: red; font-weight: bold; }
#status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
#status-dot.ok { background: #4caf50; }
#status-dot.bad { background: #f44336; }
.power-panel { display: flex; gap: 24px; margin: 12px 0 18px 0; flex-wrap: wrap; }
.power-card { background: #fff; border: 1px solid #ccc; border-radius: 8px; padding: 14px 24px; min-width: 160px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.power-card .label { font-size: 0.85em; color: #666; margin-bottom: 4px; }
.power-card .value { font-size: 1.6em; font-weight: bold; font-family: monospace; color: #333; }
.power-card.warn .value { color: #e67e00; }
.power-card.alert .value { color: #e00; }
</style>
</head>
<body>
<h2>🤖 Robot Status Monitor</h2>
<p><span id="status-dot" class="bad"></span>WebSocket: <span id="status">连接中...</span></p>
<p class="ts">最近更新: <span id="ts">-</span></p>
<div class="power-panel">
<div class="power-card" id="pwr-voltage">
<div class="label">⚡ 主电池电压</div>
<div class="value" id="pwr-voltage-val">- V</div>
</div>
<div class="power-card" id="pwr-current">
<div class="label">🔋 主电池电流</div>
<div class="value" id="pwr-current-val">- A</div>
</div>
<div class="power-card" id="pwr-power">
<div class="label">📊 主电池电量</div>
<div class="value" id="pwr-power-val">- %</div>
</div>
</div>
<div class="power-panel">
<div class="power-card" id="pwr-little-voltage">
<div class="label">⚡ 副电池电压</div>
<div class="value" id="pwr-little-voltage-val">- V</div>
</div>
<div class="power-card" id="pwr-little-current">
<div class="label">🔋 副电池电流</div>
<div class="value" id="pwr-little-current-val">- A</div>
</div>
<div class="power-card" id="pwr-little-power">
<div class="label">📊 副电池电量</div>
<div class="value" id="pwr-little-power-val">- %</div>
</div>
</div>
<table>
<thead>
<tr>
<th>ID</th><th>Pos (rad)</th><th>Speed</th><th>Current (A)</th><th>Temp (°C)</th><th>Motor Temp</th><th>MOS Temp</th><th>Error</th>
</tr>
</thead>
<tbody id="tbody">
<!-- 左臂 11-14 -->
<tr><td colspan="8" class="group-header arm">左臂 (11-14)</td></tr>
<tr id="r11"><td>11</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r12"><td>12</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r13"><td>13</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r14"><td>14</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<!-- 右臂 21-24 -->
<tr><td colspan="8" class="group-header arm">右臂 (21-24)</td></tr>
<tr id="r21"><td>21</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r22"><td>22</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r23"><td>23</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r24"><td>24</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<!-- 腰部 31-33 -->
<tr><td colspan="8" class="group-header waist">腰部 (31-33)</td></tr>
<tr id="r31"><td>31</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r32"><td>32</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r33"><td>33</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<!-- 左腿 51-56 -->
<tr><td colspan="8" class="group-header">左腿 (51-56)</td></tr>
<tr id="r51"><td>51</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r52"><td>52</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r53"><td>53</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r54"><td>54</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r55"><td>55</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r56"><td>56</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<!-- 右腿 61-66 -->
<tr><td colspan="8" class="group-header">右腿 (61-66)</td></tr>
<tr id="r61"><td>61</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r62"><td>62</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r63"><td>63</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r64"><td>64</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r65"><td>65</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
<tr id="r66"><td>66</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>
</tbody>
</table>
<script>
const statusSpan = document.getElementById('status');
const statusDot = document.getElementById('status-dot');
const tsSpan = document.getElementById('ts');
const WS_HOST = location.hostname;
const WS_PORT = location.port || '8000';
let ws;
function connectWS() {
ws = new WebSocket('ws://' + WS_HOST + ':' + WS_PORT + '/ws');
ws.onopen = () => { statusSpan.textContent = '已连接'; statusDot.className = 'ok'; console.log('[WS] connected'); };
ws.onclose = () => { statusSpan.textContent = '已断开3秒后重连...'; statusDot.className = 'bad'; setTimeout(connectWS, 3000); };
ws.onerror = (e) => { statusSpan.textContent = '错误'; statusDot.className = 'bad'; console.error('[WS] error', e); };
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.timestamp) tsSpan.textContent = msg.timestamp;
if (msg.statuses && Array.isArray(msg.statuses)) {
msg.statuses.forEach(s => updateRow(s));
}
if (msg.power) updatePower(msg.power);
} catch (e) {
console.error('parse error', e, ev.data);
}
};
}
function fmt(v, digits) {
if (v === undefined || v === null) return '-';
return Number(v).toFixed(digits !== undefined ? digits : 4);
}
function tempClass(v) {
if (v === undefined || v === null) return '';
if (v > 120) return 'temp-red';
if (v > 100) return 'temp-yellow';
return '';
}
function updateRow(s) {
const tr = document.getElementById('r' + s.name);
if (!tr) return;
const hasError = s.error && s.error !== 0;
tr.className = hasError ? 'error' : '';
tr.cells[1].textContent = fmt(s.pos, 4);
tr.cells[2].textContent = fmt(s.speed, 4);
tr.cells[3].textContent = fmt(s.current, 4);
tr.cells[4].textContent = fmt(s.temperature, 1);
tr.cells[4].className = tempClass(s.temperature);
if (s.motor_temp !== undefined) {
tr.cells[5].textContent = fmt(s.motor_temp, 1);
tr.cells[5].className = tempClass(s.motor_temp);
}
if (s.mos_temp !== undefined) {
tr.cells[6].textContent = fmt(s.mos_temp, 1);
tr.cells[6].className = tempClass(s.mos_temp);
}
tr.cells[7].textContent = hasError ? s.error : '';
tr.cells[7].className = hasError ? 'bad' : 'ok';
}
function updatePower(p) {
const vCard = document.getElementById('pwr-voltage');
const pCard = document.getElementById('pwr-power');
const lvCard = document.getElementById('pwr-little-voltage');
const lpCard = document.getElementById('pwr-little-power');
document.getElementById('pwr-voltage-val').textContent = fmt(p.voltage, 2) + ' V';
document.getElementById('pwr-current-val').textContent = fmt(p.current, 2) + ' A';
document.getElementById('pwr-power-val').textContent = fmt(p.power, 1) + ' %';
document.getElementById('pwr-little-voltage-val').textContent = fmt(p.little_voltage, 2) + ' V';
document.getElementById('pwr-little-current-val').textContent = fmt(p.little_current, 2) + ' A';
document.getElementById('pwr-little-power-val').textContent = fmt(p.little_power, 1) + ' %';
// colour-code master voltage: warn < 48V, alert < 44V
vCard.className = 'power-card' + (p.voltage < 44 ? ' alert' : p.voltage < 48 ? ' warn' : '');
// colour-code master SOC: warn < 30%, alert < 15%
pCard.className = 'power-card' + (p.power < 15 ? ' alert' : p.power < 30 ? ' warn' : '');
// colour-code little voltage: warn < 48V, alert < 44V
lvCard.className = 'power-card' + (p.little_voltage < 44 ? ' alert' : p.little_voltage < 48 ? ' warn' : '');
// colour-code little SOC: warn < 30%, alert < 15%
lpCard.className = 'power-card' + (p.little_power < 15 ? ' alert' : p.little_power < 30 ? ' warn' : '');
}
connectWS();
</script>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
async def index():
return INDEX_HTML
def append_robot_packet_log(packet: dict):
"""将服务器收到的机器人数据按 JSON Lines 追加写入日志文件。"""
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_record = {
"received_at": datetime.now(timezone.utc).isoformat(),
"packet_timestamp": packet.get("timestamp"),
"packet": packet,
}
with PACKET_LOG_PATH.open("a", encoding="utf-8") as log_file:
log_file.write(json.dumps(log_record, ensure_ascii=False) + "\n")
# 浏览器客户端 WebSocket接收广播
@app.websocket("/ws")
async def websocket_browser_endpoint(ws: WebSocket):
await ws.accept()
browser_clients.append(ws)
global last_status
try:
# 可在连接时推送最近一条状态
if last_status is not None:
await ws.send_text(json.dumps(last_status))
while True:
# 浏览器无需发送消息,但保持连接活跃
msg = await ws.receive_text()
# 可以处理浏览器发来的控制命令(未实现),这里忽略
except WebSocketDisconnect:
pass
finally:
if ws in browser_clients:
browser_clients.remove(ws)
# 机器人/ROS2 发送端 WebSocket推送 JSON
@app.websocket("/ws/robot")
async def websocket_robot_endpoint(ws: WebSocket):
await ws.accept()
print("[WS] 机器人发送端已连接")
try:
while True:
text = await ws.receive_text()
try:
data = json.loads(text)
except Exception:
print(f"[WS] JSON 解析失败: {text[:200]}")
continue
print(f"[WS] 收到机器人数据: {len(data.get('statuses', []))} 个电机")
append_robot_packet_log(data)
payload = {
"statuses": data.get("statuses", []),
"timestamp": data.get("timestamp"),
"power": data.get("power"),
}
global last_status
last_status = payload
await broadcast_to_browsers(payload)
except WebSocketDisconnect:
print("[WS] 机器人发送端已断开")
async def broadcast_to_browsers(payload):
text = json.dumps(payload)
to_remove = []
for client in list(browser_clients):
try:
await client.send_text(text)
except Exception:
to_remove.append(client)
for c in to_remove:
if c in browser_clients:
browser_clients.remove(c)
# 可直接运行: uvicorn main_monitor:app --reload --port 8000
if __name__ == "__main__":
import uvicorn
uvicorn.run("main_monitor:app", host="0.0.0.0", port=8000, reload=True)

231
monitor_sender.py Normal file
View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""
monitor_sender.py
运行在机器人控制器上,订阅 /robot_state ROS2 话题,
并尽量兼容 /power/battery/status 电池话题,
通过 WebSocket 将状态信息发送到监控服务器。
每 1 秒采样并发送一次,降低通信开销。
用法:
ros2 run <your_package> monitor_sender
或直接: python3 monitor_sender.py --ip 192.168.1.100 --port 8000
"""
import argparse
import asyncio
import json
import threading
from datetime import datetime, timezone
import rclpy
from rclpy.node import Node
import websockets
import websockets.exceptions
try:
from ros2_bridge_msgs.msg import RobotState
except ImportError as exc:
RobotState = None
ROBOT_STATE_IMPORT_ERROR = exc
else:
ROBOT_STATE_IMPORT_ERROR = None
try:
from bodyctrl_msgs.msg import PowerBatteryStatus
except ImportError:
PowerBatteryStatus = None
class RobotStatusBridge(Node):
def __init__(self, msg_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
super().__init__("monitor_sender")
self._queue = msg_queue
self._loop = loop
self._lock = threading.Lock()
self._latest_snapshot = None
self._latest_power = None # 电源状态单独保存
if RobotState is None:
raise RuntimeError(
"无法导入 ros2_bridge_msgs/msg/RobotState请确认已 source 对应 ROS 2 工作空间。"
) from ROBOT_STATE_IMPORT_ERROR
self.create_subscription(RobotState, "/robot_state", self._robot_state_cb, 10)
subscribed_topics = ["/robot_state"]
if PowerBatteryStatus is not None:
self.create_subscription(
PowerBatteryStatus,
"/power/battery/status",
self._power_cb,
10,
)
subscribed_topics.append("/power/battery/status")
else:
self.get_logger().warn(
"未找到 bodyctrl_msgs/PowerBatteryStatus将跳过 /power/battery/status 订阅"
)
self.get_logger().info(f"已订阅 {', '.join(subscribed_topics)}")
# 创建 1 秒定时器,每秒将最新状态放入队列
self._timer = self.create_timer(1.0, self._timer_cb)
@staticmethod
def _stamp_to_iso(stamp) -> str:
return datetime.fromtimestamp(
stamp.sec + stamp.nanosec * 1e-9, tz=timezone.utc
).isoformat()
@staticmethod
def _extract_temp_field(status, snake_name: str, legacy_name: str):
value = getattr(status, snake_name, None)
if value is None:
value = getattr(status, legacy_name, None)
return value
def _collect_statuses(self, status_group) -> list[dict]:
statuses = []
for s in getattr(status_group, "status", []):
entry = {
"name": int(s.name),
"pos": float(s.pos),
"speed": float(s.speed),
"current": float(s.current),
"temperature": float(s.temperature),
"error": int(s.error),
}
motor_temp = self._extract_temp_field(s, "motor_temperature", "motortemperature")
mos_temp = self._extract_temp_field(s, "mos_temperature", "mostemperature")
if motor_temp is not None:
entry["motor_temp"] = round(float(motor_temp), 1)
if mos_temp is not None:
entry["mos_temp"] = round(float(mos_temp), 1)
statuses.append(entry)
return statuses
def _robot_state_cb(self, msg):
try:
statuses = []
for key in ("head", "arm", "waist", "leg"):
section = getattr(msg, key, None)
if section is not None:
statuses.extend(self._collect_statuses(section))
timestamp = None
header = getattr(msg, "header", None)
if header is not None and hasattr(header, "stamp"):
timestamp = self._stamp_to_iso(header.stamp)
with self._lock:
self._latest_snapshot = {"statuses": statuses, "timestamp": timestamp}
except Exception as e:
self.get_logger().warn(f"解析 /robot_state 消息失败: {e}")
def _power_cb(self, msg):
"""电源状态回调:保存最新电源数据"""
try:
with self._lock:
self._latest_power = {
"voltage": round(float(msg.master_battery_voltage), 3),
"current": round(float(msg.master_battery_current), 3),
"power": round(float(msg.master_battery_power), 1),
"little_voltage": round(float(msg.little_battery_voltage), 3),
"little_current": round(float(msg.little_battery_current), 3),
"little_power": round(float(msg.little_battery_power), 1),
}
except Exception as e:
self.get_logger().warn(f"解析 power 消息失败: {e}")
def _timer_cb(self):
"""1 秒定时器回调:合并所有话题的最新状态,放入异步队列"""
with self._lock:
snapshot = self._latest_snapshot
power = self._latest_power
# 清空,避免重复发送
self._latest_snapshot = None
self._latest_power = None
if snapshot is None and power is None:
return
payload = {
"statuses": snapshot["statuses"] if snapshot else [],
"timestamp": snapshot["timestamp"] if snapshot else None,
"power": power,
}
try:
self._loop.call_soon_threadsafe(self._queue.put_nowait, payload)
except asyncio.QueueFull:
self.get_logger().warn("发送队列已满,丢弃本次数据")
async def ws_sender(server_url: str, msg_queue: asyncio.Queue):
"""异步 WebSocket 发送循环,带自动重连"""
while True:
try:
async with websockets.connect(server_url) as ws:
print(f"[WS] 已连接到 {server_url}")
while True:
payload = await msg_queue.get()
text = json.dumps(payload)
await ws.send(text)
except (
OSError,
websockets.exceptions.ConnectionClosed,
websockets.exceptions.InvalidURI,
ConnectionRefusedError,
) as e:
print(f"[WS] 连接断开/失败: {e}3 秒后重连...")
while not msg_queue.empty():
try:
msg_queue.get_nowait()
except asyncio.QueueEmpty:
break
await asyncio.sleep(3)
def spin_ros(node: Node):
"""在独立线程中运行 ROS2 spin"""
rclpy.spin(node)
def main():
parser = argparse.ArgumentParser(description="ROS2 Robot Status → WebSocket 发送端")
parser.add_argument(
"--ip",
type=str,
default="10.11.24.86",
help="监控服务器 IP 地址,例如 192.168.1.100",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="监控服务器端口(默认 8000",
)
args = parser.parse_args()
server_url = f"ws://{args.ip}:{args.port}/ws/robot"
rclpy.init()
loop = asyncio.new_event_loop()
msg_queue: asyncio.Queue = asyncio.Queue(maxsize=100)
node = RobotStatusBridge(msg_queue, loop)
ros_thread = threading.Thread(target=spin_ros, args=(node,), daemon=True)
ros_thread.start()
try:
loop.run_until_complete(ws_sender(server_url, msg_queue))
except KeyboardInterrupt:
print("\n[INFO] 正在关闭...")
finally:
node.destroy_node()
rclpy.shutdown()
loop.close()
if __name__ == "__main__":
main()