You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
300 lines
8.5 KiB
Python
300 lines
8.5 KiB
Python
import asyncio
|
|
import os
|
|
import time
|
|
from collections import defaultdict, deque
|
|
from typing import Any, Deque, Dict, List, Optional
|
|
|
|
from fastapi import FastAPI, Query, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import HTMLResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from common.runtime import load_root_env
|
|
|
|
load_root_env(__file__)
|
|
|
|
TELEMETRY_BIND_HOST = os.getenv('telemetry_bind_host', os.getenv('lochost', '0.0.0.0'))
|
|
TELEMETRY_BIND_PORT = int(os.getenv('telemetry_bind_port', os.getenv('telemetry_port', '5020')))
|
|
TELEMETRY_HISTORY_SEC = int(float(os.getenv('telemetry_history_sec', '900')))
|
|
TELEMETRY_MAX_POINTS_PER_FREQ = int(os.getenv('telemetry_max_points_per_freq', '5000'))
|
|
|
|
|
|
def _new_buffer() -> Deque[Dict[str, Any]]:
|
|
return deque(maxlen=TELEMETRY_MAX_POINTS_PER_FREQ)
|
|
|
|
|
|
app = FastAPI(title='DroneDetector Telemetry Server')
|
|
_buffers: Dict[str, Deque[Dict[str, Any]]] = defaultdict(_new_buffer)
|
|
_ws_clients: List[WebSocket] = []
|
|
_state_lock = asyncio.Lock()
|
|
|
|
|
|
class TelemetryPoint(BaseModel):
|
|
freq: str
|
|
ts: float = Field(default_factory=lambda: time.time())
|
|
dbfs_current: float
|
|
dbfs_threshold: Optional[float] = None
|
|
alarm: bool = False
|
|
channel_idx: int = 0
|
|
channels_total: int = 1
|
|
|
|
|
|
def _prune_freq_locked(freq: str, now_ts: float) -> None:
|
|
cutoff = now_ts - TELEMETRY_HISTORY_SEC
|
|
buf = _buffers[freq]
|
|
while buf and float(buf[0].get('ts', 0.0)) < cutoff:
|
|
buf.popleft()
|
|
|
|
|
|
def _copy_series_locked(seconds: int, freq: Optional[str] = None) -> Dict[str, List[Dict[str, Any]]]:
|
|
now_ts = time.time()
|
|
cutoff = now_ts - seconds
|
|
|
|
if freq is not None:
|
|
data = [point for point in _buffers.get(freq, []) if float(point.get('ts', 0.0)) >= cutoff]
|
|
return {freq: data}
|
|
|
|
series: Dict[str, List[Dict[str, Any]]] = {}
|
|
for key, buf in _buffers.items():
|
|
series[key] = [point for point in buf if float(point.get('ts', 0.0)) >= cutoff]
|
|
return series
|
|
|
|
|
|
async def _broadcast(message: Dict[str, Any]) -> None:
|
|
dead: List[WebSocket] = []
|
|
for ws in list(_ws_clients):
|
|
try:
|
|
await ws.send_json(message)
|
|
except Exception:
|
|
dead.append(ws)
|
|
|
|
if dead:
|
|
async with _state_lock:
|
|
for ws in dead:
|
|
if ws in _ws_clients:
|
|
_ws_clients.remove(ws)
|
|
|
|
|
|
@app.post('/telemetry')
|
|
async def ingest_telemetry(point: TelemetryPoint):
|
|
payload = point.model_dump()
|
|
freq = str(payload['freq'])
|
|
now_ts = time.time()
|
|
|
|
async with _state_lock:
|
|
_buffers[freq].append(payload)
|
|
_prune_freq_locked(freq, now_ts)
|
|
|
|
await _broadcast({'type': 'point', 'data': payload})
|
|
return {'ok': True}
|
|
|
|
|
|
@app.get('/telemetry/history')
|
|
async def telemetry_history(
|
|
freq: Optional[str] = Query(default=None),
|
|
seconds: int = Query(default=300, ge=10, le=86400),
|
|
):
|
|
seconds = min(seconds, TELEMETRY_HISTORY_SEC)
|
|
async with _state_lock:
|
|
series = _copy_series_locked(seconds=seconds, freq=freq)
|
|
return {'seconds': seconds, 'series': series}
|
|
|
|
|
|
@app.websocket('/telemetry/ws')
|
|
async def telemetry_ws(websocket: WebSocket):
|
|
await websocket.accept()
|
|
async with _state_lock:
|
|
_ws_clients.append(websocket)
|
|
snapshot = _copy_series_locked(seconds=min(300, TELEMETRY_HISTORY_SEC), freq=None)
|
|
|
|
await websocket.send_json({'type': 'snapshot', 'data': snapshot})
|
|
|
|
try:
|
|
while True:
|
|
# Keepalive channel from browser; content is ignored.
|
|
await websocket.receive_text()
|
|
except WebSocketDisconnect:
|
|
pass
|
|
finally:
|
|
async with _state_lock:
|
|
if websocket in _ws_clients:
|
|
_ws_clients.remove(websocket)
|
|
|
|
|
|
MONITOR_HTML = """
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset=\"utf-8\" />
|
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
|
<title>DroneDetector Telemetry</title>
|
|
<script src=\"https://cdn.plot.ly/plotly-2.35.2.min.js\"></script>
|
|
<style>
|
|
:root {
|
|
--bg: #f6f8fb;
|
|
--card: #ffffff;
|
|
--line: #d9dde5;
|
|
--text: #1c232e;
|
|
--green: #12b76a;
|
|
--red: #ef4444;
|
|
}
|
|
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, Segoe UI, sans-serif; }
|
|
.wrap { max-width: 1400px; margin: 0 auto; padding: 16px; }
|
|
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
|
.meta { font-size: 13px; color: #5b6574; }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 12px; }
|
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 8px 8px 2px; }
|
|
.title { font-size: 14px; font-weight: 600; margin: 6px 8px; }
|
|
.plot { height: 280px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class=\"wrap\">
|
|
<div class=\"head\">
|
|
<div>
|
|
<h2 style=\"margin:0;\">DroneDetector Telemetry Monitor</h2>
|
|
<div class=\"meta\">Green: dBFS current, Red: dynamic alarm threshold</div>
|
|
</div>
|
|
<div class=\"meta\" id=\"status\">connecting...</div>
|
|
</div>
|
|
<div class=\"grid\" id=\"plots\"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const windowSec = 300;
|
|
const state = {}; // freq -> points[]
|
|
|
|
function numericSortFreq(a, b) {
|
|
return Number(a) - Number(b);
|
|
}
|
|
|
|
function ensurePlot(freq) {
|
|
if (document.getElementById(`plot-${freq}`)) return;
|
|
const card = document.createElement('div');
|
|
card.className = 'card';
|
|
card.innerHTML = `<div class=\"title\">${freq} MHz</div><div class=\"plot\" id=\"plot-${freq}\"></div>`;
|
|
document.getElementById('plots').appendChild(card);
|
|
}
|
|
|
|
function trimPoints(freq) {
|
|
const arr = state[freq] || [];
|
|
const cutoff = Date.now() / 1000 - windowSec;
|
|
state[freq] = arr.filter(p => Number(p.ts) >= cutoff);
|
|
}
|
|
|
|
function render(freq) {
|
|
ensurePlot(freq);
|
|
trimPoints(freq);
|
|
const pts = state[freq] || [];
|
|
|
|
const x = pts.map(p => new Date(Number(p.ts) * 1000));
|
|
const y = pts.map(p => p.dbfs_current);
|
|
const thr = pts.map(p => p.dbfs_threshold);
|
|
const alarmPts = pts.filter(p => p.alarm === true);
|
|
|
|
const traces = [
|
|
{
|
|
x,
|
|
y,
|
|
mode: 'lines',
|
|
name: 'dBFS',
|
|
line: {color: '#12b76a', width: 2}
|
|
},
|
|
{
|
|
x,
|
|
y: thr,
|
|
mode: 'lines',
|
|
name: 'Threshold',
|
|
line: {color: '#ef4444', width: 2, dash: 'dash'}
|
|
},
|
|
{
|
|
x: alarmPts.map(p => new Date(Number(p.ts) * 1000)),
|
|
y: alarmPts.map(p => p.dbfs_current),
|
|
mode: 'markers',
|
|
name: 'Alarm',
|
|
marker: {color: '#ef4444', size: 6, symbol: 'circle'}
|
|
}
|
|
];
|
|
|
|
Plotly.react(`plot-${freq}`, traces, {
|
|
margin: {l: 40, r: 12, t: 12, b: 32},
|
|
showlegend: true,
|
|
legend: {orientation: 'h', y: 1.16},
|
|
xaxis: {title: 'time'},
|
|
yaxis: {title: 'dBFS'}
|
|
}, {displayModeBar: false, responsive: true});
|
|
}
|
|
|
|
function renderAll() {
|
|
const freqs = Object.keys(state).sort(numericSortFreq);
|
|
freqs.forEach(render);
|
|
}
|
|
|
|
async function loadInitial() {
|
|
const res = await fetch(`/telemetry/history?seconds=${windowSec}`);
|
|
const payload = await res.json();
|
|
const series = payload.series || {};
|
|
for (const [freq, points] of Object.entries(series)) {
|
|
state[freq] = points;
|
|
}
|
|
renderAll();
|
|
}
|
|
|
|
function connectWs() {
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
const ws = new WebSocket(`${proto}://${location.host}/telemetry/ws`);
|
|
|
|
ws.onopen = () => {
|
|
document.getElementById('status').textContent = 'ws connected';
|
|
setInterval(() => {
|
|
if (ws.readyState === 1) ws.send('ping');
|
|
}, 20000);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === 'snapshot' && msg.data) {
|
|
for (const [freq, points] of Object.entries(msg.data)) {
|
|
state[freq] = points;
|
|
}
|
|
renderAll();
|
|
return;
|
|
}
|
|
if (msg.type !== 'point') return;
|
|
const p = msg.data;
|
|
const freq = String(p.freq);
|
|
if (!state[freq]) state[freq] = [];
|
|
state[freq].push(p);
|
|
render(freq);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
document.getElementById('status').textContent = 'ws disconnected, retrying...';
|
|
setTimeout(connectWs, 1500);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
document.getElementById('status').textContent = 'ws error';
|
|
};
|
|
}
|
|
|
|
loadInitial().then(connectWs).catch((e) => {
|
|
document.getElementById('status').textContent = `init error: ${e}`;
|
|
connectWs();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
@app.get('/', response_class=HTMLResponse)
|
|
@app.get('/monitor', response_class=HTMLResponse)
|
|
async def monitor_page():
|
|
return HTMLResponse(content=MONITOR_HTML)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
import uvicorn
|
|
|
|
uvicorn.run(app, host=TELEMETRY_BIND_HOST, port=TELEMETRY_BIND_PORT)
|