Compare commits

..
2 Commits
Author SHA1 Message Date
Arne 1c56217927 Added stop to the container if no device needs frames 2026-09-12 17:05:13 +02:00
Arne 539df2182a Added tap to wake and cycle feed 2026-08-22 17:11:53 +02:00
6 changed files with 445 additions and 54 deletions
+295 -42
View File
@@ -1,40 +1,194 @@
#!/usr/bin/env python3 #!/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.
Prefetch runs only while clients are requesting a stream; after IDLE_TIMEOUT
seconds with no hits it pauses until the next request.
"""
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import io import io
import json
import os import os
import re
import threading import threading
import time import time
from dataclasses import dataclass, field
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
import requests import requests
from flask import Flask, request from flask import Flask, abort, request
from PIL import Image 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")) JPEG_QUALITY = int(os.environ.get("JPEG_QUALITY", "40"))
FETCH_TIMEOUT = float(os.environ.get("FETCH_TIMEOUT", "5")) FETCH_TIMEOUT = float(os.environ.get("FETCH_TIMEOUT", "5"))
PREFETCH_HZ = float(os.environ.get("PREFETCH_HZ", "10")) PREFETCH_HZ = float(os.environ.get("PREFETCH_HZ", "10"))
# Stop prefetching a stream after this many seconds with no HTTP clients.
IDLE_TIMEOUT = float(os.environ.get("IDLE_TIMEOUT", "30"))
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__) _NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
_lock = threading.Lock()
_jpeg = b""
_etag = ""
_width = 0
_height = 0
def _encode(frame: Image.Image) -> tuple[bytes, int, int]: @dataclass(frozen=True)
frame = frame.convert("RGB") class CameraSource:
if frame.width != TARGET_WIDTH: name: str
height = max(1, round(frame.height * TARGET_WIDTH / frame.width)) url: str
frame = frame.resize((TARGET_WIDTH, height), Image.BILINEAR) 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)
wake: threading.Event = field(default_factory=threading.Event)
jpeg: bytes = b""
etag: str = ""
width: int = 0
height: int = 0
last_access: float = 0.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
def touch(self) -> None:
with self.lock:
self.last_access = time.monotonic()
self.wake.set()
def is_active(self, now: float | None = None) -> bool:
if IDLE_TIMEOUT <= 0:
return True
with self.lock:
last = self.last_access
if last <= 0:
return False
return ((now if now is not None else time.monotonic()) - last) < IDLE_TIMEOUT
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() buf = io.BytesIO()
frame.save( frame.save(
buf, buf,
@@ -47,39 +201,92 @@ def _encode(frame: Image.Image) -> tuple[bytes, int, int]:
return buf.getvalue(), frame.width, frame.height 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() session = requests.Session()
interval = 1.0 / max(PREFETCH_HZ, 0.5) interval = 1.0 / max(PREFETCH_HZ, 0.5)
idle = True
while True: while True:
if not stream.is_active():
if not idle:
print(f"[{cfg.label}] prefetch paused (idle)", flush=True)
idle = True
stream.wake.wait(timeout=1.0)
stream.wake.clear()
continue
if idle:
print(f"[{cfg.label}] prefetch active", flush=True)
idle = False
started = time.monotonic() started = time.monotonic()
try: 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() response.raise_for_status()
with Image.open(io.BytesIO(response.content)) as image: 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() + '"' etag = '"' + hashlib.md5(encoded).hexdigest() + '"'
with _lock: stream.store(encoded, etag, width, height)
global _jpeg, _etag, _width, _height
_jpeg = encoded
_etag = etag
_width = width
_height = height
except Exception as exc: # noqa: BLE001 — keep last good frame 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) delay = interval - (time.monotonic() - started)
if delay > 0: if delay > 0:
time.sleep(delay) # Wake early if a client touches the stream (or it goes idle).
stream.wake.wait(timeout=delay)
stream.wake.clear()
def _snapshot() -> tuple[bytes, str, int, int]: def _jpeg_response(stream: CameraStream):
with _lock: stream.touch()
return _jpeg, _etag, _width, _height body, etag, width, height = stream.snapshot()
@app.get("/")
@app.get("/latest.jpg")
def latest():
body, etag, width, height = _snapshot()
if not body: if not body:
return "Image not ready", 503, {"Content-Type": "text/plain"} return "Image not ready", 503, {"Content-Type": "text/plain"}
@@ -96,18 +303,64 @@ def latest():
"Last-Modified": time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()), "Last-Modified": time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()),
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Accept-Ranges": "none", "Accept-Ranges": "none",
"X-Camera": stream.config.source.name,
"X-Image-Size": f"{width}x{height}", "X-Image-Size": f"{width}x{height}",
"X-Zoom": f"{stream.config.zoom:g}",
}, },
) )
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") @app.get("/health")
def health(): def health():
body, _, width, height = _snapshot() now = time.monotonic()
status = 200 if body else 503 result = {}
return {"ready": bool(body), "bytes": len(body), "size": f"{width}x{height}"}, status for stream in registry.all_streams():
body, _, width, height = stream.snapshot()
label = stream.config.label
result[label] = {
"ready": bool(body),
"active": stream.is_active(now),
"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,
}
# Healthy once the process is up; streams may be idle with no clients.
return {"streams": result, "idle_timeout": IDLE_TIMEOUT}, 200
@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__": 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) app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "80")), threaded=True)
+3 -3
View File
@@ -17,9 +17,9 @@ namespace esphome {
namespace fast_jpeg { namespace fast_jpeg {
static const char *const TAG = "fast_jpeg"; static const char *const TAG = "fast_jpeg";
static const int kMaxWidth = 480; static const int kMaxWidth = 720;
static const int kMaxHeight = 480; static const int kMaxHeight = 720;
static const size_t kJpegCap = 48 * 1024; static const size_t kJpegCap = 128 * 1024;
static int align16_(int value) { return (value + 15) & ~15; } static int align16_(int value) { return (value + 15) & ~15; }
+5 -1
View File
@@ -18,7 +18,11 @@ namespace fast_jpeg {
class FastJpeg : public PollingComponent { class FastJpeg : public PollingComponent {
public: public:
void set_url(const std::string &url) { this->url_ = url; } void set_url(const std::string &url) {
this->url_ = url;
this->etag_[0] = '\0';
this->bars_cleared_ = false;
}
void set_display(display::Display *display) { this->display_ = display; } void set_display(display::Display *display) { this->display_ = display; }
void set_x(int x) { this->x_ = x; } void set_x(int x) { this->x_ = x; }
void set_y(int y) { this->y_ = y; } void set_y(int y) { this->y_ = y; }
Executable
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Build doorbell-resizer locally and load it on the Portainer host over SSH.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
IMAGE="${IMAGE:-doorbell-resizer:latest}"
HOST="${HOST:-10.0.1.195}"
USER="${USER_REMOTE:-arne}"
# Override with: SSH_PASSWORD='...' ./deploy.sh
PASSWORD="${SSH_PASSWORD:-}"
REMOTE_TAR="${REMOTE_TAR:-/tmp/doorbell-resizer.tar.gz}"
LOCAL_TAR="${LOCAL_TAR:-/tmp/doorbell-resizer.tar.gz}"
if [[ -z "${PASSWORD}" ]]; then
echo "Set SSH_PASSWORD (e.g. SSH_PASSWORD='yourpass' ./deploy.sh)" >&2
exit 1
fi
if ! command -v sshpass >/dev/null 2>&1; then
echo "sshpass is required. On macOS: brew install esolitos/ipa/sshpass" >&2
exit 1
fi
export SSHPASS="${PASSWORD}"
SSH_OPTS=(-o StrictHostKeyChecking=accept-new -o PreferredAuthentications=password -o PubkeyAuthentication=no)
cd "$ROOT"
echo "==> Building ${IMAGE}"
docker build -t "${IMAGE}" .
echo "==> Saving ${IMAGE} -> ${LOCAL_TAR}"
docker save "${IMAGE}" | gzip > "${LOCAL_TAR}"
echo "==> Copying to ${USER}@${HOST}:${REMOTE_TAR}"
sshpass -e scp "${SSH_OPTS[@]}" "${LOCAL_TAR}" "${USER}@${HOST}:${REMOTE_TAR}"
echo "==> Loading image on ${HOST}"
sshpass -e ssh "${SSH_OPTS[@]}" "${USER}@${HOST}" \
"gunzip -c '${REMOTE_TAR}' | docker load && rm -f '${REMOTE_TAR}'"
rm -f "${LOCAL_TAR}"
echo "==> Done. Image ${IMAGE} is on ${HOST}."
echo " In Portainer: update/recreate the stack (or restart the container) to pick up latest."
+15 -2
View File
@@ -1,4 +1,12 @@
# Portainer-friendly: no build step. Load/push the image first, then paste this stack. # Portainer-friendly: no build step. Load/push the image first, then paste this stack.
#
# Cameras are source-only; resolution is a query param (square by default):
# http://<host>:8080/doorbell/?size=480
# http://<host>:8080/oprit/?w=720
# http://<host>:8080/oprit/?size=480&zoom=1.35
#
# Per-camera "zoom" in CAMERAS also applies when ?zoom= is omitted.
# zoom > 1 fills portrait/wide feeds tighter (more crop, larger subject).
services: services:
resizer: resizer:
image: doorbell-resizer:latest image: doorbell-resizer:latest
@@ -6,11 +14,16 @@ services:
ports: ports:
- "8080:80" - "8080:80"
environment: environment:
FRIGATE_URL: http://10.0.1.197:5050/api/doorbell/latest.jpg?h=270
TARGET_WIDTH: "480"
JPEG_QUALITY: "40" JPEG_QUALITY: "40"
FETCH_TIMEOUT: "5" FETCH_TIMEOUT: "5"
PREFETCH_HZ: "10" PREFETCH_HZ: "10"
IDLE_TIMEOUT: "15"
PREFETCH_SIZES: "480,720"
CAMERAS: >-
[
{"name":"doorbell","url":"http://10.0.1.197:5050/api/doorbell/latest.jpg"},
{"name":"oprit","url":"http://10.0.1.197:5050/api/oprit/latest.jpg","zoom":1.35}
]
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1/health')"]
interval: 30s interval: 30s
+81 -4
View File
@@ -2,13 +2,16 @@
# Copy this file AND the components/ folder into your ESPHome config directory. # Copy this file AND the components/ folder into your ESPHome config directory.
# Pair with the resizer proxy in this repo. # Pair with the resizer proxy in this repo.
# #
# Touch cycles: stream A (default) -> stream B -> off -> stream A ...
#
# Uses a custom fast_jpeg component (RGB565 block decode + DMA blit). # Uses a custom fast_jpeg component (RGB565 block decode + DMA blit).
# Stock online_image JPEG is too slow for ~5 fps. # Stock online_image JPEG is too slow for ~5 fps.
substitutions: substitutions:
name: doorbell-display name: doorbell-display
friendly_name: Doorbell Display friendly_name: Doorbell Display
image_url: "http://10.0.1.195:8080/" stream_a_url: "http://10.0.1.195:8080/doorbell/?size=480"
stream_b_url: "http://10.0.1.195:8080/oprit/?size=480"
external_components: external_components:
- source: - source:
@@ -35,6 +38,12 @@ esphome:
- delay: 120ms - delay: 120ms
- lambda: |- - lambda: |-
id(ext_io)->digital_write(7, true); id(ext_io)->digital_write(7, true);
- priority: 600
then:
- script.execute: activate_stream_a
- light.turn_on:
id: backlight
brightness: 100%
esp32: esp32:
board: esp32-s3-devkitc-1 board: esp32-s3-devkitc-1
@@ -79,6 +88,55 @@ pca9554:
address: 0x20 address: 0x20
pin_count: 8 pin_count: 8
globals:
- id: touch_state
type: int
restore_value: false
initial_value: "0"
script:
- id: activate_stream_a
then:
- lambda: |-
id(touch_state) = 0;
id(doorbell_jpg).set_url("${stream_a_url}");
id(doorbell_jpg).set_enabled(true);
- id: deactivate_stream
then:
- lambda: |-
id(touch_state) = 2;
id(doorbell_jpg).set_enabled(false);
- id: cycle_stream
mode: restart
then:
- lambda: |-
id(touch_state) = (id(touch_state) + 1) % 3;
- if:
condition:
lambda: "return id(touch_state) == 0;"
then:
- script.execute: activate_stream_a
- light.turn_on:
id: backlight
brightness: 100%
- if:
condition:
lambda: "return id(touch_state) == 1;"
then:
- lambda: |-
id(doorbell_jpg).set_url("${stream_b_url}");
id(doorbell_jpg).set_enabled(true);
- light.turn_on:
id: backlight
brightness: 100%
- if:
condition:
lambda: "return id(touch_state) == 2;"
then:
- light.turn_off: backlight
spi: spi:
- id: disp_spi - id: disp_spi
interface: software interface: software
@@ -107,9 +165,9 @@ light:
restore_mode: ALWAYS_ON restore_mode: ALWAYS_ON
default_transition_length: 0s default_transition_length: 0s
on_turn_on: on_turn_on:
- lambda: id(doorbell_jpg).set_enabled(true); - script.execute: activate_stream_a
on_turn_off: on_turn_off:
- lambda: id(doorbell_jpg).set_enabled(false); - script.execute: deactivate_stream
# Simple on/off for Home Assistant automations (light still exposes brightness). # Simple on/off for Home Assistant automations (light still exposes brightness).
switch: switch:
@@ -126,6 +184,25 @@ switch:
turn_off_action: turn_off_action:
- light.turn_off: backlight - light.turn_off: backlight
touchscreen:
- platform: gt911
id: touch
display: lcd
update_interval: 50ms
reset_pin:
pca9554: ext_io
number: 5
mode:
output: true
# INT is on the IO expander; used for address selection, polled for touches.
interrupt_pin:
pca9554: ext_io
number: 6
mode:
input: true
on_touch:
- script.execute: cycle_stream
display: display:
- platform: mipi_rgb - platform: mipi_rgb
id: lcd id: lcd
@@ -199,7 +276,7 @@ display:
fast_jpeg: fast_jpeg:
id: doorbell_jpg id: doorbell_jpg
url: ${image_url} url: ${stream_a_url}
display_id: lcd display_id: lcd
update_interval: 200ms update_interval: 200ms
# If colours look swapped, set big_endian: false # If colours look swapped, set big_endian: false