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 #!/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 from __future__ import annotations
import hashlib import hashlib
import io import io
import json
import os import os
import re
import threading import threading
import time import time
from dataclasses import dataclass, field
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
import requests import requests
from flask import Flask, request from flask import Flask, abort, request
from PIL import Image 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")) 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"))
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__) _NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
_lock = threading.Lock()
_jpeg = b""
_etag = ""
_width = 0
_height = 0
def _encode(frame: Image.Image) -> tuple[bytes, int, int]: @dataclass(frozen=True)
frame = frame.convert("RGB") class CameraSource:
if frame.width != TARGET_WIDTH: name: str
height = max(1, round(frame.height * TARGET_WIDTH / frame.width)) url: str
frame = frame.resize((TARGET_WIDTH, height), Image.BILINEAR) 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() buf = io.BytesIO()
frame.save( frame.save(
buf, buf,
@@ -47,39 +179,76 @@ def _encode(frame: Image.Image) -> tuple[bytes, int, int]:
return buf.getvalue(), frame.width, frame.height 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() session = requests.Session()
interval = 1.0 / max(PREFETCH_HZ, 0.5) interval = 1.0 / max(PREFETCH_HZ, 0.5)
while True: while True:
started = time.monotonic() started = time.monotonic()
try: 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() response.raise_for_status()
with Image.open(io.BytesIO(response.content)) as image: 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() + '"' etag = '"' + hashlib.md5(encoded).hexdigest() + '"'
with _lock: stream.store(encoded, etag, width, height)
global _jpeg, _etag, _width, _height
_jpeg = encoded
_etag = etag
_width = width
_height = height
except Exception as exc: # noqa: BLE001 — keep last good frame 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) delay = interval - (time.monotonic() - started)
if delay > 0: if delay > 0:
time.sleep(delay) time.sleep(delay)
def _snapshot() -> tuple[bytes, str, int, int]: def _jpeg_response(stream: CameraStream):
with _lock: body, etag, width, height = stream.snapshot()
return _jpeg, _etag, _width, _height
@app.get("/")
@app.get("/latest.jpg")
def latest():
body, etag, width, height = _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"}
@@ -96,18 +265,61 @@ def latest():
"Last-Modified": time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()), "Last-Modified": time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()),
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Accept-Ranges": "none", "Accept-Ranges": "none",
"X-Camera": stream.config.source.name,
"X-Image-Size": f"{width}x{height}", "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") @app.get("/health")
def health(): def health():
body, _, width, height = _snapshot() result = {}
status = 200 if body else 503 for stream in registry.all_streams():
return {"ready": bool(body), "bytes": len(body), "size": f"{width}x{height}"}, status 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__": 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) 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 { namespace fast_jpeg {
static const char *const TAG = "fast_jpeg"; static const char *const TAG = "fast_jpeg";
static const int kMaxWidth = 480; static const int kMaxWidth = 720;
static const int kMaxHeight = 480; static const int kMaxHeight = 720;
static const size_t kJpegCap = 48 * 1024; static const size_t kJpegCap = 128 * 1024;
static int align16_(int value) { return (value + 15) & ~15; } static int align16_(int value) { return (value + 15) & ~15; }
+5 -1
View File
@@ -18,7 +18,11 @@ namespace fast_jpeg {
class FastJpeg : public PollingComponent { class FastJpeg : public PollingComponent {
public: 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_display(display::Display *display) { this->display_ = display; }
void set_x(int x) { this->x_ = x; } void set_x(int x) { this->x_ = x; }
void set_y(int y) { this->y_ = y; } 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. # 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: services:
resizer: resizer:
image: doorbell-resizer:latest image: doorbell-resizer:latest
@@ -6,11 +14,15 @@ services:
ports: ports:
- "8080:80" - "8080:80"
environment: environment:
FRIGATE_URL: http://10.0.1.197:5050/api/doorbell/latest.jpg?h=270
TARGET_WIDTH: "480"
JPEG_QUALITY: "40" JPEG_QUALITY: "40"
FETCH_TIMEOUT: "5" FETCH_TIMEOUT: "5"
PREFETCH_HZ: "10" 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: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1/health')"]
interval: 30s 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. # Copy this file AND the components/ folder into your ESPHome config directory.
# Pair with the resizer proxy in this repo. # 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). # Uses a custom fast_jpeg component (RGB565 block decode + DMA blit).
# Stock online_image JPEG is too slow for ~5 fps. # Stock online_image JPEG is too slow for ~5 fps.
substitutions: substitutions:
name: doorbell-display name: doorbell-display
friendly_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: external_components:
- source: - source:
@@ -35,6 +38,15 @@ esphome:
- delay: 120ms - delay: 120ms
- lambda: |- - lambda: |-
id(ext_io)->digital_write(7, true); 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: esp32:
board: esp32-s3-devkitc-1 board: esp32-s3-devkitc-1
@@ -79,6 +91,45 @@ pca9554:
address: 0x20 address: 0x20
pin_count: 8 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: spi:
- id: disp_spi - id: disp_spi
interface: software interface: software
@@ -107,7 +158,10 @@ light:
restore_mode: ALWAYS_ON restore_mode: ALWAYS_ON
default_transition_length: 0s default_transition_length: 0s
on_turn_on: 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: on_turn_off:
- lambda: id(doorbell_jpg).set_enabled(false); - lambda: id(doorbell_jpg).set_enabled(false);
@@ -120,12 +174,38 @@ 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:
- 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: display:
- platform: mipi_rgb - platform: mipi_rgb
id: lcd id: lcd
@@ -199,7 +279,7 @@ display:
fast_jpeg: fast_jpeg:
id: doorbell_jpg id: doorbell_jpg
url: ${image_url} url: ${stream_a_url}
display_id: lcd display_id: lcd
update_interval: 200ms update_interval: 200ms
# If colours look swapped, set big_endian: false # If colours look swapped, set big_endian: false