Added stop to the container if no device needs frames

This commit is contained in:
2026-09-12 17:05:13 +02:00
parent 539df2182a
commit 1c56217927
4 changed files with 62 additions and 229 deletions
+44 -3
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""JPEG proxy: prefetch from Frigate per camera/size and serve cover-cropped JPEGs.""" """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
@@ -20,6 +24,8 @@ from PIL import Image
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")) DEFAULT_SIZE = int(os.environ.get("DEFAULT_SIZE", "480"))
MAX_SIZE = int(os.environ.get("MAX_SIZE", "720")) MAX_SIZE = int(os.environ.get("MAX_SIZE", "720"))
MIN_SIZE = int(os.environ.get("MIN_SIZE", "64")) MIN_SIZE = int(os.environ.get("MIN_SIZE", "64"))
@@ -58,10 +64,12 @@ class StreamConfig:
class CameraStream: class CameraStream:
config: StreamConfig config: StreamConfig
lock: threading.Lock = field(default_factory=threading.Lock) lock: threading.Lock = field(default_factory=threading.Lock)
wake: threading.Event = field(default_factory=threading.Event)
jpeg: bytes = b"" jpeg: bytes = b""
etag: str = "" etag: str = ""
width: int = 0 width: int = 0
height: int = 0 height: int = 0
last_access: float = 0.0
def snapshot(self) -> tuple[bytes, str, int, int]: def snapshot(self) -> tuple[bytes, str, int, int]:
with self.lock: with self.lock:
@@ -74,6 +82,20 @@ class CameraStream:
self.width = width self.width = width
self.height = height 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: class StreamRegistry:
def __init__(self, sources: dict[str, CameraSource]) -> None: def __init__(self, sources: dict[str, CameraSource]) -> None:
@@ -228,7 +250,20 @@ def _prefetch_loop(stream: CameraStream) -> None:
cfg = stream.config 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:
source_url = _frigate_url(cfg.source.url, cfg.width, cfg.height, cfg.zoom) source_url = _frigate_url(cfg.source.url, cfg.width, cfg.height, cfg.zoom)
@@ -244,10 +279,13 @@ def _prefetch_loop(stream: CameraStream) -> None:
print(f"[{cfg.label}] 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 _jpeg_response(stream: CameraStream): def _jpeg_response(stream: CameraStream):
stream.touch()
body, etag, width, height = stream.snapshot() body, etag, width, height = stream.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"}
@@ -288,19 +326,22 @@ def create_app() -> Flask:
@app.get("/health") @app.get("/health")
def health(): def health():
now = time.monotonic()
result = {} result = {}
for stream in registry.all_streams(): for stream in registry.all_streams():
body, _, width, height = stream.snapshot() body, _, width, height = stream.snapshot()
label = stream.config.label label = stream.config.label
result[label] = { result[label] = {
"ready": bool(body), "ready": bool(body),
"active": stream.is_active(now),
"bytes": len(body), "bytes": len(body),
"size": f"{width}x{height}", "size": f"{width}x{height}",
"target": f"{stream.config.width}x{stream.config.height}", "target": f"{stream.config.width}x{stream.config.height}",
"zoom": stream.config.zoom, "zoom": stream.config.zoom,
"source": stream.config.source.url, "source": stream.config.source.url,
} }
return {"streams": result}, 200 if any(s["ready"] for s in result.values()) else 503 # 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("/")
@app.get("/latest.jpg") @app.get("/latest.jpg")
+1
View File
@@ -17,6 +17,7 @@ services:
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" PREFETCH_SIZES: "480,720"
CAMERAS: >- CAMERAS: >-
[ [
-206
View File
@@ -1,206 +0,0 @@
# 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
+17 -20
View File
@@ -40,10 +40,7 @@ esphome:
id(ext_io)->digital_write(7, true); id(ext_io)->digital_write(7, true);
- priority: 600 - priority: 600
then: then:
- lambda: |- - script.execute: activate_stream_a
id(touch_state) = 0;
id(doorbell_jpg).set_url("${stream_a_url}");
id(doorbell_jpg).set_enabled(true);
- light.turn_on: - light.turn_on:
id: backlight id: backlight
brightness: 100% brightness: 100%
@@ -98,6 +95,19 @@ globals:
initial_value: "0" initial_value: "0"
script: 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 - id: cycle_stream
mode: restart mode: restart
then: then:
@@ -107,9 +117,7 @@ script:
condition: condition:
lambda: "return id(touch_state) == 0;" lambda: "return id(touch_state) == 0;"
then: then:
- lambda: |- - script.execute: activate_stream_a
id(doorbell_jpg).set_url("${stream_a_url}");
id(doorbell_jpg).set_enabled(true);
- light.turn_on: - light.turn_on:
id: backlight id: backlight
brightness: 100% brightness: 100%
@@ -127,7 +135,6 @@ script:
condition: condition:
lambda: "return id(touch_state) == 2;" lambda: "return id(touch_state) == 2;"
then: then:
- lambda: "id(doorbell_jpg).set_enabled(false);"
- light.turn_off: backlight - light.turn_off: backlight
spi: spi:
@@ -158,12 +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: |- - script.execute: activate_stream_a
if (id(touch_state) != 2) {
id(doorbell_jpg).set_enabled(true);
}
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:
@@ -174,17 +178,10 @@ switch:
lambda: |- lambda: |-
return id(backlight).current_values.is_on(); return id(backlight).current_values.is_on();
turn_on_action: 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: - light.turn_on:
id: backlight id: backlight
brightness: 100% brightness: 100%
turn_off_action: turn_off_action:
- lambda: |-
id(touch_state) = 2;
id(doorbell_jpg).set_enabled(false);
- light.turn_off: backlight - light.turn_off: backlight
touchscreen: touchscreen: