Files
xMonitor/main_monitor.py
2026-04-14 17:45:47 +08:00

306 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)