Add whiteboard frame mode (Nextcloud Whiteboard / Excalidraw over WebDAV)
Build and push server image / build-and-push (push) Failing after 1m10s
Build and push server image / build-and-push (push) Failing after 1m10s
New third mode alongside photos/calendar: fetches a .whiteboard file over plain WebDAV (Basic auth -- generic, not Nextcloud-specific) and renders it via a small Node.js sidecar using Excalidraw's own real export code (@excalidraw/utils + @resvg/resvg-js, no headless browser), since a .whiteboard file turns out to be Excalidraw scene JSON, not an image. The sidecar runs as a second process inside this same container (Dockerfile installs Node, start.sh backgrounds it before exec'ing uvicorn) rather than a separate docker-compose service -- lightweight, stateless, reachable only at 127.0.0.1 from the Python process, nothing worth independently scaling. The rendered PNG is treated exactly like a photo from there on -- composed/quantized through the existing image_pipeline (letterboxed, never cropped) rather than a second parallel rendering pipeline. WebDAV credentials support the common "it's actually the same Nextcloud account as my CalDAV" case (an explicit opt-in checkbox, not silently inferred) while still working with any WebDAV server generically. Frame-level source (URL + owning account) follows the same owner- controls-their-own-data permission split as calendar sources and the week view's task list: only the account owner can point a frame at it, anyone linked can clear it. Honest limitation: this environment has no Node.js/npm, so render-service/ is written carefully against each library's documented API (verified via the npm registry, including transitive dependency licenses after the CalDAV/AGPL surprise earlier this session) but has never actually been executed. First real docker build is the first true test -- see render-service/README.md.
This commit is contained in:
@@ -46,6 +46,7 @@ from .common import (
|
||||
get_or_refresh_calendar_events,
|
||||
get_or_refresh_tasks,
|
||||
get_or_refresh_weather,
|
||||
get_or_refresh_whiteboard,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
@@ -616,6 +617,64 @@ def api_tasks_source(
|
||||
return {"status": "saved", "calendar_key": body.calendar_key}
|
||||
|
||||
|
||||
class WhiteboardSourceRequest(BaseModel):
|
||||
url: str | None # None clears the source
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/whiteboard-source")
|
||||
def api_whiteboard_source(
|
||||
body: WhiteboardSourceRequest,
|
||||
request: Request,
|
||||
frame: Frame = Depends(require_frame_view),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Points this frame's whiteboard at one of the calling user's own
|
||||
WebDAV (or reused-CalDAV, see User.webdav_reuse_caldav_creds)
|
||||
credentials -- same owner-controls-their-own-data permission split
|
||||
as api_tasks_source: only the account owner can set the frame to use
|
||||
it, but anyone linked to the frame can clear it, same as muting a
|
||||
shared calendar."""
|
||||
user = require_user_api(request, db)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
if body.url is None:
|
||||
cfg.whiteboard_user_id = None
|
||||
cfg.whiteboard_url = ""
|
||||
cfg.whiteboard_cached_image = None
|
||||
else:
|
||||
stripped = body.url.strip()
|
||||
if not valid_http_url(stripped):
|
||||
raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL")
|
||||
cfg.whiteboard_user_id = user.id
|
||||
cfg.whiteboard_url = stripped
|
||||
cfg.whiteboard_checked_at = 0.0 # pick up the change promptly
|
||||
return {"status": "saved", "url": body.url}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/preview/whiteboard")
|
||||
def api_preview_whiteboard(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""The same throttled fetch/render cache a live device request would
|
||||
use, run through the same panel composition/quantization pipeline
|
||||
(see routers/device.py's _render_whiteboard_mode) -- "how it will
|
||||
look on the frame" (dithered, letterboxed), not just the raw
|
||||
Excalidraw export, same convention as preview/rendered and
|
||||
preview/calendar."""
|
||||
png_bytes = get_or_refresh_whiteboard(db, frame)
|
||||
if png_bytes is None:
|
||||
if not frame.whiteboard_url:
|
||||
raise HTTPException(400, "No whiteboard configured on this frame yet")
|
||||
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||
png = render_preview_png(
|
||||
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
display_mode="letterbox",
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
class WeatherCityAddRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import caldav_client, calendar_feed, quiet_hours, weather
|
||||
from .. import caldav_client, calendar_feed, quiet_hours, weather, whiteboard
|
||||
from ..db import frame_locked
|
||||
from ..image_pipeline import render_frame
|
||||
from ..immich_client import ImmichClient
|
||||
@@ -24,7 +24,7 @@ from ..models import BatteryLog, Frame, FrameCalendar, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FRAME_MODES = ("photos", "calendar")
|
||||
FRAME_MODES = ("photos", "calendar", "whiteboard")
|
||||
|
||||
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
|
||||
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
||||
@@ -542,3 +542,50 @@ def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]:
|
||||
locked.calendar_tasks_cached = tasks
|
||||
locked.calendar_tasks_checked_at = now
|
||||
return tasks
|
||||
|
||||
|
||||
def webdav_creds_for(user: User) -> tuple[str, str] | None:
|
||||
"""(username, password) for `user`'s WebDAV access -- their own
|
||||
dedicated webdav_username/password, or (if they opted in)
|
||||
calendar_caldav_username/password reused from their CalDAV account
|
||||
(see models.py's User docstring on webdav_reuse_caldav_creds). None
|
||||
if neither is actually set up."""
|
||||
if user.webdav_reuse_caldav_creds:
|
||||
if user.calendar_caldav_username:
|
||||
return user.calendar_caldav_username, user.calendar_caldav_password
|
||||
return None
|
||||
if user.webdav_username:
|
||||
return user.webdav_username, user.webdav_password
|
||||
return None
|
||||
|
||||
|
||||
def get_or_refresh_whiteboard(db: Session, frame: Frame) -> bytes | None:
|
||||
"""Frame-level throttled render cache (calendar_feed.CHECK_INTERVAL_S)
|
||||
-- None if no whiteboard source is configured, credentials are
|
||||
missing (e.g. the owning user unlinked their WebDAV/CalDAV account),
|
||||
or the most recent fetch/render failed and nothing was ever cached
|
||||
yet. A failure after a previous success keeps showing the last
|
||||
good render rather than going blank for one bad refresh cycle, same
|
||||
reasoning as get_or_refresh_weather/get_or_refresh_tasks."""
|
||||
if not frame.whiteboard_url or not frame.whiteboard_user_id:
|
||||
return None
|
||||
now = time.time()
|
||||
if (frame.whiteboard_cached_image is not None
|
||||
and now - frame.whiteboard_checked_at < calendar_feed.CHECK_INTERVAL_S):
|
||||
return frame.whiteboard_cached_image
|
||||
|
||||
user = db.get(User, frame.whiteboard_user_id)
|
||||
creds = webdav_creds_for(user) if user else None
|
||||
if creds is None:
|
||||
return frame.whiteboard_cached_image
|
||||
|
||||
try:
|
||||
png = whiteboard.fetch_and_render(frame.whiteboard_url, creds[0], creds[1])
|
||||
except whiteboard.WhiteboardRenderError as e:
|
||||
logger.warning("Could not refresh whiteboard for frame %d: %s", frame.id, e)
|
||||
return frame.whiteboard_cached_image
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.whiteboard_cached_image = png
|
||||
locked.whiteboard_checked_at = now
|
||||
return png
|
||||
|
||||
@@ -12,12 +12,14 @@ see manage_overlay.py and common.build_manage_content)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -26,7 +28,7 @@ from .. import calendar_render, mail, photo_queue, quiet_hours
|
||||
from ..auth import get_server_settings, require_device
|
||||
from ..db import frame_locked, get_db
|
||||
from ..firmware import firmware_path
|
||||
from ..image_pipeline import render_placeholder
|
||||
from ..image_pipeline import render_frame, render_placeholder
|
||||
from ..models import BatteryLog, Frame
|
||||
from .common import (
|
||||
BATTERY_HISTORY_MAX,
|
||||
@@ -37,6 +39,7 @@ from .common import (
|
||||
get_or_refresh_calendar_events,
|
||||
get_or_refresh_tasks,
|
||||
get_or_refresh_weather,
|
||||
get_or_refresh_whiteboard,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
@@ -204,17 +207,64 @@ def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes
|
||||
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
|
||||
|
||||
# --- whiteboard mode ---
|
||||
|
||||
def _render_whiteboard_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
||||
is_normal_wake: bool) -> bytes:
|
||||
"""Fetches (throttled, see get_or_refresh_whiteboard) and renders the
|
||||
frame's configured .whiteboard file. The rendered PNG is treated
|
||||
exactly like a photo from here on -- run through the same
|
||||
render_frame composition/quantization pipeline as photos mode,
|
||||
letterboxed (never cropped: unlike a photo, losing part of a
|
||||
whiteboard to a crop loses actual content, not just some background)
|
||||
-- rather than a second parallel image pipeline just for this mode."""
|
||||
png_bytes = get_or_refresh_whiteboard(db, frame)
|
||||
if png_bytes is None:
|
||||
return render_placeholder(
|
||||
["This frame's whiteboard isn't set up yet",
|
||||
"Add a WebDAV/Nextcloud whiteboard file URL on",
|
||||
"this frame's Whiteboard tab."],
|
||||
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
|
||||
)
|
||||
|
||||
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||
return render_frame(
|
||||
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
display_mode="letterbox", manage=manage,
|
||||
)
|
||||
|
||||
|
||||
def _advance_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
"""NEXT in whiteboard mode: there's no "next" concept for a single
|
||||
static board, so this instead forces an immediate re-fetch/re-render
|
||||
bypassing the throttle -- a "check now" button for "someone just
|
||||
updated the board, show it right away" rather than waiting out
|
||||
calendar_feed.CHECK_INTERVAL_S."""
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.whiteboard_checked_at = 0.0
|
||||
return _render_whiteboard_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
|
||||
|
||||
def _back_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
"""Same "check now" behavior as _advance_whiteboard_mode -- there's
|
||||
no separate "back" concept for a single static board either."""
|
||||
return _advance_whiteboard_mode(db, frame, manage)
|
||||
|
||||
|
||||
RENDERERS = {
|
||||
"photos": _render_photos_mode,
|
||||
"calendar": _render_calendar_mode,
|
||||
"whiteboard": _render_whiteboard_mode,
|
||||
}
|
||||
ADVANCE_RENDERERS = {
|
||||
"photos": _advance_photos_mode,
|
||||
"calendar": _advance_calendar_mode,
|
||||
"whiteboard": _advance_whiteboard_mode,
|
||||
}
|
||||
BACK_RENDERERS = {
|
||||
"photos": _back_photos_mode,
|
||||
"calendar": _back_calendar_mode,
|
||||
"whiteboard": _back_whiteboard_mode,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -152,6 +152,34 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g
|
||||
)
|
||||
|
||||
|
||||
def _whiteboard_source_info(db: Session, frame: Frame) -> dict | None:
|
||||
"""Whose account this frame's whiteboard currently fetches with, for
|
||||
showing "using <name>'s account" to everyone linked, not just
|
||||
whoever set it. None if no source is configured."""
|
||||
if not frame.whiteboard_user_id or not frame.whiteboard_url:
|
||||
return None
|
||||
user = db.get(User, frame.whiteboard_user_id)
|
||||
if user is None:
|
||||
return None
|
||||
return {"user_id": user.id, "display_name": user.display_name or user.username, "url": frame.whiteboard_url}
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}/whiteboard", response_class=HTMLResponse)
|
||||
def frame_whiteboard_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
viewer = current_user(request, db)
|
||||
frame = db.get(Frame, frame_id)
|
||||
viewer_has_webdav_creds = False
|
||||
if viewer is not None and frame is not None and can_view_frame(db, viewer, frame):
|
||||
viewer_has_webdav_creds = bool(
|
||||
viewer.webdav_username or (viewer.webdav_reuse_caldav_creds and viewer.calendar_caldav_username)
|
||||
)
|
||||
return _frame_page(
|
||||
request, db, frame_id, "frame_whiteboard.html", "whiteboard",
|
||||
whiteboard_source=_whiteboard_source_info(db, frame) if frame is not None else None,
|
||||
viewer_has_webdav_creds=viewer_has_webdav_creds,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
|
||||
def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
return _frame_page(request, db, frame_id, "frame_stats.html", "stats")
|
||||
@@ -428,6 +428,9 @@ def settings_submit(
|
||||
calendar_caldav_url: str = Form(""),
|
||||
calendar_caldav_username: str = Form(""),
|
||||
calendar_caldav_password: str = Form(""),
|
||||
webdav_username: str = Form(""),
|
||||
webdav_password: str = Form(""),
|
||||
webdav_reuse_caldav_creds: bool = Form(False),
|
||||
current_password: str = Form(""),
|
||||
new_password: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -473,6 +476,11 @@ def settings_submit(
|
||||
if calendar_caldav_password.strip():
|
||||
user.calendar_caldav_password = calendar_caldav_password.strip()
|
||||
|
||||
user.webdav_reuse_caldav_creds = webdav_reuse_caldav_creds
|
||||
user.webdav_username = webdav_username.strip()
|
||||
if webdav_password.strip():
|
||||
user.webdav_password = webdav_password.strip()
|
||||
|
||||
if new_password:
|
||||
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
||||
error = "Current password is wrong -- password not changed."
|
||||
|
||||
Reference in New Issue
Block a user