306 lines
13 KiB
Python
306 lines
13 KiB
Python
# 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)
|