Added stop to the container if no device needs frames
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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: >-
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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);
|
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user