Added tap to wake and cycle feed
This commit is contained in:
@@ -1,40 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Doorbell JPEG proxy: prefetch from Frigate and serve a baseline 480px-wide JPEG."""
|
||||
"""JPEG proxy: prefetch from Frigate per camera/size and serve cover-cropped JPEGs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import requests
|
||||
from flask import Flask, request
|
||||
from flask import Flask, abort, request
|
||||
from PIL import Image
|
||||
|
||||
FRIGATE_URL = os.environ.get(
|
||||
"FRIGATE_URL", "http://10.0.1.197:5050/api/doorbell/latest.jpg?h=270"
|
||||
)
|
||||
TARGET_WIDTH = int(os.environ.get("TARGET_WIDTH", "480"))
|
||||
JPEG_QUALITY = int(os.environ.get("JPEG_QUALITY", "40"))
|
||||
FETCH_TIMEOUT = float(os.environ.get("FETCH_TIMEOUT", "5"))
|
||||
PREFETCH_HZ = float(os.environ.get("PREFETCH_HZ", "10"))
|
||||
DEFAULT_SIZE = int(os.environ.get("DEFAULT_SIZE", "480"))
|
||||
MAX_SIZE = int(os.environ.get("MAX_SIZE", "720"))
|
||||
MIN_SIZE = int(os.environ.get("MIN_SIZE", "64"))
|
||||
MAX_ZOOM = float(os.environ.get("MAX_ZOOM", "3.0"))
|
||||
MIN_ZOOM = float(os.environ.get("MIN_ZOOM", "1.0"))
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_jpeg = b""
|
||||
_etag = ""
|
||||
_width = 0
|
||||
_height = 0
|
||||
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
||||
|
||||
|
||||
def _encode(frame: Image.Image) -> tuple[bytes, int, int]:
|
||||
frame = frame.convert("RGB")
|
||||
if frame.width != TARGET_WIDTH:
|
||||
height = max(1, round(frame.height * TARGET_WIDTH / frame.width))
|
||||
frame = frame.resize((TARGET_WIDTH, height), Image.BILINEAR)
|
||||
@dataclass(frozen=True)
|
||||
class CameraSource:
|
||||
name: str
|
||||
url: str
|
||||
zoom: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamConfig:
|
||||
source: CameraSource
|
||||
width: int
|
||||
height: int
|
||||
zoom: float
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[str, int, int, float]:
|
||||
return (self.source.name, self.width, self.height, round(self.zoom, 3))
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
if self.zoom != 1.0:
|
||||
return f"{self.source.name}:{self.width}x{self.height}@{self.zoom:g}x"
|
||||
return f"{self.source.name}:{self.width}x{self.height}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraStream:
|
||||
config: StreamConfig
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
jpeg: bytes = b""
|
||||
etag: str = ""
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
|
||||
def snapshot(self) -> tuple[bytes, str, int, int]:
|
||||
with self.lock:
|
||||
return self.jpeg, self.etag, self.width, self.height
|
||||
|
||||
def store(self, body: bytes, etag: str, width: int, height: int) -> None:
|
||||
with self.lock:
|
||||
self.jpeg = body
|
||||
self.etag = etag
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
|
||||
class StreamRegistry:
|
||||
def __init__(self, sources: dict[str, CameraSource]) -> None:
|
||||
self._sources = sources
|
||||
self._streams: dict[tuple[str, int, int, float], CameraStream] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._prefetch_started: set[tuple[str, int, int, float]] = set()
|
||||
|
||||
@staticmethod
|
||||
def _clamp(value: int) -> int:
|
||||
return max(MIN_SIZE, min(MAX_SIZE, value))
|
||||
|
||||
@staticmethod
|
||||
def _clamp_zoom(value: float) -> float:
|
||||
return max(MIN_ZOOM, min(MAX_ZOOM, value))
|
||||
|
||||
@classmethod
|
||||
def parse_zoom(cls, args, source: CameraSource) -> float:
|
||||
raw = args.get("zoom", args.get("z"))
|
||||
if raw is None:
|
||||
return cls._clamp_zoom(source.zoom)
|
||||
return cls._clamp_zoom(float(raw))
|
||||
|
||||
@classmethod
|
||||
def parse_dimensions(cls, args) -> tuple[int, int]:
|
||||
raw_w = args.get("w", args.get("width"))
|
||||
raw_h = args.get("h", args.get("height"))
|
||||
raw_size = args.get("size")
|
||||
|
||||
if raw_size is not None:
|
||||
size = cls._clamp(int(raw_size))
|
||||
return size, size
|
||||
|
||||
if raw_w is None and raw_h is None:
|
||||
size = cls._clamp(DEFAULT_SIZE)
|
||||
return size, size
|
||||
|
||||
width = cls._clamp(int(raw_w if raw_w is not None else raw_h))
|
||||
height = cls._clamp(int(raw_h if raw_h is not None else raw_w))
|
||||
return width, height
|
||||
|
||||
def get(self, camera: str, width: int, height: int, zoom: float) -> CameraStream:
|
||||
if camera not in self._sources:
|
||||
abort(404)
|
||||
key = (camera, width, height, round(zoom, 3))
|
||||
with self._lock:
|
||||
stream = self._streams.get(key)
|
||||
if stream is None:
|
||||
cfg = StreamConfig(
|
||||
source=self._sources[camera],
|
||||
width=width,
|
||||
height=height,
|
||||
zoom=zoom,
|
||||
)
|
||||
stream = CameraStream(config=cfg)
|
||||
self._streams[key] = stream
|
||||
if key not in self._prefetch_started:
|
||||
self._prefetch_started.add(key)
|
||||
threading.Thread(
|
||||
target=_prefetch_loop,
|
||||
args=(stream,),
|
||||
name=f"prefetch-{camera}-{width}x{height}-z{zoom:g}",
|
||||
daemon=True,
|
||||
).start()
|
||||
return stream
|
||||
|
||||
def prefetch_all(self, sizes: list[int]) -> None:
|
||||
for source in self._sources.values():
|
||||
for size in sizes:
|
||||
self.get(source.name, size, size, self._clamp_zoom(source.zoom))
|
||||
|
||||
def all_streams(self) -> list[CameraStream]:
|
||||
with self._lock:
|
||||
return list(self._streams.values())
|
||||
|
||||
|
||||
def _cover_crop(
|
||||
frame: Image.Image, target_w: int, target_h: int, zoom: float = 1.0
|
||||
) -> Image.Image:
|
||||
scale = max(target_w / frame.width, target_h / frame.height) * zoom
|
||||
new_w = max(1, round(frame.width * scale))
|
||||
new_h = max(1, round(frame.height * scale))
|
||||
if (new_w, new_h) != frame.size:
|
||||
frame = frame.resize((new_w, new_h), Image.BILINEAR)
|
||||
left = max(0, (new_w - target_w) // 2)
|
||||
top = max(0, (new_h - target_h) // 2)
|
||||
return frame.crop((left, top, left + target_w, top + target_h))
|
||||
|
||||
|
||||
def _encode(
|
||||
frame: Image.Image, target_w: int, target_h: int, zoom: float = 1.0
|
||||
) -> tuple[bytes, int, int]:
|
||||
frame = _cover_crop(frame.convert("RGB"), target_w, target_h, zoom)
|
||||
buf = io.BytesIO()
|
||||
frame.save(
|
||||
buf,
|
||||
@@ -47,39 +179,76 @@ def _encode(frame: Image.Image) -> tuple[bytes, int, int]:
|
||||
return buf.getvalue(), frame.width, frame.height
|
||||
|
||||
|
||||
def _prefetch_loop() -> None:
|
||||
def _frigate_url(base: str, width: int, height: int, zoom: float = 1.0) -> str:
|
||||
fetch_h = round(max(width, height) * zoom)
|
||||
parsed = urlparse(base)
|
||||
query = parse_qs(parsed.query)
|
||||
query["h"] = [str(fetch_h)]
|
||||
return urlunparse(parsed._replace(query=urlencode(query, doseq=True)))
|
||||
|
||||
|
||||
def _parse_sources() -> dict[str, CameraSource]:
|
||||
raw = os.environ.get("CAMERAS", "").strip()
|
||||
if raw:
|
||||
items = json.loads(raw)
|
||||
if not isinstance(items, list) or not items:
|
||||
raise ValueError("CAMERAS must be a non-empty JSON array")
|
||||
sources: dict[str, CameraSource] = {}
|
||||
for item in items:
|
||||
name = str(item["name"]).lower()
|
||||
if not _NAME_RE.match(name):
|
||||
raise ValueError(f"invalid camera name: {name!r}")
|
||||
if name in sources:
|
||||
raise ValueError(f"duplicate camera name: {name!r}")
|
||||
url = str(item["url"])
|
||||
zoom = float(item.get("zoom", 1.0))
|
||||
sources[name] = CameraSource(name=name, url=url, zoom=zoom)
|
||||
return sources
|
||||
|
||||
url = os.environ.get(
|
||||
"FRIGATE_URL", "http://10.0.1.197:5050/api/doorbell/latest.jpg"
|
||||
)
|
||||
name = os.environ.get("CAMERA_NAME", "doorbell").lower()
|
||||
return {name: CameraSource(name=name, url=url)}
|
||||
|
||||
|
||||
def _parse_prefetch_sizes() -> list[int]:
|
||||
raw = os.environ.get("PREFETCH_SIZES", "480,720").strip()
|
||||
if not raw:
|
||||
return []
|
||||
sizes: list[int] = []
|
||||
for part in raw.split(","):
|
||||
part = part.strip()
|
||||
if part:
|
||||
sizes.append(StreamRegistry._clamp(int(part)))
|
||||
return sizes
|
||||
|
||||
|
||||
def _prefetch_loop(stream: CameraStream) -> None:
|
||||
cfg = stream.config
|
||||
session = requests.Session()
|
||||
interval = 1.0 / max(PREFETCH_HZ, 0.5)
|
||||
while True:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
response = session.get(FRIGATE_URL, timeout=FETCH_TIMEOUT)
|
||||
source_url = _frigate_url(cfg.source.url, cfg.width, cfg.height, cfg.zoom)
|
||||
response = session.get(source_url, timeout=FETCH_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
with Image.open(io.BytesIO(response.content)) as image:
|
||||
encoded, width, height = _encode(image)
|
||||
encoded, width, height = _encode(
|
||||
image, cfg.width, cfg.height, cfg.zoom
|
||||
)
|
||||
etag = '"' + hashlib.md5(encoded).hexdigest() + '"'
|
||||
with _lock:
|
||||
global _jpeg, _etag, _width, _height
|
||||
_jpeg = encoded
|
||||
_etag = etag
|
||||
_width = width
|
||||
_height = height
|
||||
stream.store(encoded, etag, width, height)
|
||||
except Exception as exc: # noqa: BLE001 — keep last good frame
|
||||
print(f"prefetch failed: {exc}", flush=True)
|
||||
print(f"[{cfg.label}] prefetch failed: {exc}", flush=True)
|
||||
delay = interval - (time.monotonic() - started)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
|
||||
|
||||
def _snapshot() -> tuple[bytes, str, int, int]:
|
||||
with _lock:
|
||||
return _jpeg, _etag, _width, _height
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@app.get("/latest.jpg")
|
||||
def latest():
|
||||
body, etag, width, height = _snapshot()
|
||||
def _jpeg_response(stream: CameraStream):
|
||||
body, etag, width, height = stream.snapshot()
|
||||
if not body:
|
||||
return "Image not ready", 503, {"Content-Type": "text/plain"}
|
||||
|
||||
@@ -96,18 +265,61 @@ def latest():
|
||||
"Last-Modified": time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()),
|
||||
"Cache-Control": "no-cache",
|
||||
"Accept-Ranges": "none",
|
||||
"X-Camera": stream.config.source.name,
|
||||
"X-Image-Size": f"{width}x{height}",
|
||||
"X-Zoom": f"{stream.config.zoom:g}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
body, _, width, height = _snapshot()
|
||||
status = 200 if body else 503
|
||||
return {"ready": bool(body), "bytes": len(body), "size": f"{width}x{height}"}, status
|
||||
def create_app() -> Flask:
|
||||
sources = _parse_sources()
|
||||
registry = StreamRegistry(sources)
|
||||
primary = next(iter(sources))
|
||||
registry.prefetch_all(_parse_prefetch_sizes())
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
def _serve(camera: str):
|
||||
source = registry._sources[camera]
|
||||
width, height = registry.parse_dimensions(request.args)
|
||||
zoom = registry.parse_zoom(request.args, source)
|
||||
return _jpeg_response(registry.get(camera, width, height, zoom))
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
result = {}
|
||||
for stream in registry.all_streams():
|
||||
body, _, width, height = stream.snapshot()
|
||||
label = stream.config.label
|
||||
result[label] = {
|
||||
"ready": bool(body),
|
||||
"bytes": len(body),
|
||||
"size": f"{width}x{height}",
|
||||
"target": f"{stream.config.width}x{stream.config.height}",
|
||||
"zoom": stream.config.zoom,
|
||||
"source": stream.config.source.url,
|
||||
}
|
||||
return {"streams": result}, 200 if any(s["ready"] for s in result.values()) else 503
|
||||
|
||||
@app.get("/")
|
||||
@app.get("/latest.jpg")
|
||||
def latest_primary():
|
||||
return _serve(primary)
|
||||
|
||||
for name in sources:
|
||||
handler = (lambda camera=name: lambda: _serve(camera))()
|
||||
app.add_url_rule(f"/{name}/", endpoint=f"camera-{name}", view_func=handler)
|
||||
app.add_url_rule(
|
||||
f"/{name}/latest.jpg",
|
||||
endpoint=f"camera-{name}-jpg",
|
||||
view_func=handler,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target=_prefetch_loop, name="prefetch", daemon=True).start()
|
||||
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "80")), threaded=True)
|
||||
|
||||
Reference in New Issue
Block a user