From 81717cb9a8df858aab73a8fcc4b96486c7e7bccf Mon Sep 17 00:00:00 2001 From: Arne Vlaeminck Date: Thu, 20 Aug 2026 21:26:05 +0200 Subject: [PATCH] first commit --- .dockerignore | 5 + .gitignore | 4 + Dockerfile | 9 + app.py | 113 +++++++++++ components/fast_jpeg/__init__.py | 45 +++++ components/fast_jpeg/fast_jpeg.cpp | 292 +++++++++++++++++++++++++++++ components/fast_jpeg/fast_jpeg.h | 81 ++++++++ docker-compose.yml | 23 +++ doorbell-display.yaml | 202 ++++++++++++++++++++ requirements.txt | 3 + 10 files changed, 777 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 components/fast_jpeg/__init__.py create mode 100644 components/fast_jpeg/fast_jpeg.cpp create mode 100644 components/fast_jpeg/fast_jpeg.h create mode 100644 docker-compose.yml create mode 100644 doorbell-display.yaml create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ddbd6e9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.gitignore +.DS_Store +__pycache__ +*.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e44422d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +.env +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..175a004 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py /app/app.py + +EXPOSE 80 +CMD ["python", "-u", "app.py"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..c727dfd --- /dev/null +++ b/app.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Doorbell JPEG proxy: prefetch from Frigate and serve a baseline 480px-wide JPEG.""" + +from __future__ import annotations + +import hashlib +import io +import os +import threading +import time + +import requests +from flask import Flask, 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")) + +app = Flask(__name__) + +_lock = threading.Lock() +_jpeg = b"" +_etag = "" +_width = 0 +_height = 0 + + +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) + buf = io.BytesIO() + frame.save( + buf, + format="JPEG", + quality=JPEG_QUALITY, + subsampling=2, + optimize=False, + progressive=False, + ) + return buf.getvalue(), frame.width, frame.height + + +def _prefetch_loop() -> None: + 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) + response.raise_for_status() + with Image.open(io.BytesIO(response.content)) as image: + encoded, width, height = _encode(image) + etag = '"' + hashlib.md5(encoded).hexdigest() + '"' + with _lock: + global _jpeg, _etag, _width, _height + _jpeg = encoded + _etag = etag + _width = width + _height = height + except Exception as exc: # noqa: BLE001 — keep last good frame + print(f"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() + if not body: + return "Image not ready", 503, {"Content-Type": "text/plain"} + + if request.headers.get("If-None-Match") == etag: + return ("", 304, {"ETag": etag, "Cache-Control": "no-cache"}) + + return ( + body, + 200, + { + "Content-Type": "image/jpeg", + "Content-Length": str(len(body)), + "ETag": etag, + "Last-Modified": time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()), + "Cache-Control": "no-cache", + "Accept-Ranges": "none", + "X-Image-Size": f"{width}x{height}", + }, + ) + + +@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 + + +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/__init__.py b/components/fast_jpeg/__init__.py new file mode 100644 index 0000000..d2e14d3 --- /dev/null +++ b/components/fast_jpeg/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import display +from esphome.const import CONF_DISPLAY_ID, CONF_ID, CONF_URL, CONF_X, CONF_Y +from esphome.core import CORE + +DEPENDENCIES = ["display", "esp32"] + +fast_jpeg_ns = cg.esphome_ns.namespace("fast_jpeg") +FastJpeg = fast_jpeg_ns.class_("FastJpeg", cg.PollingComponent) + +CONF_BIG_ENDIAN = "big_endian" + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(FastJpeg), + cv.Required(CONF_URL): cv.url, + cv.Required(CONF_DISPLAY_ID): cv.use_id(display.Display), + cv.Optional(CONF_X, default=0): cv.int_, + cv.Optional(CONF_Y): cv.int_, + cv.Optional(CONF_BIG_ENDIAN, default=True): cv.boolean, + } + ) + .extend(cv.polling_component_schema("200ms")) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add(var.set_url(config[CONF_URL])) + disp = await cg.get_variable(config[CONF_DISPLAY_ID]) + cg.add(var.set_display(disp)) + cg.add(var.set_x(config[CONF_X])) + if CONF_Y in config: + cg.add(var.set_y(config[CONF_Y])) + cg.add(var.set_big_endian(config[CONF_BIG_ENDIAN])) + + cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") + if CORE.is_esp32: + from esphome.components.esp32 import add_idf_component, include_builtin_idf_component + + include_builtin_idf_component("esp_http_client") + add_idf_component(name="espressif/esp-dsp", ref="1.8.2") diff --git a/components/fast_jpeg/fast_jpeg.cpp b/components/fast_jpeg/fast_jpeg.cpp new file mode 100644 index 0000000..dc2ef83 --- /dev/null +++ b/components/fast_jpeg/fast_jpeg.cpp @@ -0,0 +1,292 @@ +#include "fast_jpeg.h" + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include "esp_heap_caps.h" +#include "esp_http_client.h" + +#include +#include +#include +#include +#include + +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 int align16_(int value) { return (value + 15) & ~15; } + +static void *psram_alloc_(size_t size) { + void *ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (ptr == nullptr) + ptr = heap_caps_malloc(size, MALLOC_CAP_8BIT); + return ptr; +} + +int jpeg_draw_cb_(JPEGDRAW *draw) { + auto *self = static_cast(draw->pUser); + if (self == nullptr || self->back_buffer() == nullptr) + return 1; + + const int img_w = self->pending_width(); + const int img_h = self->pending_height(); + const int stride = self->pending_stride(); + if (img_w <= 0 || img_h <= 0 || stride <= 0) + return 1; + + // JPEGDEC emits MCU tiles that can extend past the true image edge (270 is not + // 16-pixel aligned). Clip or we write off the end of the framebuffer and reboot. + const int x0 = draw->x; + const int y0 = draw->y; + if (x0 >= img_w || y0 >= img_h) + return 1; + + const int copy_w = std::min(draw->iWidthUsed > 0 ? draw->iWidthUsed : draw->iWidth, img_w - x0); + const int copy_h = std::min(draw->iHeight, img_h - y0); + if (copy_w <= 0 || copy_h <= 0) + return 1; + + auto *dst = reinterpret_cast(self->back_buffer()); + const auto *src = reinterpret_cast(draw->pPixels); + for (int row = 0; row < copy_h; row++) { + memcpy(dst + (y0 + row) * stride + x0, src + row * draw->iWidth, static_cast(copy_w) * sizeof(uint16_t)); + } + return 1; +} + +esp_err_t http_event_cb_(esp_http_client_event_t *evt) { + auto *self = static_cast(evt->user_data); + if (self == nullptr) + return ESP_OK; + switch (evt->event_id) { + case HTTP_EVENT_ON_HEADER: + if (evt->header_key != nullptr && strcasecmp(evt->header_key, "ETag") == 0 && evt->header_value != nullptr) { + strncpy(self->etag(), evt->header_value, 79); + self->etag()[79] = '\0'; + } + break; + case HTTP_EVENT_ON_DATA: + if (evt->data_len <= 0) + break; + if (*self->http_rx_len() + evt->data_len > self->jpeg_cap()) { + ESP_LOGE(TAG, "JPEG larger than %u bytes", (unsigned) self->jpeg_cap()); + return ESP_FAIL; + } + memcpy(self->jpeg_buf() + *self->http_rx_len(), evt->data, evt->data_len); + *self->http_rx_len() += evt->data_len; + break; + default: + break; + } + return ESP_OK; +} + +void FastJpeg::setup() { + ESP_LOGI(TAG, "Setup"); + this->jpeg_cap_ = kJpegCap; + this->jpeg_buf_ = static_cast(psram_alloc_(this->jpeg_cap_)); + this->decoder_ = new (std::nothrow) ::JPEGDEC(); + if (this->jpeg_buf_ == nullptr || this->decoder_ == nullptr) { + this->mark_failed(); + ESP_LOGE(TAG, "Failed to allocate JPEG buffers"); + return; + } + + BaseType_t ok = xTaskCreate(task_fn_, "fast_jpeg", 8192, this, 5, &this->task_); + if (ok != pdPASS) { + this->mark_failed(); + ESP_LOGE(TAG, "Failed to create fetch task"); + return; + } + this->last_fps_log_ = millis(); +} + +void FastJpeg::dump_config() { + ESP_LOGCONFIG(TAG, "Fast JPEG stream"); + ESP_LOGCONFIG(TAG, " URL: %s", this->url_.c_str()); + ESP_LOGCONFIG(TAG, " Interval: %u ms", this->get_update_interval()); +} + +void FastJpeg::update() { + if (this->is_failed() || this->task_ == nullptr) + return; + if (this->busy_.load() || this->decoded_ready_.load()) + return; + xTaskNotifyGive(this->task_); +} + +void FastJpeg::loop() { + if (!this->decoded_ready_.exchange(false)) + return; + std::swap(this->front_, this->back_); + this->w_ = this->pending_w_; + this->h_ = this->pending_h_; + this->stride_ = this->pending_stride_; + this->clear_bars_once_(); + this->paint_(); + this->frames_++; + uint32_t now = millis(); + if (now - this->last_fps_log_ >= 5000) { + ESP_LOGI(TAG, "%u frames in 5s (%.1f fps), last %dx%d", this->frames_, this->frames_ / 5.0f, this->w_, this->h_); + this->frames_ = 0; + this->last_fps_log_ = now; + } +} + +void FastJpeg::task_fn_(void *param) { + auto *self = static_cast(param); + for (;;) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + self->busy_.store(true); + self->fetch_once_(); + self->busy_.store(false); + } +} + +void FastJpeg::fetch_once_() { + if (this->url_.empty() || this->jpeg_buf_ == nullptr) + return; + + esp_http_client_config_t config = {}; + config.url = this->url_.c_str(); + config.timeout_ms = 2000; + config.event_handler = http_event_cb_; + config.user_data = this; + config.keep_alive_enable = true; + config.method = HTTP_METHOD_GET; + + if (this->http_ == nullptr) { + this->http_ = esp_http_client_init(&config); + if (this->http_ == nullptr) { + ESP_LOGE(TAG, "HTTP init failed"); + return; + } + } + auto *client = static_cast(this->http_); + esp_http_client_set_url(client, this->url_.c_str()); + esp_http_client_set_header(client, "Accept", "image/jpeg"); + if (this->etag_[0] != '\0') + esp_http_client_set_header(client, "If-None-Match", this->etag_); + + this->http_rx_len_ = 0; + uint32_t t0 = millis(); + esp_err_t err = esp_http_client_perform(client); + if (err != ESP_OK) { + ESP_LOGW(TAG, "HTTP failed: %s", esp_err_to_name(err)); + return; + } + int status = esp_http_client_get_status_code(client); + if (status == 304) + return; + if (status != 200) { + ESP_LOGW(TAG, "HTTP status %d", status); + return; + } + uint32_t download_ms = millis() - t0; + ESP_LOGD(TAG, "downloaded %u bytes in %ums", (unsigned) this->http_rx_len_, (unsigned) download_ms); + if (!this->decode_jpeg_(this->http_rx_len_)) + return; +} + +bool FastJpeg::ensure_framebuffers_(int width, int height) { + if (width <= 0 || height <= 0 || width > kMaxWidth || height > kMaxHeight) { + ESP_LOGW(TAG, "Unsupported JPEG size %dx%d", width, height); + return false; + } + const int stride = align16_(width); + const int rows = align16_(height); + size_t need = static_cast(stride) * static_cast(rows) * 2; + this->pending_stride_ = stride; + if (need <= this->fb_cap_ && this->front_ != nullptr && this->back_ != nullptr) + return true; + heap_caps_free(this->front_); + heap_caps_free(this->back_); + this->front_ = nullptr; + this->back_ = nullptr; + this->front_ = static_cast(psram_alloc_(need)); + this->back_ = static_cast(psram_alloc_(need)); + this->fb_cap_ = need; + if (this->front_ == nullptr || this->back_ == nullptr) { + ESP_LOGE(TAG, "Framebuffer alloc failed (%u bytes)", (unsigned) need); + return false; + } + memset(this->front_, 0, need); + memset(this->back_, 0, need); + return true; +} + +bool FastJpeg::decode_jpeg_(size_t len) { + if (len < 16 || this->decoder_ == nullptr) + return false; + ::JPEGDEC *jpeg = this->decoder_; + if (!jpeg->openRAM(this->jpeg_buf_, static_cast(len), jpeg_draw_cb_)) { + ESP_LOGW(TAG, "JPEG open failed: %d", jpeg->getLastError()); + return false; + } + if (jpeg->getJPEGType() == JPEG_MODE_PROGRESSIVE) { + ESP_LOGW(TAG, "Progressive JPEG is not supported"); + jpeg->close(); + return false; + } + int width = jpeg->getWidth(); + int height = jpeg->getHeight(); + jpeg->setUserPointer(this); + if (!this->ensure_framebuffers_(width, height)) { + jpeg->close(); + return false; + } + this->pending_w_ = width; + this->pending_h_ = height; + jpeg->setPixelType(this->big_endian_ ? ::RGB565_BIG_ENDIAN : ::RGB565_LITTLE_ENDIAN); + uint32_t t0 = millis(); + if (!jpeg->decode(0, 0, 0)) { + ESP_LOGW(TAG, "JPEG decode failed: %d", jpeg->getLastError()); + jpeg->close(); + return false; + } + jpeg->close(); + ESP_LOGI(TAG, "decoded %dx%d in %ums", width, height, (unsigned) (millis() - t0)); + this->decoded_ready_.store(true); + return true; +} + +void FastJpeg::clear_bars_once_() { + if (this->bars_cleared_ || this->display_ == nullptr || this->display_->is_failed()) + return; + const int canvas_w = this->display_->get_width(); + const int canvas_h = this->display_->get_height(); + if (canvas_w <= 0 || canvas_h <= 0) + return; + const int y = this->y_ >= 0 ? this->y_ : (canvas_h - this->h_) / 2; + uint16_t line[kMaxWidth] = {}; + const int line_w = std::min(canvas_w, kMaxWidth); + for (int row = 0; row < y; row++) { + this->display_->draw_pixels_at(0, row, line_w, 1, reinterpret_cast(line), display::COLOR_ORDER_RGB, + display::COLOR_BITNESS_565, this->big_endian_); + } + for (int row = y + this->h_; row < canvas_h; row++) { + this->display_->draw_pixels_at(0, row, line_w, 1, reinterpret_cast(line), display::COLOR_ORDER_RGB, + display::COLOR_BITNESS_565, this->big_endian_); + } + this->bars_cleared_ = true; +} + +void FastJpeg::paint_() { + if (this->display_ == nullptr || this->display_->is_failed() || this->front_ == nullptr || this->w_ <= 0) + return; + const int canvas_h = this->display_->get_height(); + const int y = this->y_ >= 0 ? this->y_ : (canvas_h - this->h_) / 2; + const int x_pad = this->stride_ > this->w_ ? this->stride_ - this->w_ : 0; + this->display_->draw_pixels_at(this->x_, y, this->w_, this->h_, this->front_, display::COLOR_ORDER_RGB, + display::COLOR_BITNESS_565, this->big_endian_, 0, 0, x_pad); +} + +} // namespace fast_jpeg +} // namespace esphome diff --git a/components/fast_jpeg/fast_jpeg.h b/components/fast_jpeg/fast_jpeg.h new file mode 100644 index 0000000..2b3c9a9 --- /dev/null +++ b/components/fast_jpeg/fast_jpeg.h @@ -0,0 +1,81 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/display/display.h" + +#include +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +class JPEGDEC; + +namespace esphome { +namespace fast_jpeg { + +class FastJpeg : public PollingComponent { + public: + void set_url(const std::string &url) { this->url_ = url; } + 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; } + void set_big_endian(bool big_endian) { this->big_endian_ = big_endian; } + + void setup() override; + void loop() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + uint8_t *jpeg_buf() { return this->jpeg_buf_; } + size_t jpeg_cap() const { return this->jpeg_cap_; } + size_t *http_rx_len() { return &this->http_rx_len_; } + char *etag() { return this->etag_; } + uint8_t *back_buffer() { return this->back_; } + int pending_width() const { return this->pending_w_; } + int pending_height() const { return this->pending_h_; } + int pending_stride() const { return this->pending_stride_; } + + protected: + static void task_fn_(void *param); + void fetch_once_(); + bool decode_jpeg_(size_t len); + bool ensure_framebuffers_(int width, int height); + void paint_(); + void clear_bars_once_(); + + std::string url_; + display::Display *display_{nullptr}; + int x_{0}; + int y_{-1}; + bool big_endian_{true}; + + uint8_t *front_{nullptr}; + uint8_t *back_{nullptr}; + uint8_t *jpeg_buf_{nullptr}; + ::JPEGDEC *decoder_{nullptr}; + size_t fb_cap_{0}; + size_t jpeg_cap_{0}; + int pending_w_{0}; + int pending_h_{0}; + int pending_stride_{0}; + int w_{0}; + int h_{0}; + int stride_{0}; + + TaskHandle_t task_{nullptr}; + void *http_{nullptr}; + size_t http_rx_len_{0}; + std::atomic busy_{false}; + std::atomic decoded_ready_{false}; + bool bars_cleared_{false}; + char etag_[80]{}; + uint32_t frames_{0}; + uint32_t last_fps_log_{0}; +}; + +} // namespace fast_jpeg +} // namespace esphome diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..34b8eeb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +# Portainer: Stacks → Add stack → upload this file + Dockerfile/app.py/requirements.txt +# (Web editor alone can't build; use Git stack or pre-built image — see notes below.) +services: + resizer: + build: . + image: doorbell-resizer:latest + container_name: doorbell-resizer + ports: + - "8080:80" + environment: + # Reachable from the Portainer host (LAN IP or Docker DNS if same compose network) + 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" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + restart: unless-stopped diff --git a/doorbell-display.yaml b/doorbell-display.yaml new file mode 100644 index 0000000..8e7b8fe --- /dev/null +++ b/doorbell-display.yaml @@ -0,0 +1,202 @@ +# Waveshare ESP32-S3-Touch-LCD-4B — doorbell JPEG viewer +# Copy this file AND the components/ folder into your ESPHome config directory. +# Pair with the resizer proxy in this repo. +# +# 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.70:8080/" + +external_components: + - source: + type: local + path: components + +esphome: + name: ${name} + friendly_name: ${friendly_name} + min_version: "2025.2.0" + on_boot: + - priority: 800 + then: + - lambda: |- + auto *io = id(ext_io); + io->pin_mode(0, esphome::gpio::FLAG_OUTPUT); + io->pin_mode(1, esphome::gpio::FLAG_OUTPUT); + io->pin_mode(2, esphome::gpio::FLAG_OUTPUT); + io->pin_mode(7, esphome::gpio::FLAG_OUTPUT); + io->digital_write(0, true); + io->digital_write(1, false); + io->digital_write(2, false); + io->digital_write(7, false); + - delay: 120ms + - lambda: |- + id(ext_io)->digital_write(7, true); + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + sdkconfig_options: + CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y" + CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y" + CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y" + CONFIG_SPIRAM_RODATA: "y" + advanced: + include_builtin_idf_components: + - esp_http_client + +psram: + mode: octal + speed: 80MHz + +logger: + logs: + fast_jpeg: INFO + +api: +ota: + - platform: esphome + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +i2c: + sda: GPIO47 + scl: GPIO48 + frequency: 400kHz + scan: true + +pca9554: + - id: ext_io + address: 0x20 + pin_count: 8 + +spi: + - id: disp_spi + interface: software + clk_pin: + pca9554: ext_io + number: 2 + mode: + output: true + mosi_pin: + pca9554: ext_io + number: 1 + mode: + output: true + +output: + - platform: ledc + id: backlight_pwm + inverted: true + pin: GPIO4 + +light: + - platform: monochromatic + id: backlight + name: "Backlight" + output: backlight_pwm + restore_mode: ALWAYS_ON + default_transition_length: 0s + +# Simple on/off for Home Assistant automations (light still exposes brightness). +switch: + - platform: template + name: "Screen" + id: screen_power + icon: mdi:monitor + lambda: |- + return id(backlight).current_values.is_on(); + turn_on_action: + - light.turn_on: + id: backlight + brightness: 100% + turn_off_action: + - light.turn_off: backlight + +display: + - platform: mipi_rgb + id: lcd + model: WAVESHARE-4-480x480 + dimensions: 480x480 + color_order: RGB + auto_clear_enabled: false + update_interval: never + + spi_id: disp_spi + cs_pin: + pca9554: ext_io + number: 0 + mode: + output: true + reset_pin: + pca9554: ext_io + number: 7 + mode: + output: true + + de_pin: GPIO17 + hsync_pin: + number: GPIO46 + ignore_strapping_warning: true + vsync_pin: + number: GPIO3 + ignore_strapping_warning: true + pclk_pin: GPIO9 + pclk_inverted: false + + data_pins: + red: [GPIO10, GPIO11, GPIO12, GPIO13, GPIO14] + green: [GPIO21, GPIO8, GPIO18, GPIO45, GPIO38, GPIO39] + blue: [GPIO40, GPIO41, GPIO42, GPIO2, GPIO1] + + init_sequence: + - [0xFF, 0x77, 0x01, 0x00, 0x00, 0x10] + - [0xC0, 0x3B, 0x00] + - [0xC1, 0x0D, 0x02] + - [0xC2, 0x21, 0x08] + - [0xCD, 0x08] + - [0xB0, 0x00, 0x11, 0x18, 0x0E, 0x11, 0x06, 0x07, 0x08, 0x07, 0x22, 0x04, 0x12, 0x0F, 0xAA, 0x31, 0x18] + - [0xB1, 0x00, 0x11, 0x19, 0x0E, 0x12, 0x07, 0x08, 0x08, 0x08, 0x22, 0x04, 0x11, 0x11, 0xA9, 0x32, 0x18] + - [0xFF, 0x77, 0x01, 0x00, 0x00, 0x11] + - [0xB0, 0x60] + - [0xB1, 0x30] + - [0xB2, 0x87] + - [0xB3, 0x80] + - [0xB5, 0x49] + - [0xB7, 0x85] + - [0xB8, 0x21] + - [0xC1, 0x78] + - [0xC2, 0x78] + - delay 20ms + - [0xE0, 0x00, 0x1B, 0x02] + - [0xE1, 0x08, 0xA0, 0x00, 0x00, 0x07, 0xA0, 0x00, 0x00, 0x00, 0x44, 0x44] + - [0xE2, 0x11, 0x11, 0x44, 0x44, 0xED, 0xA0, 0x00, 0x00, 0xEC, 0xA0, 0x00, 0x00] + - [0xE3, 0x00, 0x00, 0x11, 0x11] + - [0xE4, 0x44, 0x44] + - [0xE5, 0x0A, 0xE9, 0xD8, 0xA0, 0x0C, 0xEB, 0xD8, 0xA0, 0x0E, 0xED, 0xD8, 0xA0, 0x10, 0xEF, 0xD8, 0xA0] + - [0xE6, 0x00, 0x00, 0x11, 0x11] + - [0xE7, 0x44, 0x44] + - [0xE8, 0x09, 0xE8, 0xD8, 0xA0, 0x0B, 0xEA, 0xD8, 0xA0, 0x0D, 0xEC, 0xD8, 0xA0, 0x0F, 0xEE, 0xD8, 0xA0] + - [0xEB, 0x02, 0x00, 0xE4, 0xE4, 0x88, 0x00, 0x40] + - [0xEC, 0x3C, 0x00] + - [0xED, 0xAB, 0x89, 0x76, 0x54, 0x02, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x20, 0x45, 0x67, 0x98, 0xBA] + - [0xFF, 0x77, 0x01, 0x00, 0x00, 0x00] + - [0x36, 0x00] + - [0x3A, 0x66] + +fast_jpeg: + id: doorbell_jpg + url: ${image_url} + display_id: lcd + update_interval: 200ms + # If colours look swapped, set big_endian: false + big_endian: true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cc8472c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask==3.1.2 +pillow==11.3.0 +requests==2.32.5