292 lines
12 KiB
Python
292 lines
12 KiB
Python
"""Tiny stdlib MJPEG + control dashboard over HTTP.
|
|
|
|
Serves a live JPEG stream (the Go2 camera with GoWelcome's detection/state
|
|
overlay) plus an optional control dashboard -- ideal for headless operation on
|
|
the dog. Stdlib only; the caller supplies a ``jpeg_provider`` that returns
|
|
already-encoded JPEG bytes (so this module needs no cv2), and optionally a
|
|
``status_provider`` (-> dict) and ``control_handler`` (dict -> dict) to power
|
|
the dashboard panel + buttons.
|
|
|
|
Routes:
|
|
GET / -> dashboard page (or bare viewer if no controls)
|
|
GET /stream.mjpg -> multipart/x-mixed-replace MJPEG stream
|
|
GET /snapshot.jpg -> a single current JPEG frame
|
|
GET /status.json -> current status dict (if status_provider given)
|
|
POST /control -> apply a control command (if control_handler given)
|
|
GET /healthz -> "ok" (liveness)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Callable, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# A callable returning the current frame as encoded JPEG bytes, or None if no
|
|
# frame is available yet.
|
|
JpegProvider = Callable[[], Optional[bytes]]
|
|
StatusProvider = Callable[[], dict]
|
|
ControlHandler = Callable[[dict], dict]
|
|
|
|
_BOUNDARY = "gowelcomeframe"
|
|
|
|
|
|
def _index_html(title: str, controls: bool) -> bytes:
|
|
head = (
|
|
"<!doctype html><html><head><meta charset='utf-8'>"
|
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
f"<title>{title}</title>"
|
|
"<style>body{margin:0;background:#111;color:#eee;font-family:sans-serif}"
|
|
".wrap{max-width:900px;margin:0 auto;padding:8px;text-align:center}"
|
|
"img{max-width:100%;max-height:70vh;object-fit:contain;background:#000}"
|
|
"h3{margin:8px}button{font-size:15px;margin:3px;padding:8px 12px;border:0;"
|
|
"border-radius:6px;background:#2a2a2a;color:#eee;cursor:pointer}"
|
|
"button:hover{background:#3a3a3a}.estop{background:#a11}.estop:hover{background:#c22}"
|
|
"#status{font-family:monospace;font-size:13px;text-align:left;background:#1a1a1a;"
|
|
"padding:8px;border-radius:6px;margin-top:8px;white-space:pre-wrap}"
|
|
".row{margin:6px 0}</style></head><body><div class='wrap'>"
|
|
)
|
|
if not controls:
|
|
body = (
|
|
f"<h3>{title} — live camera</h3>"
|
|
"<img src='/stream.mjpg' alt='camera stream'/>"
|
|
)
|
|
else:
|
|
body = (
|
|
f"<h3>{title} — control dashboard</h3>"
|
|
"<img src='/stream.mjpg' alt='camera stream'/>"
|
|
"<div class='row'><b>Play:</b>"
|
|
"<button onclick=\"ctl({action:'play_mode',mode:'calm'})\">Calm</button>"
|
|
"<button onclick=\"ctl({action:'play_mode',mode:'moderate'})\">Moderate</button>"
|
|
"<button onclick=\"ctl({action:'play_mode',mode:'playful'})\">Playful</button>"
|
|
"</div>"
|
|
"<div class='row'>"
|
|
"<button onclick=\"ctl({action:'pause',paused:true})\">Pause</button>"
|
|
"<button onclick=\"ctl({action:'resume'})\">Resume</button>"
|
|
"<button onclick=\"ctl({action:'set_center'})\">Set fence centre here</button>"
|
|
"</div>"
|
|
"<div class='row'>"
|
|
"<button class='estop' onclick=\"ctl({action:'estop'})\">E-STOP</button>"
|
|
"<button onclick=\"ctl({action:'clear_estop'})\">Clear E-STOP</button>"
|
|
"</div>"
|
|
"<div id='status'>loading...</div>"
|
|
"<script>"
|
|
"function ctl(c){fetch('/control',{method:'POST',headers:{'Content-Type':"
|
|
"'application/json'},body:JSON.stringify(c)}).then(r=>r.json()).then(poll);}"
|
|
"function poll(){fetch('/status.json').then(r=>r.json()).then(s=>{"
|
|
"document.getElementById('status').textContent=JSON.stringify(s,null,2);})"
|
|
".catch(()=>{});}"
|
|
"setInterval(poll,1000);poll();"
|
|
"</script>"
|
|
)
|
|
return (head + body + "</div></body></html>").encode("utf-8")
|
|
|
|
|
|
class MjpegServer:
|
|
"""Serve a JPEG stream over HTTP on a background daemon thread.
|
|
|
|
Args:
|
|
jpeg_provider: Returns the current frame as JPEG bytes (or ``None``).
|
|
host: Bind address (``0.0.0.0`` to allow remote viewers).
|
|
port: TCP port.
|
|
fps: Maximum stream frame rate (paces the multipart writer).
|
|
title: Page/title shown in the browser.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
jpeg_provider: JpegProvider,
|
|
host: str = "0.0.0.0",
|
|
port: int = 8080,
|
|
fps: float = 10.0,
|
|
title: str = "GoWelcome",
|
|
status_provider: Optional[StatusProvider] = None,
|
|
control_handler: Optional[ControlHandler] = None,
|
|
) -> None:
|
|
self._provider = jpeg_provider
|
|
self._status_provider = status_provider
|
|
self._control_handler = control_handler
|
|
self._host = host
|
|
self._port = port
|
|
self._period = 1.0 / max(1e-3, fps)
|
|
self._title = title
|
|
self._httpd: Optional[ThreadingHTTPServer] = None
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._stop = threading.Event()
|
|
|
|
@property
|
|
def has_controls(self) -> bool:
|
|
return self._control_handler is not None or self._status_provider is not None
|
|
|
|
@property
|
|
def port(self) -> int:
|
|
"""The bound TCP port (resolved after :meth:`start`)."""
|
|
return self._port
|
|
|
|
# ------------------------------------------------------------------ #
|
|
def start(self) -> None:
|
|
"""Bind the socket and start serving on a daemon thread.
|
|
|
|
Raises:
|
|
OSError: if the port cannot be bound (e.g. already in use).
|
|
"""
|
|
server = self
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
# Per-connection socket timeout so a stalled/non-reading client
|
|
# cannot pin a handler thread forever inside wfile.write once the
|
|
# TCP send buffer fills: a timed-out write raises socket.timeout,
|
|
# caught by _send_stream's except -> the handler exits cleanly.
|
|
# (StreamRequestHandler.setup applies this via socket.settimeout.)
|
|
timeout = max(10.0, server._period * 4)
|
|
|
|
# Silence the default noisy stderr logging.
|
|
def log_message(self, fmt, *args): # noqa: N802, D401
|
|
logger.debug("web: " + fmt, *args)
|
|
|
|
def do_GET(self): # noqa: N802
|
|
if self.path in ("/", "/index.html"):
|
|
self._send_index()
|
|
elif self.path.startswith("/stream.mjpg"):
|
|
self._send_stream()
|
|
elif self.path.startswith("/snapshot.jpg"):
|
|
self._send_snapshot()
|
|
elif self.path.startswith("/status.json"):
|
|
self._send_json(server._status())
|
|
elif self.path.startswith("/healthz"):
|
|
self._send_text("ok")
|
|
else:
|
|
self.send_error(404)
|
|
|
|
def do_POST(self): # noqa: N802
|
|
if not self.path.startswith("/control"):
|
|
self.send_error(404)
|
|
return
|
|
try:
|
|
n = int(self.headers.get("Content-Length", 0) or 0)
|
|
if n > 64 * 1024: # control commands are tiny; reject floods
|
|
self.send_error(413, "body too large")
|
|
return
|
|
raw = self.rfile.read(n) if n > 0 else b"{}"
|
|
cmd = json.loads(raw.decode("utf-8") or "{}")
|
|
except Exception:
|
|
self.send_error(400, "bad JSON")
|
|
return
|
|
self._send_json(server._control(cmd))
|
|
|
|
def _send_index(self):
|
|
body = _index_html(server._title, server.has_controls)
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _send_text(self, text):
|
|
body = text.encode("utf-8")
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/plain")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _send_json(self, obj):
|
|
body = json.dumps(obj).encode("utf-8")
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _send_snapshot(self):
|
|
jpeg = server._safe_provide()
|
|
if jpeg is None:
|
|
self.send_error(503, "no frame yet")
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "image/jpeg")
|
|
self.send_header("Content-Length", str(len(jpeg)))
|
|
self.end_headers()
|
|
self.wfile.write(jpeg)
|
|
|
|
def _send_stream(self):
|
|
self.send_response(200)
|
|
self.send_header("Age", "0")
|
|
self.send_header("Cache-Control", "no-cache, private")
|
|
self.send_header("Pragma", "no-cache")
|
|
self.send_header(
|
|
"Content-Type",
|
|
f"multipart/x-mixed-replace; boundary={_BOUNDARY}",
|
|
)
|
|
self.end_headers()
|
|
try:
|
|
while not server._stop.is_set():
|
|
jpeg = server._safe_provide()
|
|
if jpeg:
|
|
self.wfile.write(f"--{_BOUNDARY}\r\n".encode())
|
|
self.send_header("Content-Type", "image/jpeg")
|
|
self.send_header("Content-Length", str(len(jpeg)))
|
|
self.end_headers()
|
|
self.wfile.write(jpeg)
|
|
self.wfile.write(b"\r\n")
|
|
server._stop.wait(server._period)
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
logger.debug("web: client disconnected from stream")
|
|
except Exception: # noqa: BLE001 - a viewer error must not crash us
|
|
logger.debug("web: stream handler error", exc_info=True)
|
|
|
|
self._httpd = ThreadingHTTPServer((self._host, self._port), Handler)
|
|
self._httpd.daemon_threads = True
|
|
# Reflect the actually-bound port (supports port=0 -> ephemeral).
|
|
self._port = self._httpd.server_address[1]
|
|
self._thread = threading.Thread(
|
|
target=self._httpd.serve_forever, name="MjpegServer", daemon=True
|
|
)
|
|
self._thread.start()
|
|
logger.info(
|
|
"MJPEG viewer at http://%s:%d/ (stream: /stream.mjpg)",
|
|
"<host-ip>" if self._host in ("0.0.0.0", "") else self._host,
|
|
self._port,
|
|
)
|
|
|
|
def _safe_provide(self) -> Optional[bytes]:
|
|
try:
|
|
return self._provider()
|
|
except Exception: # noqa: BLE001
|
|
logger.debug("web: jpeg_provider raised", exc_info=True)
|
|
return None
|
|
|
|
def _status(self) -> dict:
|
|
if self._status_provider is None:
|
|
return {}
|
|
try:
|
|
return self._status_provider()
|
|
except Exception: # noqa: BLE001
|
|
logger.debug("web: status_provider raised", exc_info=True)
|
|
return {"error": "status unavailable"}
|
|
|
|
def _control(self, cmd: dict) -> dict:
|
|
if self._control_handler is None:
|
|
return {"ok": False, "error": "controls disabled"}
|
|
try:
|
|
return self._control_handler(cmd) or {"ok": True}
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("web: control_handler error: %s", exc)
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
def stop(self) -> None:
|
|
"""Stop serving and release the socket. Idempotent."""
|
|
self._stop.set()
|
|
if self._httpd is not None:
|
|
try:
|
|
self._httpd.shutdown()
|
|
self._httpd.server_close()
|
|
except Exception: # noqa: BLE001
|
|
logger.debug("web: shutdown error", exc_info=True)
|
|
self._httpd = None
|
|
logger.info("MJPEG viewer stopped")
|