#!/usr/bin/env python3 """Standalone live dashboard for chicken-counter pipeline. Serve from project root: PYTHONPATH=src python3 dashboard.py [--port 8080] """ from __future__ import annotations import argparse import json import os from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path from socketserver import ThreadingMixIn from urllib.parse import unquote, urlparse class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): daemon_threads = True DEFAULT_SHM_DIR = "/dev/shm" DEFAULT_PORT = 8080 DASHBOARD_HTML = r""" Chicken Counter — Live Dashboard
waiting for pipeline... ago  
live stream
""" class DashboardHandler(SimpleHTTPRequestHandler): shm_dir = DEFAULT_SHM_DIR poll_ms = 500 def log_message(self, format, *args): pass def do_GET(self): try: self._handle_request() except (BrokenPipeError, ConnectionResetError): pass def _handle_request(self): parsed = urlparse(self.path) path = unquote(parsed.path) if path == "/" or path == "/index.html": html = DASHBOARD_HTML.replace("%%POLL_MS%%", str(self.poll_ms)).replace("%%SHM_DIR%%", self.shm_dir) self._send_html(html) return if path == "/api/cameras": cameras = self._discover_cameras() self._send_json({"cameras": cameras}) return if path.startswith("/shm/"): rel = path[len("/shm/"):] parts = rel.split("/", 1) if len(parts) >= 1: parts[0] = f"chicken_counter_{parts[0]}" rel = "/".join(parts) shm_path = Path(self.shm_dir) / rel resolved = shm_path.resolve() if not str(resolved).startswith(str(Path(self.shm_dir).resolve())): self.send_error(403) return if not resolved.exists(): self.send_error(404) return ct = "image/jpeg" if resolved.suffix in (".jpg", ".jpeg") else "application/json" self.send_response(200) self.send_header("Content-Type", ct) self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") self.end_headers() self.wfile.write(resolved.read_bytes()) return self.send_error(404) def _discover_cameras(self): shm = Path(self.shm_dir) cameras = [] if shm.is_dir(): for entry in sorted(shm.iterdir()): if entry.is_dir() and entry.name.startswith("chicken_counter_"): cam_id = entry.name[len("chicken_counter_"):] cameras.append(cam_id) return cameras def _send_html(self, html: str): data = html.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _send_json(self, obj): data = json.dumps(obj).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def main(): parser = argparse.ArgumentParser(description="Chicken Counter live dashboard") parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"HTTP port (default: {DEFAULT_PORT})") parser.add_argument("--shm-dir", default=DEFAULT_SHM_DIR, help=f"Shared memory directory (default: {DEFAULT_SHM_DIR})") parser.add_argument("--poll-ms", type=int, default=500, help="Image poll interval in ms (default: 500)") args = parser.parse_args() DashboardHandler.shm_dir = args.shm_dir DashboardHandler.poll_ms = args.poll_ms server = ThreadingHTTPServer(("0.0.0.0", args.port), DashboardHandler) print(f"[dashboard] serving at http://0.0.0.0:{args.port}") print(f"[dashboard] shm_dir={args.shm_dir} poll={args.poll_ms}ms") try: server.serve_forever() except KeyboardInterrupt: print("\n[dashboard] stopped") server.server_close() if __name__ == "__main__": main()