diff --git a/app.py b/app.py index 678b421..f9b7a85 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,9 @@ #!/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 @@ -20,6 +24,8 @@ from PIL import Image 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")) +# 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")) @@ -58,10 +64,12 @@ class StreamConfig: 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: @@ -74,6 +82,20 @@ class CameraStream: 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: @@ -228,7 +250,20 @@ def _prefetch_loop(stream: CameraStream) -> None: cfg = stream.config session = requests.Session() interval = 1.0 / max(PREFETCH_HZ, 0.5) + idle = 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() try: 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) delay = interval - (time.monotonic() - started) 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): + stream.touch() body, etag, width, height = stream.snapshot() if not body: return "Image not ready", 503, {"Content-Type": "text/plain"} @@ -288,19 +326,22 @@ def create_app() -> Flask: @app.get("/health") def health(): + now = time.monotonic() result = {} 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, } - 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("/latest.jpg") diff --git a/docker-compose.yml b/docker-compose.yml index dde820c..41c219f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: JPEG_QUALITY: "40" FETCH_TIMEOUT: "5" PREFETCH_HZ: "10" + IDLE_TIMEOUT: "15" PREFETCH_SIZES: "480,720" CAMERAS: >- [ diff --git a/doorbell-display-p4.yaml b/doorbell-display-p4.yaml deleted file mode 100644 index c720f95..0000000 --- a/doorbell-display-p4.yaml +++ /dev/null @@ -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 diff --git a/doorbell-display.yaml b/doorbell-display.yaml index 807e194..ae2819e 100644 --- a/doorbell-display.yaml +++ b/doorbell-display.yaml @@ -40,10 +40,7 @@ esphome: 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); + - script.execute: activate_stream_a - light.turn_on: id: backlight brightness: 100% @@ -98,6 +95,19 @@ globals: 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: @@ -107,9 +117,7 @@ script: condition: lambda: "return id(touch_state) == 0;" then: - - lambda: |- - id(doorbell_jpg).set_url("${stream_a_url}"); - id(doorbell_jpg).set_enabled(true); + - script.execute: activate_stream_a - light.turn_on: id: backlight brightness: 100% @@ -127,7 +135,6 @@ script: condition: lambda: "return id(touch_state) == 2;" then: - - lambda: "id(doorbell_jpg).set_enabled(false);" - light.turn_off: backlight spi: @@ -158,12 +165,9 @@ light: restore_mode: ALWAYS_ON default_transition_length: 0s on_turn_on: - - lambda: |- - if (id(touch_state) != 2) { - id(doorbell_jpg).set_enabled(true); - } + - script.execute: activate_stream_a 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). switch: @@ -174,17 +178,10 @@ 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: