Compare commits
2
Commits
2b5004a569
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c56217927 | ||
|
|
539df2182a |
@@ -1,40 +1,194 @@
|
|||||||
#!/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.
|
||||||
|
|
||||||
|
Prefetch runs only while clients are requesting a stream; after IDLE_TIMEOUT
|
||||||
|
seconds with no hits it pauses until the next request.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
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"))
|
||||||
|
# Stop prefetching a stream after this many seconds with no HTTP clients.
|
||||||
|
IDLE_TIMEOUT = float(os.environ.get("IDLE_TIMEOUT", "30"))
|
||||||
|
DEFAULT_SIZE = int(os.environ.get("DEFAULT_SIZE", "480"))
|
||||||
|
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)
|
||||||
|
wake: threading.Event = field(default_factory=threading.Event)
|
||||||
|
jpeg: bytes = b""
|
||||||
|
etag: str = ""
|
||||||
|
width: int = 0
|
||||||
|
height: int = 0
|
||||||
|
last_access: float = 0.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
|
||||||
|
|
||||||
|
def touch(self) -> None:
|
||||||
|
with self.lock:
|
||||||
|
self.last_access = time.monotonic()
|
||||||
|
self.wake.set()
|
||||||
|
|
||||||
|
def is_active(self, now: float | None = None) -> bool:
|
||||||
|
if IDLE_TIMEOUT <= 0:
|
||||||
|
return True
|
||||||
|
with self.lock:
|
||||||
|
last = self.last_access
|
||||||
|
if last <= 0:
|
||||||
|
return False
|
||||||
|
return ((now if now is not None else time.monotonic()) - last) < IDLE_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
|
class StreamRegistry:
|
||||||
|
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 +201,92 @@ 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)
|
||||||
|
idle = True
|
||||||
while True:
|
while True:
|
||||||
|
if not stream.is_active():
|
||||||
|
if not idle:
|
||||||
|
print(f"[{cfg.label}] prefetch paused (idle)", flush=True)
|
||||||
|
idle = True
|
||||||
|
stream.wake.wait(timeout=1.0)
|
||||||
|
stream.wake.clear()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if idle:
|
||||||
|
print(f"[{cfg.label}] prefetch active", flush=True)
|
||||||
|
idle = False
|
||||||
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
try:
|
try:
|
||||||
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)
|
# Wake early if a client touches the stream (or it goes idle).
|
||||||
|
stream.wake.wait(timeout=delay)
|
||||||
|
stream.wake.clear()
|
||||||
|
|
||||||
|
|
||||||
def _snapshot() -> tuple[bytes, str, int, int]:
|
def _jpeg_response(stream: CameraStream):
|
||||||
with _lock:
|
stream.touch()
|
||||||
return _jpeg, _etag, _width, _height
|
body, etag, width, height = stream.snapshot()
|
||||||
|
|
||||||
|
|
||||||
@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 +303,64 @@ 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()
|
now = time.monotonic()
|
||||||
status = 200 if body else 503
|
result = {}
|
||||||
return {"ready": bool(body), "bytes": len(body), "size": f"{width}x{height}"}, status
|
for stream in registry.all_streams():
|
||||||
|
body, _, width, height = stream.snapshot()
|
||||||
|
label = stream.config.label
|
||||||
|
result[label] = {
|
||||||
|
"ready": bool(body),
|
||||||
|
"active": stream.is_active(now),
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
# Healthy once the process is up; streams may be idle with no clients.
|
||||||
|
return {"streams": result, "idle_timeout": IDLE_TIMEOUT}, 200
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
@app.get("/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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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."
|
||||||
+15
-2
@@ -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,16 @@ 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"
|
||||||
|
IDLE_TIMEOUT: "15"
|
||||||
|
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
|
||||||
|
|||||||
+81
-4
@@ -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,12 @@ esphome:
|
|||||||
- delay: 120ms
|
- delay: 120ms
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
id(ext_io)->digital_write(7, true);
|
id(ext_io)->digital_write(7, true);
|
||||||
|
- priority: 600
|
||||||
|
then:
|
||||||
|
- script.execute: activate_stream_a
|
||||||
|
- light.turn_on:
|
||||||
|
id: backlight
|
||||||
|
brightness: 100%
|
||||||
|
|
||||||
esp32:
|
esp32:
|
||||||
board: esp32-s3-devkitc-1
|
board: esp32-s3-devkitc-1
|
||||||
@@ -79,6 +88,55 @@ 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: activate_stream_a
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
id(touch_state) = 0;
|
||||||
|
id(doorbell_jpg).set_url("${stream_a_url}");
|
||||||
|
id(doorbell_jpg).set_enabled(true);
|
||||||
|
|
||||||
|
- id: deactivate_stream
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
id(touch_state) = 2;
|
||||||
|
id(doorbell_jpg).set_enabled(false);
|
||||||
|
|
||||||
|
- id: cycle_stream
|
||||||
|
mode: restart
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
id(touch_state) = (id(touch_state) + 1) % 3;
|
||||||
|
- if:
|
||||||
|
condition:
|
||||||
|
lambda: "return id(touch_state) == 0;"
|
||||||
|
then:
|
||||||
|
- script.execute: activate_stream_a
|
||||||
|
- 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:
|
||||||
|
- light.turn_off: backlight
|
||||||
|
|
||||||
spi:
|
spi:
|
||||||
- id: disp_spi
|
- id: disp_spi
|
||||||
interface: software
|
interface: software
|
||||||
@@ -107,9 +165,9 @@ light:
|
|||||||
restore_mode: ALWAYS_ON
|
restore_mode: ALWAYS_ON
|
||||||
default_transition_length: 0s
|
default_transition_length: 0s
|
||||||
on_turn_on:
|
on_turn_on:
|
||||||
- lambda: id(doorbell_jpg).set_enabled(true);
|
- script.execute: activate_stream_a
|
||||||
on_turn_off:
|
on_turn_off:
|
||||||
- lambda: id(doorbell_jpg).set_enabled(false);
|
- script.execute: deactivate_stream
|
||||||
|
|
||||||
# Simple on/off for Home Assistant automations (light still exposes brightness).
|
# Simple on/off for Home Assistant automations (light still exposes brightness).
|
||||||
switch:
|
switch:
|
||||||
@@ -126,6 +184,25 @@ switch:
|
|||||||
turn_off_action:
|
turn_off_action:
|
||||||
- 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 +276,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
|
||||||
|
|||||||
Reference in New Issue
Block a user