first commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
__pycache__
|
||||
*.pyc
|
||||
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,292 @@
|
||||
#include "fast_jpeg.h"
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <JPEGDEC.h>
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_http_client.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <strings.h>
|
||||
#include <utility>
|
||||
|
||||
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<FastJpeg *>(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<uint16_t *>(self->back_buffer());
|
||||
const auto *src = reinterpret_cast<const uint16_t *>(draw->pPixels);
|
||||
for (int row = 0; row < copy_h; row++) {
|
||||
memcpy(dst + (y0 + row) * stride + x0, src + row * draw->iWidth, static_cast<size_t>(copy_w) * sizeof(uint16_t));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
esp_err_t http_event_cb_(esp_http_client_event_t *evt) {
|
||||
auto *self = static_cast<FastJpeg *>(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<uint8_t *>(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<FastJpeg *>(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<esp_http_client_handle_t>(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<size_t>(stride) * static_cast<size_t>(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<uint8_t *>(psram_alloc_(need));
|
||||
this->back_ = static_cast<uint8_t *>(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<int>(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<const uint8_t *>(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<const uint8_t *>(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
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/display/display.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#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<bool> busy_{false};
|
||||
std::atomic<bool> 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
flask==3.1.2
|
||||
pillow==11.3.0
|
||||
requests==2.32.5
|
||||
Reference in New Issue
Block a user