first commit
This commit is contained in:
305
main_monitor.py
Normal file
305
main_monitor.py
Normal 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)
|
||||
Reference in New Issue
Block a user