From 539df2182ad97927c2b18fdb0a0da318308cb50b Mon Sep 17 00:00:00 2001 From: Arne Vlaeminck Date: Sat, 22 Aug 2026 17:11:53 +0200 Subject: [PATCH] Added tap to wake and cycle feed --- app.py | 298 ++++++++++++++++++++++++----- components/fast_jpeg/fast_jpeg.cpp | 6 +- components/fast_jpeg/fast_jpeg.h | 6 +- deploy.sh | 44 +++++ docker-compose.yml | 16 +- doorbell-display-p4.yaml | 206 ++++++++++++++++++++ doorbell-display.yaml | 86 ++++++++- 7 files changed, 610 insertions(+), 52 deletions(-) create mode 100755 deploy.sh create mode 100644 doorbell-display-p4.yaml diff --git a/app.py b/app.py index c727dfd..678b421 100644 --- a/app.py +++ b/app.py @@ -1,40 +1,172 @@ #!/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 import hashlib import io +import json import os +import re import threading import time +from dataclasses import dataclass, field +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import requests -from flask import Flask, request +from flask import Flask, abort, request 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")) FETCH_TIMEOUT = float(os.environ.get("FETCH_TIMEOUT", "5")) 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__) - -_lock = threading.Lock() -_jpeg = b"" -_etag = "" -_width = 0 -_height = 0 +_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") -def _encode(frame: Image.Image) -> tuple[bytes, int, int]: - frame = frame.convert("RGB") - if frame.width != TARGET_WIDTH: - height = max(1, round(frame.height * TARGET_WIDTH / frame.width)) - frame = frame.resize((TARGET_WIDTH, height), Image.BILINEAR) +@dataclass(frozen=True) +class CameraSource: + name: str + url: str + 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() frame.save( buf, @@ -47,39 +179,76 @@ def _encode(frame: Image.Image) -> tuple[bytes, int, int]: 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() interval = 1.0 / max(PREFETCH_HZ, 0.5) while True: started = time.monotonic() 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() 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() + '"' - with _lock: - global _jpeg, _etag, _width, _height - _jpeg = encoded - _etag = etag - _width = width - _height = height + stream.store(encoded, etag, width, height) 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) if delay > 0: time.sleep(delay) -def _snapshot() -> tuple[bytes, str, int, int]: - with _lock: - return _jpeg, _etag, _width, _height - - -@app.get("/") -@app.get("/latest.jpg") -def latest(): - body, etag, width, height = _snapshot() +def _jpeg_response(stream: CameraStream): + body, etag, width, height = stream.snapshot() if not body: 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()), "Cache-Control": "no-cache", "Accept-Ranges": "none", + "X-Camera": stream.config.source.name, "X-Image-Size": f"{width}x{height}", + "X-Zoom": f"{stream.config.zoom:g}", }, ) -@app.get("/health") -def health(): - body, _, width, height = _snapshot() - status = 200 if body else 503 - return {"ready": bool(body), "bytes": len(body), "size": f"{width}x{height}"}, status +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") + def health(): + result = {} + for stream in registry.all_streams(): + 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__": - 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) diff --git a/components/fast_jpeg/fast_jpeg.cpp b/components/fast_jpeg/fast_jpeg.cpp index dd1529c..e187587 100644 --- a/components/fast_jpeg/fast_jpeg.cpp +++ b/components/fast_jpeg/fast_jpeg.cpp @@ -17,9 +17,9 @@ namespace esphome { namespace fast_jpeg { static const char *const TAG = "fast_jpeg"; -static const int kMaxWidth = 480; -static const int kMaxHeight = 480; -static const size_t kJpegCap = 48 * 1024; +static const int kMaxWidth = 720; +static const int kMaxHeight = 720; +static const size_t kJpegCap = 128 * 1024; static int align16_(int value) { return (value + 15) & ~15; } diff --git a/components/fast_jpeg/fast_jpeg.h b/components/fast_jpeg/fast_jpeg.h index e96f501..4f50f9f 100644 --- a/components/fast_jpeg/fast_jpeg.h +++ b/components/fast_jpeg/fast_jpeg.h @@ -18,7 +18,11 @@ namespace fast_jpeg { class FastJpeg : public PollingComponent { 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_x(int x) { this->x_ = x; } void set_y(int y) { this->y_ = y; } diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..7d0eb11 --- /dev/null +++ b/deploy.sh @@ -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." diff --git a/docker-compose.yml b/docker-compose.yml index 08890dc..dde820c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,12 @@ # 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://:8080/doorbell/?size=480 +# http://:8080/oprit/?w=720 +# http://: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: resizer: image: doorbell-resizer:latest @@ -6,11 +14,15 @@ services: ports: - "8080:80" environment: - FRIGATE_URL: http://10.0.1.197:5050/api/doorbell/latest.jpg?h=270 - TARGET_WIDTH: "480" JPEG_QUALITY: "40" FETCH_TIMEOUT: "5" 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: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1/health')"] interval: 30s diff --git a/doorbell-display-p4.yaml b/doorbell-display-p4.yaml new file mode 100644 index 0000000..c720f95 --- /dev/null +++ b/doorbell-display-p4.yaml @@ -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 diff --git a/doorbell-display.yaml b/doorbell-display.yaml index 867dc16..807e194 100644 --- a/doorbell-display.yaml +++ b/doorbell-display.yaml @@ -2,13 +2,16 @@ # Copy this file AND the components/ folder into your ESPHome config directory. # 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). # Stock online_image JPEG is too slow for ~5 fps. substitutions: 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: - source: @@ -35,6 +38,15 @@ esphome: - delay: 120ms - lambda: |- 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: board: esp32-s3-devkitc-1 @@ -79,6 +91,45 @@ pca9554: address: 0x20 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: - id: disp_spi interface: software @@ -107,7 +158,10 @@ light: restore_mode: ALWAYS_ON default_transition_length: 0s 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: - lambda: id(doorbell_jpg).set_enabled(false); @@ -120,12 +174,38 @@ 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: + - 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: - platform: mipi_rgb id: lcd @@ -199,7 +279,7 @@ display: fast_jpeg: id: doorbell_jpg - url: ${image_url} + url: ${stream_a_url} display_id: lcd update_interval: 200ms # If colours look swapped, set big_endian: false