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

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)