Added tap to wake and cycle feed

This commit is contained in:
2026-08-22 17:11:53 +02:00
parent 2b5004a569
commit 539df2182a
7 changed files with 610 additions and 52 deletions
+253 -41
View File
@@ -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}",
},
)
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():
body, _, width, height = _snapshot()
status = 200 if body else 503
return {"ready": bool(body), "bytes": len(body), "size": f"{width}x{height}"}, status
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)
+3 -3
View File
@@ -17,9 +17,9 @@ namespace esphome {
namespace fast_jpeg {
static const char *const TAG = "fast_jpeg";
static const int kMaxWidth = 480;
static const int kMaxHeight = 480;
static const size_t kJpegCap = 48 * 1024;
static const int kMaxWidth = 720;
static const int kMaxHeight = 720;
static const size_t kJpegCap = 128 * 1024;
static int align16_(int value) { return (value + 15) & ~15; }
+5 -1
View File
@@ -18,7 +18,11 @@ namespace fast_jpeg {
class FastJpeg : public PollingComponent {
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_x(int x) { this->x_ = x; }
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."
+14 -2
View File
@@ -1,4 +1,12 @@
# 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:
resizer:
image: doorbell-resizer:latest
@@ -6,11 +14,15 @@ services:
ports:
- "8080:80"
environment:
FRIGATE_URL: http://10.0.1.197:5050/api/doorbell/latest.jpg?h=270
TARGET_WIDTH: "480"
JPEG_QUALITY: "40"
FETCH_TIMEOUT: "5"
PREFETCH_HZ: "10"
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:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1/health')"]
interval: 30s
+206
View File
@@ -0,0 +1,206 @@
# Waveshare ESP32-P4-WIFI6-Touch-LCD-4B — doorbell JPEG viewer (DRAFT)
# Square 720x720 IPS. Bring this up when the board arrives; the S3 yaml stays as-is.
#
# Touch cycles: stream A (default) -> stream B -> off -> stream A ...
#
# Notes:
# - ESPHome has a first-class 4C model (round 720x720), not 4B. Start with 4C;
# if the panel looks wrong, switch to CUSTOM + Waveshare ST7703 init.
# - Wi-Fi is via onboard ESP32-C6 (esp32_hosted). Use fast_connect.
# - fast_jpeg still works as software decode initially; HW JPEG is a follow-up.
substitutions:
name: doorbell-display-p4
friendly_name: Doorbell Display P4
stream_a_url: "http://10.0.1.195:8080/doorbell/?size=720"
stream_b_url: "http://10.0.1.195:8080/oprit/?size=720"
external_components:
- source:
type: local
path: components
esphome:
name: ${name}
friendly_name: ${friendly_name}
min_version: "2026.2.0"
on_boot:
- priority: 600
then:
- lambda: |-
id(touch_state) = 0;
id(doorbell_jpg).set_url("${stream_a_url}");
id(doorbell_jpg).set_enabled(true);
- switch.turn_on: backlight_enable
- light.turn_on:
id: backlight
brightness: 100%
esp32:
board: esp32-p4-evboard
variant: esp32p4
flash_size: 32MB
framework:
type: esp-idf
advanced:
enable_idf_experimental_features: true
include_builtin_idf_components:
- esp_http_client
psram:
speed: 200MHz
logger:
hardware_uart: UART0
logs:
fast_jpeg: INFO
api:
ota:
- platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
fast_connect: true
esp32_hosted:
variant: ESP32C6
active_high: true
reset_pin: GPIO54
cmd_pin: GPIO19
clk_pin: GPIO18
d0_pin: GPIO14
d1_pin: GPIO15
d2_pin: GPIO16
d3_pin: GPIO17
esp_ldo:
- channel: 3
voltage: 2.5V
i2c:
sda: GPIO7
scl: GPIO8
frequency: 400kHz
scan: true
globals:
- id: touch_state
type: int
restore_value: false
initial_value: "0"
script:
- id: cycle_stream
mode: restart
then:
- lambda: |-
id(touch_state) = (id(touch_state) + 1) % 3;
- if:
condition:
lambda: "return id(touch_state) == 0;"
then:
- lambda: |-
id(doorbell_jpg).set_url("${stream_a_url}");
id(doorbell_jpg).set_enabled(true);
- switch.turn_on: backlight_enable
- 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);
- switch.turn_on: backlight_enable
- light.turn_on:
id: backlight
brightness: 100%
- if:
condition:
lambda: "return id(touch_state) == 2;"
then:
- lambda: "id(doorbell_jpg).set_enabled(false);"
- light.turn_off: backlight
switch:
- platform: gpio
id: backlight_enable
name: "Backlight Enable"
pin: GPIO33
restore_mode: ALWAYS_ON
internal: true
- platform: template
name: "Screen"
id: screen_power
icon: mdi:monitor
lambda: |-
return id(backlight).current_values.is_on();
turn_on_action:
- lambda: |-
id(touch_state) = 0;
id(doorbell_jpg).set_url("${stream_a_url}");
id(doorbell_jpg).set_enabled(true);
- switch.turn_on: backlight_enable
- light.turn_on:
id: backlight
brightness: 100%
turn_off_action:
- lambda: |-
id(touch_state) = 2;
id(doorbell_jpg).set_enabled(false);
- light.turn_off: backlight
output:
- platform: ledc
id: backlight_pwm
pin: GPIO26
inverted: true
frequency: 1000Hz
light:
- platform: monochromatic
id: backlight
name: "Backlight"
output: backlight_pwm
restore_mode: ALWAYS_ON
default_transition_length: 0s
on_turn_on:
- switch.turn_on: backlight_enable
- lambda: |-
if (id(touch_state) != 2) {
id(doorbell_jpg).set_enabled(true);
}
on_turn_off:
- lambda: "id(doorbell_jpg).set_enabled(false);"
touchscreen:
- platform: gt911
id: touch
display: lcd
reset_pin: GPIO23
update_interval: 50ms
on_touch:
- script.execute: cycle_stream
display:
- platform: mipi_dsi
id: lcd
model: WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C
dimensions: 720x720
reset_pin: GPIO27
color_order: RGB
auto_clear_enabled: false
update_interval: never
fast_jpeg:
id: doorbell_jpg
url: ${stream_a_url}
display_id: lcd
update_interval: 150ms
big_endian: false
+83 -3
View File
@@ -2,13 +2,16 @@
# Copy this file AND the components/ folder into your ESPHome config directory.
# 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).
# Stock online_image JPEG is too slow for ~5 fps.
substitutions:
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:
- source:
@@ -35,6 +38,15 @@ esphome:
- delay: 120ms
- lambda: |-
id(ext_io)->digital_write(7, true);
- priority: 600
then:
- lambda: |-
id(touch_state) = 0;
id(doorbell_jpg).set_url("${stream_a_url}");
id(doorbell_jpg).set_enabled(true);
- light.turn_on:
id: backlight
brightness: 100%
esp32:
board: esp32-s3-devkitc-1
@@ -79,6 +91,45 @@ pca9554:
address: 0x20
pin_count: 8
globals:
- id: touch_state
type: int
restore_value: false
initial_value: "0"
script:
- id: cycle_stream
mode: restart
then:
- lambda: |-
id(touch_state) = (id(touch_state) + 1) % 3;
- if:
condition:
lambda: "return id(touch_state) == 0;"
then:
- lambda: |-
id(doorbell_jpg).set_url("${stream_a_url}");
id(doorbell_jpg).set_enabled(true);
- 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:
- lambda: "id(doorbell_jpg).set_enabled(false);"
- light.turn_off: backlight
spi:
- id: disp_spi
interface: software
@@ -107,7 +158,10 @@ light:
restore_mode: ALWAYS_ON
default_transition_length: 0s
on_turn_on:
- lambda: id(doorbell_jpg).set_enabled(true);
- lambda: |-
if (id(touch_state) != 2) {
id(doorbell_jpg).set_enabled(true);
}
on_turn_off:
- lambda: id(doorbell_jpg).set_enabled(false);
@@ -120,12 +174,38 @@ switch:
lambda: |-
return id(backlight).current_values.is_on();
turn_on_action:
- lambda: |-
id(touch_state) = 0;
id(doorbell_jpg).set_url("${stream_a_url}");
id(doorbell_jpg).set_enabled(true);
- light.turn_on:
id: backlight
brightness: 100%
turn_off_action:
- lambda: |-
id(touch_state) = 2;
id(doorbell_jpg).set_enabled(false);
- 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:
- platform: mipi_rgb
id: lcd
@@ -199,7 +279,7 @@ display:
fast_jpeg:
id: doorbell_jpg
url: ${image_url}
url: ${stream_a_url}
display_id: lcd
update_interval: 200ms
# If colours look swapped, set big_endian: false