d4eebf79f6
- camera/capture.py: V4L2 mmap-based MJPEG frame capture with background thread - camera/mjpeg.py: MJPEG streaming server (multipart/x-mixed-replace) + minimal web UI - camera/app.py: Entry point with argument parsing and signal handling - pyproject.toml: uv project config (no external dependencies) - camera-webui.service: systemd unit for native Pi deployment - README.md: Updated for systemd-only direction with install instructions
131 lines
4.1 KiB
Python
131 lines
4.1 KiB
Python
"""MJPEG streaming handler for HTTP multipart/x-mixed-replace.
|
|
|
|
Each client gets its own handler thread. Frames are pulled from the shared
|
|
camera capture singleton — no queuing, just the latest frame.
|
|
"""
|
|
|
|
import io
|
|
import threading
|
|
import time
|
|
import socketserver
|
|
from http import server
|
|
|
|
BOUNDARY = b"--myboundary"
|
|
|
|
# HTML template for the web UI
|
|
INDEX_HTML = (
|
|
"<!DOCTYPE html>"
|
|
"<html lang='en'>"
|
|
"<head>"
|
|
"<meta charset='utf-8'>"
|
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
"<title>Camera Feed</title>"
|
|
"<style>"
|
|
"* { margin: 0; padding: 0; box-sizing: border-box; }"
|
|
"html, body { width: 100%; height: 100%; background: #111; overflow: hidden; }"
|
|
"#feed { display: block; width: 100vw; height: 100vh; object-fit: contain; }"
|
|
"#status { position: fixed; top: 12px; left: 12px; z-index: 10;"
|
|
" color: #ccc; font: 13px/1 system-ui, sans-serif; display: flex;"
|
|
" align-items: center; gap: 6px; opacity: 1; transition: opacity 0.3s; }"
|
|
"#status.hidden { opacity: 0; }"
|
|
"#spinner { width: 12px; height: 12px; border: 2px solid #555;"
|
|
" border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite;"
|
|
" flex-shrink: 0; }"
|
|
"@keyframes spin { to { transform: rotate(360deg); } }"
|
|
"</style>"
|
|
"</head>"
|
|
"<body>"
|
|
"<div id='status'><div id='spinner'></div><span id='label'>Connecting...</span></div>"
|
|
"<img id='feed' src='/stream' onerror='document.getElementById(\"label\").textContent=\"Stream error\"' />"
|
|
"</body>"
|
|
"</html>"
|
|
)
|
|
|
|
|
|
class MJPEGHandler(server.BaseHTTPRequestHandler):
|
|
"""Serve a single MJPEG stream to one HTTP client."""
|
|
|
|
server_version = "CameraWebUI/0.1"
|
|
|
|
def log_message(self, format, *args): # noqa: A002
|
|
"""Suppress per-request logging to reduce noise."""
|
|
pass
|
|
|
|
def do_GET(self):
|
|
if self.path == "/" or self.path == "":
|
|
self._serve_index()
|
|
elif self.path == "/stream":
|
|
self._serve_stream()
|
|
else:
|
|
self.send_error(404)
|
|
|
|
def _serve_index(self):
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.end_headers()
|
|
self.wfile.write(INDEX_HTML.encode("utf-8"))
|
|
|
|
def _serve_stream(self):
|
|
self.send_response(200)
|
|
self.send_header(
|
|
"Content-Type", "multipart/x-mixed-replace; boundary=%s" % BOUNDARY.decode()
|
|
)
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.send_header("Connection", "close")
|
|
self.end_headers()
|
|
|
|
from camera.capture import get_camera
|
|
|
|
cam = get_camera()
|
|
try:
|
|
last_frame = None
|
|
while True:
|
|
frame = cam.frame()
|
|
if frame is not None and frame is not last_frame:
|
|
self._write_frame(frame)
|
|
last_frame = frame
|
|
else:
|
|
time.sleep(0.05)
|
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
pass
|
|
|
|
def _write_frame(self, frame: bytes) -> None:
|
|
"""Write one MJPEG frame with boundary and headers."""
|
|
w = self.wfile
|
|
w.write(BOUNDARY)
|
|
w.write(b"\r\n")
|
|
w.write(b"Content-Type: image/jpeg\r\n")
|
|
w.write(b"Content-Length: %d\r\n\r\n" % len(frame))
|
|
w.write(frame)
|
|
w.write(b"\r\n")
|
|
w.flush()
|
|
|
|
|
|
class ThreadedServer(socketserver.ThreadingMixIn, server.HTTPServer):
|
|
"""HTTP server that handles each request in a new thread."""
|
|
|
|
daemon_threads = True
|
|
allow_reuse_address = True
|
|
|
|
|
|
def run(host: str = "0.0.0.0", port: int = 8080):
|
|
"""Start the HTTP server and block."""
|
|
from camera.capture import get_camera
|
|
|
|
cam = get_camera()
|
|
try:
|
|
cam.start()
|
|
except FileNotFoundError:
|
|
print(f"Error: {cam._device} not found — is the camera connected?")
|
|
raise SystemExit(1)
|
|
|
|
server = ThreadedServer((host, port), MJPEGHandler)
|
|
print(f"Server running on http://{host}:{port}")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
cam.stop()
|
|
server.server_close()
|