GoWelcome/gowelcome/web/stream.py

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} &mdash; live camera</h3>"
"<img src='/stream.mjpg' alt='camera stream'/>"
)
else:
body = (
f"<h3>{title} &mdash; 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")