f48daa71c849f285cd36f48c90ecc364ae16b31b
14
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f48daa71c8 |
Widget system Phase 1: per-type render/action modules
New app/widgets/ package (photos.py, calendar.py, whiteboard.py, plus the WIDGET_TYPES registry) -- the widget-system analogue of routers/device.py's old RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS, generalized from "one mode owns the whole panel" to "each widget renders into its own region and responds to named button actions." Each module exposes render(db, frame, widget, target_w, target_h) -> Image.Image (never raises -- a widget's own fetch hiccup falls back to a small placeholder rather than taking the whole panel's render down) and an ACTIONS registry for NEXT/BACK button assignment. Supporting changes needed to give the widget modules something to call, all mechanical/behavior-preserving for every existing caller: - image_pipeline.py: render_panel(regions, ...) generalizes render_frame's tail (paste, enhance once, overlay once, quantize once, pack once) from one photo to N regions -- not a restructuring, since calendar mode's photo-inlay feature already pastes a second composed image onto the canvas before that single shared pipeline runs. - photo_queue.py: advance_forced/back_forced/remove_from_rotation/ get_current take an explicit `frame` param now that `cfg` won't always be the Frame itself once photo-queue state moves to PhotoWidgetConfig -- caught a real latent bug while doing this: get_current was reading refresh_interval_s off `cfg`, but that's a frame-level wake-cadence setting, not something that becomes per-widget, so it now reads that off `frame` explicitly instead. - routers/common.py: list_assets/fetch_source_and_faces take album_id/ display_mode directly instead of a whole Frame (both only ever read that one attribute off it); new get_or_refresh_*_for_widget siblings of the existing calendar/weather/tasks/whiteboard cache helpers, read/ writing the new per-widget config tables -- the Frame-scoped originals are untouched and still what routers/device.py's actual dispatch calls until the Phase 2 cutover. 26 new tests (95 total): render_panel size/placement/orientation coverage, and per-widget-type render/action tests (unconfigured -> placeholder, a fetch failure -> placeholder not a crash, actions mutate the right state). Full suite passes; diff-reviewed to confirm device.py's actual RENDERERS dispatch and the old Frame-scoped get_or_refresh_* bodies are unchanged, so this is safe to deploy on its own despite being step 1 of a two-step cutover (see the project plan on why the *next* step, not this one, has to ship atomically). |
||
|
|
644fdefa66 |
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. |
||
|
|
db9a6f1875 |
Week view: relative start-day offset for non-7-day counts; hide week-only settings elsewhere
Build and push server image / build-and-push (push) Successful in 53s
calendar_week_start's fixed-weekday anchor ("start on the most recent
Monday") stops making sense once the view isn't a literal calendar
week, so a non-7-day week view now starts calendar_week_start_offset
days from today instead (0 = starts today, negative/positive = past/
future) -- calendar_week_start still governs at the default 7 days,
unchanged.
Also hides the Calendar tab's week-only fields (days to show, layout,
start offset) unless View is actually set to Week, and further hides
the new start-offset field specifically when Days to show is 7 (where
it has no effect). "Week starts on" stays visible for Month too, since
it actually still applies there.
|
||
|
|
ce8525bee8 |
Week view flexibility: configurable day count, layout, and a CalDAV task list
Build and push server image / build-and-push (push) Successful in 52s
- Day count (2-10, was fixed at 7) -- 5 days trims the weekend clutter without losing the grid format. - Layout choice: days side by side (original behavior) or stacked vertically as full agenda-style sections (reuses _draw_agenda_day, same approach _build_today_tomorrow already used for a fixed 2 days). - Optional task list (CalDAV VTODO collections only -- a plain ICS subscription doesn't meaningfully have one) that takes the space of one day slot instead of adding an extra one. Same owner-controls- their-own-data permission split as calendar sources: only the calendar's owner can point a frame's task list at it, but anyone linked to the frame can clear it. Browse-offset paging now moves by N days (was hardcoded to weeks), identical to the old behavior when days=7. Changing the day count resets the browse offset, same reasoning as changing views already did. |
||
|
|
ffce798754 |
Add weather to calendar mode; fix CalDAV events never showing
Build and push server image / build-and-push (push) Successful in 48s
Weather: multiple cities per frame, geocoded via Open-Meteo (no API key), shown above the event list on agenda/today & tomorrow/week views -- never month, no room for it there. Hand-drawn sun/cloud/rain/snow/ thunderstorm icons (no new font/icon asset, same primitives-only approach the rest of calendar_render.py already uses). City geocoding handles "City, State" qualifiers Open-Meteo's own search doesn't (disambiguates same-named cities, e.g. the three "Portland"s). CalDAV fix: events were never showing despite calendars discovering fine, because fetch_calendar_events relied on the calendar-query REPORT's server-side time-range filter, which real servers implement inconsistently (confirmed against a real server, not just guessed -- reproduced locally with Radicale). Switched to fetching every event unfiltered and doing all date-window filtering/expansion client-side, same approach already used for plain ICS feeds. |
||
|
|
aa194be09a |
Calendar mode polish batch + Today & Tomorrow view
Responds to post-launch feedback on calendar mode: configurable week-start day for week/month views, crisper non-antialiased text (threshold-masked instead of drawn straight, so Floyd-Steinberg dithering doesn't speckle glyph edges), a color-coded/proportionally filled battery icon on the manage overlay, word-wrapped placeholder text so "Calendar isn't set up yet" no longer clips in portrait, photo inlay support extended from agenda-only to every view, and a fix so manage-overlay face labels reposition correctly when a photo inlay is active (they previously assumed the photo filled the whole canvas). Also adds a fourth calendar view, "Today & Tomorrow" -- a two-day agenda that reuses the same per-day row-layout helper the single-day agenda view already has. |
||
|
|
c007acde75 |
Add calendar frame mode + server-side manage overlay (server)
Build and push server image / build-and-push (push) Successful in 42s
calendar_feed.py/calendar_render.py: fetch/merge per-user ICS feeds, render agenda/week/month views. manage_overlay.py: composites the manage-button overlay server-side (QR, battery, location/date, share-QR, face labels), reused by every render mode. device.py/common.py wire both together: mode dispatch for /frame/image+advance+back, and the &manage=1 flag. Plus UI (frame_config.html Calendar card, settings calendar URL field) and the icalendar/recurring-ical-events deps. |
||
|
|
e1bca5a81a |
Fix false recharge-cycle detection from a single noisy battery reading
Build and push server image / build-and-push (push) Successful in 38s
A report was flagged as "the battery got recharged" (resetting battery_history and stats_recharge_cycles, and re-arming the low-battery alert) whenever it came in >= RECHARGE_JUMP_PCT above the single immediately-previous report. That's exactly what a real recharge looks like, but it's also exactly what a normal reading looks like right after one noisy low report: e.g. 60, 59, 58, then a stray 53, then back to a perfectly normal 58 -- 58 >= 53+5 falsely read as a recharge. Now compared against the max of the last RECHARGE_LOOKBACK (3) reports instead of just the one before it, so a lone stray reading doesn't get to set the bar a normal reading then trips. A real recharge still needs to clear all of them, so genuine recharges are still caught immediately (verified: 18% -> 90% still triggers, history still resets). Paired with the firmware-side battery.c change (trimmed-mean ADC sampling) that reduces how often a stray reading like the 53 above happens in the first place. |
||
|
|
d324bc4a57 |
Read battery once, after the picture is pushed, not at boot
Build and push server image / build-and-push (push) Successful in 40s
battery_read_percent() was called once at the very start of boot, before WiFi even connects, and that value was reused both for the manage-menu overlay and the server report. Taken right after a reset (e.g. the OTA reboot that immediately precedes it), the rail may still be settling -- plausible source of noisy jumps in reported battery level. Now there's a single read, in frame_client_run() right before report_battery(), after the photo (and manage overlay, if shown) is already on the panel -- the fetch/display work already done this cycle is the settle time, no delay to guess. The manage overlay no longer needs an early local reading at all: it shows the server's last-known value instead, added to the /frame/photo-info response it already fetches. |
||
|
|
49bc9f9ec9 |
Display mode: crop to fill, crop to faces, stretch to fill, shrink to fit
Build and push server image / build-and-push (push) Successful in 40s
Replaces the smart_crop_faces boolean with a 4-way display_mode select on each frame's Configuration tab (image_pipeline.DISPLAY_MODES): - Crop to fill / Crop to faces: the previous False/True behavior, unchanged (center-crop trimming excess, optionally shifted to keep faces on screen). - Stretch to fill (new): fills the panel exactly, aspect ratio not preserved -- a plain resize, no crop. - Shrink to fit (new): the whole photo visible, letterboxed with white where it doesn't fill the panel. Named-face overlay label positioning (face_labels.py, the manage menu's "who's in this photo") now goes through a shared _placement_transform() in image_pipeline.py instead of duplicating crop-box math, so label placement stays correct (and in-bounds) under all four modes, not just the two crop ones -- letterbox/stretch never crop a face out, so labels just use straight scale+offset math there. Schema migration v5 adds display_mode, backfills it from the old boolean (True/False -> crop_faces/crop_fill), and drops the boolean. Verified against the live-shaped test database: the migration (existing frames correctly preserved as crop_faces), the config page's new 4-option select, actual renders under letterbox (confirmed real white letterbox padding in the packed panel-code bytes) and stretch_fill, invalid-input fallback, and the standing legacy-device curl suite. |
||
|
|
5b11f2accb |
Per-frame palette calibration + sidebar battery indicator
Build and push server image / build-and-push (push) Successful in 40s
Advanced configuration (Configuration tab, collapsed <details> section):
a color picker per ink color (black/white/yellow/red/blue/green),
overriding image_pipeline.DEFAULT_PALETTE_RGB for that frame's actual
panel -- different units can vary enough from the documented
approximations to be worth calibrating once you can compare a rendered
photo against the real hardware. Stored as Frame.palette_rgb (NULL =
default, schema migration v4), threaded through render_frame/
render_placeholder/_quantize_and_pack (which now builds the PIL palette
image per call instead of once at import) so both photos and the
unclaimed/unconfigured placeholder screen respect it. "Reset to
defaults" clears back to NULL. Config-save validates exactly 6 #rrggbb
values, rejecting anything else with a 400.
Also: each frame's sidebar entry now shows its last-reported battery
percent (🔋NN%) next to the name, using the frame_dot's existing
recently-seen indicator conventions -- silent when never reported
(mains-only frames, or before the first report), matching how battery
is hidden everywhere else it's not applicable.
Verified against the same live-shaped database as the SMTP work: the
v3->v4 migration, save/reload/reset round trip through the real HTTP
route, an actual rendered image using a custom palette (confirmed via
its packed panel-code bytes), input validation, and the sidebar badge
against real battery data -- plus the standing legacy-device curl suite.
|
||
|
|
8e10ca540e |
Add SMTP email: password reset + per-frame battery-threshold alerts
Build and push server image / build-and-push (push) Successful in 40s
Admin-configured SMTP (server/port/username/password/from address/ STARTTLS, a singleton server_settings row set from /admin -- not env vars, since it's operator infrastructure a household admin sets up once through the UI) powers two features, both requiring the relevant user to have an email set in their own Settings: - "Forgot password?" on /login emails a one-hour single-use reset link (password_reset_tokens table). The endpoint always returns the same generic "check your email" response regardless of whether the address matched an account, so it can't be used to enumerate registered users. - A frame's Configuration tab can set a battery-alert threshold (Frame.battery_alert_threshold_pct, -1 = disabled); POST /frame/battery emails the owner the first time a report drops to or below it, then stays quiet for the rest of that discharge cycle (battery_alert_sent, reset alongside battery_history whenever the existing recharge-jump detection fires) -- not once per wake. New app/mail.py wraps stdlib smtplib (no new dependency); send_email() never raises, so a broken mail server can't 500 a battery report or a password-reset request. Schema migration v2 adds users.email and the two frame columns via ALTER TABLE (safe against the live, already- populated database) plus the two new tables via the existing create_all-based migration runner. Verified against a real (already-migrated, real user/frame data) database: the v1->v2 migration, admin SMTP config + test-email button, full forgot/reset-password roundtrip (including single-use token invalidation and the no-enumeration response), and the battery alert firing exactly once per crossing against a hand-rolled fake SMTP server -- all via curl end-to-end, plus the standing legacy-device curl suite to confirm the device protocol is untouched. |
||
|
|
683e3881b1 |
Redesign phase C: claim flow, limited manage page, device protocol
The frame-claiming pipeline, end to end. Firmware: every request now carries ?id=<12-hex STA MAC> via build_url (mirrored in build_ota_url), and the captive portal's success page became a redirect that hands the user's browser to <server>/claim?device_id=... after ~7s -- enough time for the phone to drop the provisioning AP while the device reboots. The server pushes a per-frame device token through /frame/config during a one-time handshake; the firmware persists it to NVS (a dedicated single-key write that deliberately doesn't reset the connected-once flag or WiFi cache) and prefers it over the provisioned shared token from the next request on. Config response buffer grows 256->512. Both board variants compile clean; new firmware also works against an old server (which ignores ?id=) and old firmware against this server (the phase A legacy mapping), so either deploy order survives. Server: /claim lands the captive-portal redirect -- claim-gated signup (a valid unclaimed/unregistered device id IS the enrollment invitation), pending claims for the user-beats-the-frame race (auto-attached at self-registration, 24h expiry), and a waiting page that refreshes until the frame checks in. Unclaimed/unconfigured frames get a rendered instruction placeholder with a QR from /frame/image (200, never an error loop) -- new qrcode dep, placeholder shares the exact quantize/pack path photos use. The on-frame manage QR now resolves to a limited no-login page: scans of / carrying device credentials (new ?id&token or the legacy shared token) 303 to /m/<manage_token>, which allows exactly view queue, show-next, advance, back, and scoped thumbnails -- no settings, no removal, no other frames. Full control means logging in. One real protocol hole found by simulating full wake cycles: after self-registration the device could never authenticate again (the wake cycle fetches the image BEFORE /frame/config delivers its token). require_device now treats the id itself as the credential until the first authenticated request flips device_token_ack -- the same trust level as open registration, closing permanently once the handshake completes. |
||
|
|
9fbbb8ed2b |
Redesign phase A: SQLite storage, per-frame data model, device identity
Replaces the single global config.json (whole-file pydantic model under one RLock) with SQLite via SQLAlchemy 2.0: users/sessions/frames/links/ pending-claims/battery_log tables (models.py), a per-frame lock registry (db.frame_locked) succeeding config.locked(), and hand-rolled schema versioning (migration.py). A pre-database deployment's config.json is imported verbatim as frame #1 on first boot and left untouched as the rollback path; the old single firmware.bin slot becomes per-frame firmware/<id>.bin. Routes split out of the 900-line main.py into routers/device.py (the frozen /frame/* protocol) and routers/api.py (web UI, still on the old single-frame paths for now). Device auth moves to require_device, which already speaks the full multi-frame protocol: per-frame device tokens pushed via /frame/config and acknowledged on first use, self- registration of unknown device ids as unclaimed frames, pending-claim attachment, and the legacy-token migration window that keeps the currently-deployed firmware (no id, shared MANAGEMENT_TOKEN) resolving to frame #1 -- including the one-time binding of its device id when it first reports one after a future OTA. Externally identical for existing deployments: same paths, same token semantics, same response shapes -- verified with a migration fixture, the legacy-device curl suite, a 20-way concurrent-advance smoke test, and a mutate-restart-assert persistence check against a fake Immich. photo_queue.py ports nearly verbatim onto the Frame ORM row (MutableList JSON columns make its in-place list mutations dirty-track); quiet-hours math extracted unchanged into quiet_hours.py. |