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:
@@ -17,6 +17,12 @@ server/**/__pycache__/
|
|||||||
server/.venv/
|
server/.venv/
|
||||||
server/*.egg-info/
|
server/*.egg-info/
|
||||||
server/data/
|
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
|
# Real deploy config, copied from docker-compose.yml.example -- holds the
|
||||||
# Immich API key, must never be committed.
|
# Immich API key, must never be committed.
|
||||||
server/docker-compose.yml
|
server/docker-compose.yml
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
data/
|
||||||
|
render-service/node_modules/
|
||||||
|
.git/
|
||||||
+19
-2
@@ -6,14 +6,31 @@ WORKDIR /app
|
|||||||
# database backing the web UI's "Timezone" setting (used by "Quiet hours")
|
# database backing the web UI's "Timezone" setting (used by "Quiet hours")
|
||||||
# would have no named zones to resolve without this -- ZoneInfo() would
|
# would have no named zones to resolve without this -- ZoneInfo() would
|
||||||
# raise for anything other than "UTC".
|
# 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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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 app ./app
|
||||||
|
COPY start.sh .
|
||||||
|
RUN chmod +x start.sh
|
||||||
|
|
||||||
EXPOSE 8420
|
EXPOSE 8420
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8420"]
|
CMD ["./start.sh"]
|
||||||
|
|||||||
@@ -224,6 +224,29 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
explicit, informed call by the project owner, not a default -- anyone
|
explicit, informed call by the project owner, not a default -- anyone
|
||||||
redistributing this project (vs. just self-hosting it) should
|
redistributing this project (vs. just self-hosting it) should
|
||||||
re-evaluate that tradeoff for their own situation before doing so.
|
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()`
|
- Calendar event titles can contain emoji, which `ImageFont.load_default()`
|
||||||
(used for every other bit of text this project renders) has no glyphs
|
(used for every other bit of text this project renders) has no glyphs
|
||||||
for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than
|
for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than
|
||||||
|
|||||||
@@ -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"))
|
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 = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -213,6 +232,7 @@ MIGRATIONS = [
|
|||||||
(11, _migration_11),
|
(11, _migration_11),
|
||||||
(12, _migration_12),
|
(12, _migration_12),
|
||||||
(13, _migration_13),
|
(13, _migration_13),
|
||||||
|
(14, _migration_14),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+37
-3
@@ -19,7 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
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.ext.mutable import MutableList
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
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.
|
# 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_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||||
calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
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)
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -102,8 +114,9 @@ class Frame(Base):
|
|||||||
# the migrated legacy frame until its device first reports an id.
|
# the migrated legacy frame until its device first reports an id.
|
||||||
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
|
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
|
||||||
name: Mapped[str] = mapped_column(String, default="")
|
name: Mapped[str] = mapped_column(String, default="")
|
||||||
# Renderer dispatch seam for future calendar/canva modes -- only
|
# Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
|
||||||
# "photos" is registered today (see routers/device.py RENDERERS).
|
# (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
|
||||||
|
# and routers/common.py FRAME_MODES).
|
||||||
mode: Mapped[str] = mapped_column(String, default="photos")
|
mode: Mapped[str] = mapped_column(String, default="photos")
|
||||||
# Whose Immich library this frame pulls from; NULL = unclaimed.
|
# Whose Immich library this frame pulls from; NULL = unclaimed.
|
||||||
owner_user_id: Mapped[int | None] = mapped_column(
|
owner_user_id: Mapped[int | None] = mapped_column(
|
||||||
@@ -238,6 +251,27 @@ class Frame(Base):
|
|||||||
# by due date -- see caldav_client.fetch_tasks.
|
# by due date -- see caldav_client.fetch_tasks.
|
||||||
calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
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 --
|
# -- state --
|
||||||
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
||||||
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
|
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from .common import (
|
|||||||
get_or_refresh_calendar_events,
|
get_or_refresh_calendar_events,
|
||||||
get_or_refresh_tasks,
|
get_or_refresh_tasks,
|
||||||
get_or_refresh_weather,
|
get_or_refresh_weather,
|
||||||
|
get_or_refresh_whiteboard,
|
||||||
immich_client_for,
|
immich_client_for,
|
||||||
immich_creds,
|
immich_creds,
|
||||||
list_assets,
|
list_assets,
|
||||||
@@ -616,6 +617,64 @@ def api_tasks_source(
|
|||||||
return {"status": "saved", "calendar_key": body.calendar_key}
|
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):
|
class WeatherCityAddRequest(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from PIL import Image
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
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 ..db import frame_locked
|
||||||
from ..image_pipeline import render_frame
|
from ..image_pipeline import render_frame
|
||||||
from ..immich_client import ImmichClient
|
from ..immich_client import ImmichClient
|
||||||
@@ -24,7 +24,7 @@ from ..models import BatteryLog, Frame, FrameCalendar, User
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 / estimate tuning (see /frame/battery and battery_estimate_s).
|
||||||
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
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_cached = tasks
|
||||||
locked.calendar_tasks_checked_at = now
|
locked.calendar_tasks_checked_at = now
|
||||||
return tasks
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||||
|
from PIL import Image
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
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 ..auth import get_server_settings, require_device
|
||||||
from ..db import frame_locked, get_db
|
from ..db import frame_locked, get_db
|
||||||
from ..firmware import firmware_path
|
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 ..models import BatteryLog, Frame
|
||||||
from .common import (
|
from .common import (
|
||||||
BATTERY_HISTORY_MAX,
|
BATTERY_HISTORY_MAX,
|
||||||
@@ -37,6 +39,7 @@ from .common import (
|
|||||||
get_or_refresh_calendar_events,
|
get_or_refresh_calendar_events,
|
||||||
get_or_refresh_tasks,
|
get_or_refresh_tasks,
|
||||||
get_or_refresh_weather,
|
get_or_refresh_weather,
|
||||||
|
get_or_refresh_whiteboard,
|
||||||
immich_client_for,
|
immich_client_for,
|
||||||
immich_creds,
|
immich_creds,
|
||||||
list_assets,
|
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)
|
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 = {
|
RENDERERS = {
|
||||||
"photos": _render_photos_mode,
|
"photos": _render_photos_mode,
|
||||||
"calendar": _render_calendar_mode,
|
"calendar": _render_calendar_mode,
|
||||||
|
"whiteboard": _render_whiteboard_mode,
|
||||||
}
|
}
|
||||||
ADVANCE_RENDERERS = {
|
ADVANCE_RENDERERS = {
|
||||||
"photos": _advance_photos_mode,
|
"photos": _advance_photos_mode,
|
||||||
"calendar": _advance_calendar_mode,
|
"calendar": _advance_calendar_mode,
|
||||||
|
"whiteboard": _advance_whiteboard_mode,
|
||||||
}
|
}
|
||||||
BACK_RENDERERS = {
|
BACK_RENDERERS = {
|
||||||
"photos": _back_photos_mode,
|
"photos": _back_photos_mode,
|
||||||
"calendar": _back_calendar_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)
|
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
|
||||||
def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
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")
|
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_url: str = Form(""),
|
||||||
calendar_caldav_username: str = Form(""),
|
calendar_caldav_username: str = Form(""),
|
||||||
calendar_caldav_password: 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(""),
|
current_password: str = Form(""),
|
||||||
new_password: str = Form(""),
|
new_password: str = Form(""),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -473,6 +476,11 @@ def settings_submit(
|
|||||||
if calendar_caldav_password.strip():
|
if calendar_caldav_password.strip():
|
||||||
user.calendar_caldav_password = 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 new_password:
|
||||||
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
||||||
error = "Current password is wrong -- password not changed."
|
error = "Current password is wrong -- password not changed."
|
||||||
|
|||||||
@@ -71,9 +71,12 @@
|
|||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await apiError(resp));
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
previous = mode;
|
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"]');
|
var calTab = document.querySelector('.tabs a[href$="/calendar"]');
|
||||||
if (calTab) calTab.classList.toggle('tab-disabled', mode !== '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) {
|
} catch (e) {
|
||||||
sel.value = previous;
|
sel.value = previous;
|
||||||
showStatus(false, e.message);
|
showStatus(false, e.message);
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -3,6 +3,19 @@
|
|||||||
// the frame's already-saved Immich creds) -- so this only works after
|
// the frame's already-saved Immich creds) -- so this only works after
|
||||||
// the CalDAV URL/username/password have been saved once.
|
// 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');
|
const discoverBtn = document.getElementById('caldav-discover');
|
||||||
if (discoverBtn) {
|
if (discoverBtn) {
|
||||||
discoverBtn.addEventListener('click', async () => {
|
discoverBtn.addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<select id="frame-mode-select" class="frame-mode-select" title="Frame mode" aria-label="Frame mode">
|
<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="photos" {% if frame.mode == "photos" %}selected{% endif %}>Photos</option>
|
||||||
<option value="calendar" {% if frame.mode == "calendar" %}selected{% endif %}>Calendar</option>
|
<option value="calendar" {% if frame.mode == "calendar" %}selected{% endif %}>Calendar</option>
|
||||||
|
<option value="whiteboard" {% if frame.mode == "whiteboard" %}selected{% endif %}>Whiteboard</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -3,5 +3,7 @@
|
|||||||
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
|
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
|
||||||
<a href="/frames/{{ frame.id }}/calendar"
|
<a href="/frames/{{ frame.id }}/calendar"
|
||||||
class="{% if active_tab == 'calendar' %}active{% endif %} {% if frame.mode != 'calendar' %}tab-disabled{% endif %}">Calendar</a>
|
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>
|
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
|
||||||
</nav>
|
</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/<your-username>/</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 %}
|
||||||
@@ -71,6 +71,32 @@
|
|||||||
you're linked to from that frame's Calendar tab, so a frame only
|
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>
|
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>
|
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
|
||||||
<label>Current password
|
<label>Current password
|
||||||
<input type="password" name="current_password" autocomplete="current-password">
|
<input type="password" name="current_password" autocomplete="current-password">
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
Executable
+10
@@ -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
|
||||||
Reference in New Issue
Block a user