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
+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