Keep the colleague's microbu-esp32c5 tree in this repository
obu-firmware builds against vanetza-idf from microbu-esp32c5/external, but that tree was gitignored, so a clone of this repository could not build the firmware it ships. It is now committed here as ordinary files in its own folder, microbu-esp32c5/: the colleague's commit cf4b99f plus the V2X2MAP bridge's signature verification (--trust) used on the bench. Nothing is fetched from or pushed to the colleague's repository; this repository and its remotes carry everything. The folder's own .gitignore keeps build output, downloaded components and private key material out, as it did there; the committed file set is identical to that repository's tracked files. The ESP32-C5 is still flashed from obu-firmware/, which only takes vanetza-idf from microbu-esp32c5/, so the two stay separate folders. FLASHING.md says how to take a newer version of the colleague's tree (copy it over the folder, rebuild, test, commit).
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Local HTTP+SSE dashboard server for the ITS-G5 bridge (V2X2MAP).
|
||||
|
||||
Routes:
|
||||
GET / → dashboard.html
|
||||
GET /events → SSE stream
|
||||
GET /api/mqtt → MQTT toggle state
|
||||
GET /api/stats → last stats snapshot
|
||||
GET /api/record → PCAP recording state
|
||||
GET /api/brokers → configured broker list + status
|
||||
POST /api/mqtt → toggle MQTT publishing {"enabled": bool}
|
||||
POST /api/record → start/stop recording {"action": "start"|"stop", "file": "optional.pcap"}
|
||||
|
||||
Pure stdlib: http.server + threading + queue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
_HERE = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
|
||||
_HTML_PATH = os.path.join(_HERE, "dashboard.html")
|
||||
|
||||
_HISTORY_MAX = 200
|
||||
|
||||
_subs_lock = threading.Lock()
|
||||
_subscribers: list[queue.Queue] = []
|
||||
_history: collections.deque[bytes] = collections.deque(maxlen=_HISTORY_MAX)
|
||||
_node_id = "?"
|
||||
|
||||
# MQTT state
|
||||
_mqtt_enabled = False # off by default — user activates from dashboard
|
||||
_mqtt_available = False
|
||||
|
||||
# Broker status {url: {"host": h, "port": p, "connected": bool}}
|
||||
_broker_status: dict[str, dict] = {}
|
||||
|
||||
# Serial state
|
||||
_serial_connected = False
|
||||
_serial_port = ""
|
||||
|
||||
# Stats
|
||||
_last_stats: dict = {}
|
||||
|
||||
# PCAP recording state (updated by bridge via set_record_callback)
|
||||
_record_state: dict = {"recording": False, "file": "", "frames": 0}
|
||||
_record_callback = None # callable(action: str, filename: str) → dict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API used by the bridge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_mqtt_enabled() -> bool:
|
||||
return _mqtt_enabled and _mqtt_available
|
||||
|
||||
|
||||
def get_mqtt_state() -> dict:
|
||||
return {"enabled": _mqtt_enabled, "available": _mqtt_available}
|
||||
|
||||
|
||||
def set_mqtt_state(enabled: bool | None = None, available: bool | None = None) -> dict:
|
||||
global _mqtt_enabled, _mqtt_available
|
||||
if available is not None:
|
||||
_mqtt_available = bool(available)
|
||||
if enabled is not None:
|
||||
_mqtt_enabled = bool(enabled)
|
||||
_broadcast(_format_event("mqtt_state", get_mqtt_state()), persist=False)
|
||||
return get_mqtt_state()
|
||||
|
||||
|
||||
def register_brokers(urls: list[str]) -> None:
|
||||
"""Call once at startup with the configured broker URLs."""
|
||||
global _broker_status
|
||||
_broker_status = {}
|
||||
for url in urls:
|
||||
_broker_status[url] = {"url": url, "connected": False}
|
||||
|
||||
|
||||
def update_broker_status(host: str, port: int, connected: bool) -> None:
|
||||
for entry in _broker_status.values():
|
||||
if host in entry["url"] and str(port) in entry["url"]:
|
||||
entry["connected"] = connected
|
||||
_broadcast(_format_event("broker_status", list(_broker_status.values())), persist=False)
|
||||
|
||||
|
||||
def broadcast_frame(frame: dict) -> None:
|
||||
_broadcast(_format_event("frame", frame), persist=True)
|
||||
|
||||
|
||||
def broadcast_stats(stats: dict) -> None:
|
||||
global _last_stats
|
||||
_last_stats = stats
|
||||
_broadcast(_format_event("stats", stats), persist=False)
|
||||
|
||||
|
||||
def broadcast_serial_state(connected: bool, port: str = "") -> None:
|
||||
global _serial_connected, _serial_port
|
||||
_serial_connected = connected
|
||||
_serial_port = port
|
||||
_broadcast(_format_event("serial_state", {"connected": connected, "port": port}),
|
||||
persist=False)
|
||||
|
||||
|
||||
def set_record_callback(fn) -> None:
|
||||
global _record_callback
|
||||
_record_callback = fn
|
||||
|
||||
|
||||
def update_record_state(state: dict) -> None:
|
||||
global _record_state
|
||||
_record_state = state
|
||||
_broadcast(_format_event("record_state", state), persist=False)
|
||||
|
||||
|
||||
def get_record_state() -> dict:
|
||||
return _record_state
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_event(name: str, data) -> bytes:
|
||||
payload = json.dumps(data, separators=(",", ":"))
|
||||
return f"event: {name}\ndata: {payload}\n\n".encode("utf-8")
|
||||
|
||||
|
||||
def _broadcast(msg: bytes, persist: bool) -> None:
|
||||
with _subs_lock:
|
||||
if persist:
|
||||
_history.append(msg)
|
||||
dead = []
|
||||
for q in _subscribers:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except queue.Full:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
_subscribers.remove(q)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
logging.debug("dashboard %s - %s", self.address_string(), fmt % args)
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
if self.path in ("/", "/index.html"):
|
||||
self._serve_html()
|
||||
elif self.path == "/events":
|
||||
self._serve_sse()
|
||||
elif self.path == "/api/mqtt":
|
||||
self._serve_json(get_mqtt_state())
|
||||
elif self.path == "/api/stats":
|
||||
self._serve_json(_last_stats)
|
||||
elif self.path == "/api/record":
|
||||
self._serve_json(_record_state)
|
||||
elif self.path == "/api/record/download":
|
||||
fn = _record_state.get("file")
|
||||
if fn and os.path.isfile(fn):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/vnd.tcpdump.pcap")
|
||||
self.send_header("Content-Disposition", f'attachment; filename="{os.path.basename(fn)}"')
|
||||
self.send_header("Content-Length", str(os.path.getsize(fn)))
|
||||
self.end_headers()
|
||||
with open(fn, "rb") as f:
|
||||
self.wfile.write(f.read())
|
||||
else:
|
||||
self.send_error(404, "no recording available")
|
||||
elif self.path == "/api/brokers":
|
||||
self._serve_json(list(_broker_status.values()))
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(length) if length > 0 else b"{}"
|
||||
try:
|
||||
body = json.loads(raw.decode("utf-8") or "{}")
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
self.send_error(400, "invalid JSON"); return
|
||||
|
||||
if self.path == "/api/mqtt":
|
||||
if "enabled" not in body:
|
||||
self.send_error(400, "missing 'enabled'"); return
|
||||
self._serve_json(set_mqtt_state(enabled=bool(body["enabled"])))
|
||||
|
||||
elif self.path == "/api/record":
|
||||
action = body.get("action", "")
|
||||
filename = body.get("file", "")
|
||||
if action not in ("start", "stop"):
|
||||
self.send_error(400, "action must be 'start' or 'stop'"); return
|
||||
if _record_callback:
|
||||
result = _record_callback(action, filename)
|
||||
self._serve_json(result)
|
||||
else:
|
||||
self.send_error(503, "recording not available")
|
||||
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def _serve_json(self, data):
|
||||
body = json.dumps(data).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _serve_html(self):
|
||||
try:
|
||||
with open(_HTML_PATH, "rb") as f:
|
||||
body = f.read()
|
||||
except OSError:
|
||||
self.send_error(500, "dashboard.html not found"); return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _serve_sse(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
self.send_header("X-Accel-Buffering", "no")
|
||||
self.end_headers()
|
||||
|
||||
q: queue.Queue = queue.Queue(maxsize=1000)
|
||||
with _subs_lock:
|
||||
backlog = list(_history)
|
||||
_subscribers.append(q)
|
||||
|
||||
try:
|
||||
self.wfile.write(_format_event("hello", {
|
||||
"node_id": _node_id,
|
||||
"mqtt": get_mqtt_state(),
|
||||
"serial": {"connected": _serial_connected, "port": _serial_port},
|
||||
"stats": _last_stats,
|
||||
"brokers": list(_broker_status.values()),
|
||||
"record": _record_state,
|
||||
}))
|
||||
for msg in backlog:
|
||||
self.wfile.write(msg)
|
||||
self.wfile.flush()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = q.get(timeout=15)
|
||||
except queue.Empty:
|
||||
self.wfile.write(b": ping\n\n")
|
||||
self.wfile.flush()
|
||||
continue
|
||||
self.wfile.write(msg)
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
|
||||
pass
|
||||
finally:
|
||||
with _subs_lock:
|
||||
if q in _subscribers:
|
||||
_subscribers.remove(q)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server startup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def start(port: int, node_id: str = "?") -> ThreadingHTTPServer:
|
||||
global _node_id
|
||||
_node_id = node_id
|
||||
server = ThreadingHTTPServer(("127.0.0.1", port), _Handler)
|
||||
t = threading.Thread(target=server.serve_forever, name="dashboard-http", daemon=True)
|
||||
t.start()
|
||||
logging.info("dashboard listening on http://127.0.0.1:%d", port)
|
||||
return server
|
||||
Reference in New Issue
Block a user