Add whiteboard frame mode (Nextcloud Whiteboard / Excalidraw over WebDAV)
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:
2026-07-23 17:02:08 -04:00
parent 14cf212a60
commit 644fdefa66
24 changed files with 792 additions and 9 deletions
+6
View File
@@ -17,6 +17,12 @@ server/**/__pycache__/
server/.venv/
server/*.egg-info/
server/data/
# render-service/ (whiteboard mode's Node sidecar) -- installed fresh
# inside the Docker image, never committed. No package-lock.json exists
# yet either (no Node/npm available in this project's dev environment to
# generate one -- see render-service/README.md); if one's added later, do
# NOT ignore it, lockfiles belong in git.
server/render-service/node_modules/
# Real deploy config, copied from docker-compose.yml.example -- holds the
# Immich API key, must never be committed.
server/docker-compose.yml
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
**/__pycache__/
.venv/
*.egg-info/
data/
render-service/node_modules/
.git/
+19 -2
View File
@@ -6,14 +6,31 @@ WORKDIR /app
# database backing the web UI's "Timezone" setting (used by "Quiet hours")
# would have no named zones to resolve without this -- ZoneInfo() would
# raise for anything other than "UTC".
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
#
# Node.js + fonts: whiteboard frame mode's render-service/ (own README
# there) runs as a second process in this same container rather than a
# separate compose service -- it's a lightweight, stateless, localhost-
# only sidecar with nothing worth independently scaling or restarting.
# NodeSource's setup script is used instead of Debian bookworm's own
# apt Node package, which is both older than jsdom's minimum (20.19+)
# and inconsistently available. fonts-dejavu-core gives the sidecar's
# SVG rasterizer something to render whiteboard text with.
RUN apt-get update && apt-get install -y --no-install-recommends \
tzdata curl ca-certificates gnupg fontconfig fonts-dejavu-core \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY render-service ./render-service
RUN cd render-service && npm install --omit=dev
COPY app ./app
COPY start.sh .
RUN chmod +x start.sh
EXPOSE 8420
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8420"]
CMD ["./start.sh"]
+23
View File
@@ -224,6 +224,29 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
explicit, informed call by the project owner, not a default -- anyone
redistributing this project (vs. just self-hosting it) should
re-evaluate that tradeoff for their own situation before doing so.
- Whiteboard frame mode (`app/webdav_client.py`, `app/whiteboard.py`)
fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
file -- which turns out to be Excalidraw scene JSON (elements/appState/
files), not an image -- and renders it via `render-service/`, a small
Node.js sidecar using Excalidraw's own real export code
(`@excalidraw/utils`'s `exportToSvg`) plus `@resvg/resvg-js` (a native
Rust SVG rasterizer, no headless browser) to turn that into a PNG. That
sidecar runs as a **second process inside this same container**
(`Dockerfile` installs Node, `start.sh` launches it in the background
before `exec`-ing uvicorn), reachable only at `127.0.0.1:3001` from the
Python process -- not a second docker-compose service, since it's
lightweight, stateless, and has nothing worth independently scaling or
restarting. License check (after getting burned once already in this
same file, on the CalDAV dependency below, into checking transitive
deps and not just top-level ones): Excalidraw, `@excalidraw/utils`,
every one of its own runtime dependencies, `@resvg/resvg-js`
(MPL-2.0 -- weak/file-level copyleft, doesn't extend to code that just
calls into it), `jsdom`, and `express` are all MIT/Apache-2.0/Zlib/
MPL-2.0 -- no repeat of the AGPL surprise. **Not runtime-tested against
a real `npm install`/`docker build`** -- this project's dev environment
has no Node.js/npm, only network access to the npm registry API (used
to verify the above and pick real, current dependency versions). See
`render-service/README.md` for exactly what is and isn't verified.
- Calendar event titles can contain emoji, which `ImageFont.load_default()`
(used for every other bit of text this project renders) has no glyphs
for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than
+20
View File
@@ -199,6 +199,25 @@ def _migration_13(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start_offset INTEGER NOT NULL DEFAULT 0"))
def _migration_14(conn) -> None:
"""Whiteboard frame mode: generic WebDAV credentials per user
(webdav_username/password, plus webdav_reuse_caldav_creds as a
convenience when it's the same Nextcloud account as an already-
configured CalDAV one -- see models.py's User docstring), and the
frame-level whiteboard source (whiteboard_user_id/url) + rendered-
PNG cache (see webdav_client.py, whiteboard.py,
routers/device.py's RENDERERS["whiteboard"]). Every new column has a
behavior-preserving default -- no existing frame's render changes
until its mode is actually switched to "whiteboard"."""
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_username TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_password TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_reuse_caldav_creds INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_url TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_checked_at REAL NOT NULL DEFAULT 0.0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_cached_image BLOB"))
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -213,6 +232,7 @@ MIGRATIONS = [
(11, _migration_11),
(12, _migration_12),
(13, _migration_13),
(14, _migration_14),
]
+37 -3
View File
@@ -19,7 +19,7 @@ from __future__ import annotations
import time
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, String
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, LargeBinary, String
from sqlalchemy.ext.mutable import MutableList
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -68,6 +68,18 @@ class User(Base):
# add, without hitting the CalDAV server on every page load.
calendar_caldav_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# WebDAV credentials for whiteboard frame mode (see webdav_client.py,
# whiteboard.py) -- generic WebDAV, not Nextcloud-specific, but
# webdav_reuse_caldav_creds is a convenience for the common case
# where it IS the same Nextcloud account as calendar_caldav_*: skip
# re-entering the same username/password, since Nextcloud's CalDAV
# and general-file-WebDAV both sit under the one account. Doesn't
# try to be clever and derive the reuse automatically -- an explicit
# opt-in, same as everywhere else in this project defaults features
# off rather than silently inferring them.
webdav_username: Mapped[str] = mapped_column(String, default="")
webdav_password: Mapped[str] = mapped_column(String, default="")
webdav_reuse_caldav_creds: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
@@ -102,8 +114,9 @@ class Frame(Base):
# the migrated legacy frame until its device first reports an id.
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
name: Mapped[str] = mapped_column(String, default="")
# Renderer dispatch seam for future calendar/canva modes -- only
# "photos" is registered today (see routers/device.py RENDERERS).
# Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
# (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
# and routers/common.py FRAME_MODES).
mode: Mapped[str] = mapped_column(String, default="photos")
# Whose Immich library this frame pulls from; NULL = unclaimed.
owner_user_id: Mapped[int | None] = mapped_column(
@@ -238,6 +251,27 @@ class Frame(Base):
# by due date -- see caldav_client.fetch_tasks.
calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# -- whiteboard mode (see webdav_client.py, whiteboard.py,
# routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
# setting like calendar mode's own frame_calendars source, not
# personal data, but still owner-gated the same way: only
# whiteboard_user_id may point the frame at their own account (see
# routers/api_frames.py's api_whiteboard_source), since it's their
# credentials being used to fetch it. --
whiteboard_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# The specific .whiteboard file's WebDAV URL -- pasted directly, same
# idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's
# account-has-several-calendars case, a WebDAV account doesn't need
# a picker step here since the user already knows which one file).
whiteboard_url: Mapped[str] = mapped_column(String, default="")
whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# Cached rendered PNG bytes (see whiteboard.fetch_and_render) --
# BLOB rather than the JSON columns the rest of this cache-pattern
# family uses, since this is binary image data, not JSON-shaped.
whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
# -- state --
current_asset_id: Mapped[str] = mapped_column(String, default="")
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
+59
View File
@@ -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
+49 -2
View File
@@ -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
+51 -1
View File
@@ -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,
}
+28
View File
@@ -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")
+8
View File
@@ -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."
+4 -1
View File
@@ -71,9 +71,12 @@
});
if (!resp.ok) throw new Error(await apiError(resp));
previous = mode;
showStatus(true, mode === 'calendar' ? 'Switched to Calendar mode.' : 'Switched to Photos mode.');
var modeLabels = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard' };
showStatus(true, `Switched to ${modeLabels[mode] || mode} mode.`);
var calTab = document.querySelector('.tabs a[href$="/calendar"]');
if (calTab) calTab.classList.toggle('tab-disabled', mode !== 'calendar');
var wbTab = document.querySelector('.tabs a[href$="/whiteboard"]');
if (wbTab) wbTab.classList.toggle('tab-disabled', mode !== 'whiteboard');
} catch (e) {
sel.value = previous;
showStatus(false, e.message);
+79
View File
@@ -0,0 +1,79 @@
// Whiteboard tab: source URL (owner-gated, see api_frames.py's
// api_whiteboard_source), preview, and take control. window.FRAME_API is
// set by the template.
const whiteboardForm = document.getElementById('whiteboard-source-form');
if (whiteboardForm) {
whiteboardForm.addEventListener('submit', async (e) => {
e.preventDefault();
const url = document.getElementById('whiteboard-url-input').value.trim();
if (!url) return;
try {
const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved. Reload to see the updated source.');
loadWhiteboardPreview();
} catch (e) {
showStatus(false, e.message);
}
});
}
const whiteboardClearBtn = document.getElementById('whiteboard-source-clear');
if (whiteboardClearBtn) {
whiteboardClearBtn.addEventListener('click', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Cleared. Reload to see the change.');
loadWhiteboardPreview();
} catch (e) {
showStatus(false, e.message);
}
});
}
function loadWhiteboardPreview() {
document.getElementById('whiteboard-preview').src = `${window.FRAME_API}/preview/whiteboard?_=${Date.now()}`;
}
document.getElementById('whiteboard-preview-refresh').addEventListener('click', loadWhiteboardPreview);
loadWhiteboardPreview();
async function takeControl() {
try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'You have control now.');
loadControl();
} catch (e) {
showStatus(false, e.message);
}
}
async function loadControl() {
const banner = document.getElementById('control-banner');
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) return; // unconfigured frame: control still works via 409s
const data = await resp.json();
if (data.control && !data.control.you) {
banner.style.display = 'flex';
document.getElementById('control-holder').textContent = data.control.controller
? `${data.control.controller} currently has control of this frame.`
: 'Nobody has control of this frame yet.';
} else {
banner.style.display = 'none';
}
} catch (e) { /* banner is best-effort */ }
}
document.getElementById('take-control').addEventListener('click', takeControl);
loadControl();
+13
View File
@@ -3,6 +3,19 @@
// the frame's already-saved Immich creds) -- so this only works after
// the CalDAV URL/username/password have been saved once.
// Hides the dedicated WebDAV username/password fields while "reuse my
// CalDAV creds" is checked -- they'd be ignored server-side anyway (see
// routers/common.py's webdav_creds_for), no reason to leave them visibly
// editable and implying they still do something.
const reuseCaldavCreds = document.getElementById('webdav_reuse_caldav_creds');
if (reuseCaldavCreds) {
const updateWebdavFieldVisibility = () => {
document.getElementById('webdav-creds-fields').style.display = reuseCaldavCreds.checked ? 'none' : '';
};
reuseCaldavCreds.addEventListener('change', updateWebdavFieldVisibility);
updateWebdavFieldVisibility();
}
const discoverBtn = document.getElementById('caldav-discover');
if (discoverBtn) {
discoverBtn.addEventListener('click', async () => {
@@ -1,4 +1,5 @@
<select id="frame-mode-select" class="frame-mode-select" title="Frame mode" aria-label="Frame mode">
<option value="photos" {% if frame.mode == "photos" %}selected{% endif %}>Photos</option>
<option value="calendar" {% if frame.mode == "calendar" %}selected{% endif %}>Calendar</option>
<option value="whiteboard" {% if frame.mode == "whiteboard" %}selected{% endif %}>Whiteboard</option>
</select>
+2
View File
@@ -3,5 +3,7 @@
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
<a href="/frames/{{ frame.id }}/calendar"
class="{% if active_tab == 'calendar' %}active{% endif %} {% if frame.mode != 'calendar' %}tab-disabled{% endif %}">Calendar</a>
<a href="/frames/{{ frame.id }}/whiteboard"
class="{% if active_tab == 'whiteboard' %}active{% endif %} {% if frame.mode != 'whiteboard' %}tab-disabled{% endif %}">Whiteboard</a>
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
</nav>
@@ -0,0 +1,80 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Whiteboard{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div id="control-banner" class="control-banner" style="display: none;">
<span id="control-holder"></span>
<button type="button" id="take-control" class="btn-inline">Take control</button>
</div>
{% if frame.mode != 'whiteboard' %}
<div class="info-box">This frame is currently in <strong>{{ frame.mode|capitalize }}</strong> mode --
settings below take effect once you switch it to <strong>Whiteboard</strong> mode
using the selector at the top of the page.</div>
{% endif %}
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Whiteboard source</h2>
<p class="sub">Renders a Nextcloud Whiteboard (or any Excalidraw
scene) fetched over WebDAV -- credentials set up in
<a href="/settings">Settings</a>.</p>
{% if whiteboard_source %}
<p class="sub" style="margin-top: 10px;">
Currently showing <strong>{{ whiteboard_source.url }}</strong>
using <strong>{{ whiteboard_source.display_name }}</strong>'s
WebDAV account.
<button type="button" class="btn-inline secondary" id="whiteboard-source-clear">Clear</button>
</p>
{% else %}
<p class="sub" style="margin-top: 10px;">No whiteboard configured yet.</p>
{% endif %}
{% if viewer_has_webdav_creds %}
<form id="whiteboard-source-form" style="margin-top: 16px;">
<label>{{ "Change to one of your own files" if whiteboard_source else "Use one of your own files" }}
<input type="text" id="whiteboard-url-input"
placeholder="https://cloud.example.com/remote.php/dav/files/you/Boards/family.whiteboard"
value="{{ whiteboard_source.url if whiteboard_source and whiteboard_source.user_id == user.id else '' }}">
</label>
<p class="sub" style="margin-top: 4px;">The direct WebDAV URL
to the specific file -- in Nextcloud's Files app, this is
the file's path under
<code>remote.php/dav/files/&lt;your-username&gt;/</code>.</p>
<button type="submit">Save</button>
</form>
{% else %}
<p class="sub" style="margin-top: 10px;">Set up WebDAV credentials
in <a href="/settings">Settings</a> first to point this frame at
one of your own files.</p>
{% endif %}
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Preview</h2>
<p class="sub">How this frame's whiteboard currently renders.</p>
<img class="preview-img" id="whiteboard-preview" alt="Whiteboard preview">
<button type="button" class="secondary" id="whiteboard-preview-refresh">Refresh preview</button>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_header.js"></script>
<script src="/static/frame_whiteboard.js"></script>
{% endblock %}
+26
View File
@@ -71,6 +71,32 @@
you're linked to from that frame's Calendar tab, so a frame only
shows calendars people have actually chosen to share with it.</p>
<h2 class="card-title" style="margin-top: 24px;">Whiteboard (WebDAV)</h2>
<p class="sub">Credentials for whiteboard frame mode -- fetching a
specific file (e.g. a Nextcloud Whiteboard board) over WebDAV.
Any WebDAV server works, not just Nextcloud.</p>
<div class="checkbox-row">
<input type="checkbox" id="webdav_reuse_caldav_creds" name="webdav_reuse_caldav_creds"
value="true" {% if user.webdav_reuse_caldav_creds %}checked{% endif %}>
<label for="webdav_reuse_caldav_creds" style="margin: 0; font-weight: normal;">
Reuse my CalDAV username/password above (only works if it's the
same account -- e.g. Nextcloud's CalDAV and its regular file
storage share one login)</label>
</div>
<div id="webdav-creds-fields">
<label>WebDAV username
<input type="text" id="webdav_username" name="webdav_username" autocomplete="off"
value="{{ user.webdav_username }}">
</label>
<label>WebDAV password
<input type="password" id="webdav_password" name="webdav_password" autocomplete="off"
placeholder="{% if user.webdav_password %}(unchanged -- enter a new password to replace){% else %}app password or account password{% endif %}">
</label>
</div>
<p class="sub" style="margin-top: 8px;">Doesn't show up anywhere by
itself -- point a specific frame's Whiteboard tab at a file URL
using this account.</p>
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
<label>Current password
<input type="password" name="current_password" autocomplete="current-password">
+46
View File
@@ -0,0 +1,46 @@
"""Plain authenticated WebDAV file fetch -- whiteboard frame mode's way
of pulling one specific file (a Nextcloud Whiteboard .whiteboard, or any
other WebDAV server's file, this isn't Nextcloud-specific) out of a
user's account. Deliberately just "GET this URL with Basic auth", the
same shape as calendar_feed.py's plain ICS fetch -- no discovery, no
account-wide browsing, since the caller already has (or pastes) the
exact file URL, unlike caldav_client.py's calendar-account discovery
flow which exists because a CalDAV account can hold several calendars
worth picking between.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py.
"""
from __future__ import annotations
import httpx
HTTP_TIMEOUT_S = 15.0
FETCH_MAX_BYTES = 10 * 1024 * 1024 # a whiteboard scene is KB, not MB -- sanity cap, not an expected size
class WebDavError(Exception):
"""Fetch failed -- network, auth, a missing file, or an oversized
response. Raised loudly; callers decide what to do."""
def fetch_file(url: str, username: str, password: str) -> bytes:
"""The raw bytes of one WebDAV file, HTTP Basic auth. That's the
whole protocol surface whiteboard mode needs -- Basic auth over
plain HTTP GET is what WebDAV file access boils down to once you
already have the exact URL, no PROPFIND/discovery involved."""
try:
with httpx.stream("GET", url, auth=(username, password), timeout=HTTP_TIMEOUT_S,
follow_redirects=True) as resp:
resp.raise_for_status()
chunks = []
total = 0
for chunk in resp.iter_bytes():
total += len(chunk)
if total > FETCH_MAX_BYTES:
raise WebDavError(f"File exceeds {FETCH_MAX_BYTES} bytes")
chunks.append(chunk)
return b"".join(chunks)
except httpx.HTTPError as e:
raise WebDavError(str(e)) from e
+75
View File
@@ -0,0 +1,75 @@
"""Whiteboard frame mode: fetches a Nextcloud Whiteboard (or any other
WebDAV server's) .whiteboard file and renders it via the local
render-service sidecar (server/render-service/, own README there) --
Excalidraw's real export code, not a hand-rolled reimplementation of its
element types/styling/fonts.
Deliberately renders at a fixed generous width, not the panel's exact
target size -- the resulting PNG then runs through
image_pipeline.compose_into exactly like a photo would (crop/letterbox
per the frame's own display_mode setting), so this module doesn't need
to know anything about panel dimensions/orientation, and whiteboard mode
reuses the same fit logic photos mode already has instead of a second
parallel implementation of it.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/weather.py.
"""
from __future__ import annotations
import json
import httpx
from . import webdav_client
RENDER_SERVICE_URL = "http://127.0.0.1:3001/render"
# Rendering (not just fetching) can take a moment for a busy board --
# more generous than a typical fetch timeout.
HTTP_TIMEOUT_S = 30.0
# Fixed render width regardless of the target frame's orientation/size --
# see module docstring. Comfortably above this panel's 800px long edge
# so downstream cropping isn't working from an upscaled source.
RENDER_WIDTH = 1600
CHECK_INTERVAL_S = 20 * 60 # same cadence as calendar_feed's merge-fetch throttle
class WhiteboardRenderError(Exception):
"""Fetch or render failed -- network, auth, an invalid/non-JSON
file, or the render sidecar itself erroring. Raised loudly; callers
decide what to do."""
def fetch_and_render(url: str, username: str, password: str) -> bytes:
"""Fetches the .whiteboard file at `url` and renders it to a PNG via
the local render-service sidecar. Returns raw PNG bytes at
RENDER_WIDTH wide, natural aspect ratio."""
try:
raw = webdav_client.fetch_file(url, username, password)
except webdav_client.WebDavError as e:
raise WhiteboardRenderError(f"Could not fetch whiteboard file: {e}") from e
try:
scene = json.loads(raw)
except ValueError as e:
raise WhiteboardRenderError(f"Not a valid whiteboard file (not JSON): {e}") from e
if not isinstance(scene.get("elements"), list):
raise WhiteboardRenderError('Not a valid whiteboard file (no "elements" array)')
try:
resp = httpx.post(
RENDER_SERVICE_URL,
json={
"elements": scene.get("elements", []),
"appState": scene.get("appState", {}),
"files": scene.get("files", {}),
"width": RENDER_WIDTH,
},
timeout=HTTP_TIMEOUT_S,
)
resp.raise_for_status()
return resp.content
except httpx.HTTPError as e:
raise WhiteboardRenderError(f"Render sidecar failed: {e}") from e
+33
View File
@@ -0,0 +1,33 @@
# whiteboard-render
Local sidecar for whiteboard frame mode -- see `server.js`'s own header
comment for the full "why Node, why not a headless browser" reasoning.
Not an independently deployed service: it runs as a second process
inside the main server's container (`../Dockerfile` installs Node,
`../start.sh` launches this in the background before `exec`-ing
uvicorn), reachable only at `127.0.0.1:3001` from the Python process in
that same container.
**Not runtime-tested against a real `npm install` during development**
-- this environment had no Node.js/npm available, only the npm registry
API (used to verify the dependency versions/licenses in `package.json`
actually exist and resolve). The code is written carefully against each
library's documented API (`@excalidraw/utils`'s `exportToSvg`,
`@resvg/resvg-js`'s `Resvg` class), but the first real build
(`docker compose build`) is the first time this has actually executed
end to end. If something's off, `docker compose logs` will show it --
most likely candidates are `exportToSvg`'s actual return type (string vs.
DOM element -- handled defensively, see server.js) or a font-rendering
quirk, not a wrong API shape.
## Local development (if you have Node 20.19+/22.13+ installed)
```sh
cd render-service
npm install
npm start # listens on 127.0.0.1:3001
curl -X POST http://127.0.0.1:3001/render \
-H "Content-Type: application/json" \
-d '{"elements": [], "width": 800}' \
-o /tmp/test.png # an empty scene -- just checks the service comes up and returns a valid PNG
```
+17
View File
@@ -0,0 +1,17 @@
{
"name": "espresso-frame-whiteboard-render",
"version": "1.0.0",
"private": true,
"description": "Local sidecar: renders Excalidraw scene JSON (the format Nextcloud Whiteboard's .whiteboard files use) to PNG for whiteboard frame mode. Runs as a second process inside the main server's container (see ../Dockerfile and ../start.sh), talked to over 127.0.0.1 only -- not an independent deployment, not reachable from outside the container.",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"license": "MIT",
"dependencies": {
"@excalidraw/utils": "0.1.3-test32",
"@resvg/resvg-js": "2.6.2",
"express": "^5.2.1",
"jsdom": "^29.1.1"
}
}
+99
View File
@@ -0,0 +1,99 @@
// Local render sidecar for whiteboard frame mode: turns Excalidraw scene
// JSON (the format Nextcloud Whiteboard's .whiteboard files use --
// {"elements", "appState", "files"}, see app/webdav_client.py) into a
// PNG, using the real Excalidraw export code rather than a hand-rolled
// reimplementation of its element types/styling/fonts. That's the whole
// reason this exists as Node rather than more Python: @excalidraw/utils
// IS the renderer real whiteboards are drawn with, so this reproduces
// whatever a user actually sees in their whiteboard exactly, and never
// drifts out of sync with new element types as Excalidraw adds them.
//
// Runs as a second process inside the main Python server's own
// container (see ../Dockerfile installing Node, and ../start.sh
// launching this in the background before exec'ing uvicorn) -- not a
// separate deployment, no independent scaling/restart needs, so one
// container is simpler than a second docker-compose service. Bound to
// 127.0.0.1 only: reachable from the Python process in the same
// container, never from outside it, so there's no auth on top of that
// -- the network boundary IS the access control here.
//
// No headless browser (Puppeteer/Playwright) -- jsdom provides just
// enough of a browser-like global environment for @excalidraw/utils'
// internal DOM calls (e.g. text measurement) to work, and
// @resvg/resvg-js (a native Rust SVG rasterizer, no browser process)
// turns the resulting SVG into the actual PNG.
const { JSDOM } = require('jsdom');
// @excalidraw/utils touches `window`/`document` globals even though
// exportToSvg's own return value doesn't depend on a live page -- these
// have to exist before the package is required, not just before it's
// called.
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
global.window = dom.window;
global.document = dom.window.document;
global.navigator = dom.window.navigator;
const express = require('express');
const { exportToSvg } = require('@excalidraw/utils');
const { Resvg } = require('@resvg/resvg-js');
const PORT = process.env.RENDER_SERVICE_PORT || 3001;
const HOST = '127.0.0.1';
// A whiteboard scene is normally tiny (KB, not MB) -- this is a sanity
// cap against something going wrong upstream, not a real expected size.
const MAX_BODY_BYTES = 25 * 1024 * 1024;
const app = express();
app.use(express.json({ limit: MAX_BODY_BYTES }));
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.post('/render', async (req, res) => {
const { elements, appState, files, width, height } = req.body || {};
if (!Array.isArray(elements)) {
res.status(400).json({ error: 'elements must be an array (a parsed .whiteboard/Excalidraw scene)' });
return;
}
try {
const svg = await exportToSvg({
elements,
appState: appState || {},
files: files || {},
exportPadding: 20,
});
// Depending on the installed version, exportToSvg resolves to either
// an SVGSVGElement (needs serializing) or already a string -- handle
// both rather than assume, since this isn't runtime-tested against a
// live install in this environment (no Node available to verify
// during development, see the server README's whiteboard mode notes).
const svgString = typeof svg === 'string' ? svg : svg.outerHTML;
const targetWidth = Number(width) || undefined;
const resvg = new Resvg(svgString, {
fitTo: targetWidth ? { mode: 'width', value: targetWidth } : { mode: 'original' },
background: 'rgba(255, 255, 255, 1)',
font: {
// No bundled Excalidraw font assets (Virgil/Cascadia) in v1 --
// text renders in whatever fonts fontconfig finds in the image
// (see ../Dockerfile's fonts-dejavu-core), not pixel-identical
// to the browser editor's handwriting-style font. Good enough
// for "what does the board say", not a design-fidelity tool.
loadSystemFonts: true,
},
});
const pngBuffer = resvg.render().asPng();
res.set('Content-Type', 'image/png');
res.send(pngBuffer);
} catch (err) {
console.error('Whiteboard render failed:', err);
res.status(500).json({ error: String((err && err.message) || err) });
}
});
app.listen(PORT, HOST, () => {
console.log(`whiteboard-render listening on ${HOST}:${PORT}`);
});
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Starts render-service/ (whiteboard frame mode's Excalidraw-to-PNG
# sidecar, see its own README) in the background, bound to 127.0.0.1 --
# reachable from this container's Python process, never from outside it.
# Then execs uvicorn as the foreground/PID 1 process so it receives
# Docker's stop signal directly. The backgrounded Node process has no
# state worth flushing on shutdown -- fine for it to just die with the
# container.
node ./render-service/server.js &
exec uvicorn app.main:app --host 0.0.0.0 --port 8420