Added stop to the container if no device needs frames
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -17,6 +17,7 @@ services:
|
||||
JPEG_QUALITY: "40"
|
||||
FETCH_TIMEOUT: "5"
|
||||
PREFETCH_HZ: "10"
|
||||
IDLE_TIMEOUT: "15"
|
||||
PREFETCH_SIZES: "480,720"
|
||||
CAMERAS: >-
|
||||
[
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user