Commit Graph
100 Commits
Author SHA1 Message Date
tfaour eb7127718b Add battery widget (device's own last-reported level, no live upstream)
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 50s
Shows Frame.battery_percent/battery_as_of, already set by every device
wake-on-battery report, plus routers/common.py's existing
battery_estimate_s time-remaining estimate -- nothing new to fetch or
cache. Compact (icon + percent) or detailed (+ estimate, last report
age) display mode. No button actions.
2026-07-27 19:02:21 +00:00
tfaour 90a014d161 Revert to hand-drawn weather icons, styled after EC's set but exact panel colors
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 49s
The vendored EC bitmaps looked good but dither into a visible speckle
once quantized to the panel's 6-color palette (their colors are
anti-aliased/arbitrary RGB, essentially never an exact palette match).
Hand-drawn icons filled with the frame's actual ink colors quantize with
zero dithering error to diffuse -- confirmed by running both through the
real quantize pass: the bitmap version speckles, the hand-drawn one is
pixel-identical before and after.

Redrawn to look more like EC's style this time around: pointed
triangular sun rays (the earlier attempt's thin-line rays read as a
crosshair, not a sun) and dendrite snowflakes (tick marks near each tip,
not a bare asterisk), plus the same cloud/raindrop/lightning-bolt shapes
as before. Removed the vendored server/app/weather_icons/ directory
entirely -- no longer used, and removes the icon-image licensing
question along with it.
2026-07-27 17:32:51 +00:00
tfaour efb0f2e22d Swap hand-drawn weather icons for Environment Canada's real icon set
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m10s
Build and push server image / deploy (push) Successful in 51s
The hand-drawn glyphs (draw_cloud/draw_sun/draw_raindrop/draw_snowflake/
draw_lightning_bolt) are replaced by 7 vendored bitmaps, one per shared
weather category, sourced from weather.gc.ca's public icon set -- these
are small, flat-shaded images that dither cleanly onto the panel's
6-color palette and read as recognizable weather icons in a way the
hand-drawn attempt (a plain circle-with-ticks "sun") didn't. Used for
every provider's rendering (Open-Meteo, NWS, EC), not just when EC is
selected.

Vendored (not fetched live at render time), matching this project's
existing convention for the Noto Emoji fonts -- server/app/weather_icons/
SOURCE.md documents the source, attribution, and the licensing caveat
(this is a personal, non-commercial project; the icon images' own
copyright terms are less clearly permissive than the weather data's own
End-use Licence, since they're served from the public website rather
than ECCC's data servers).

draw_weather_icon's signature changes from (draw, cx, cy, r, category,
palette_rgb) to (img, cx, cy, r, category): pasting a bitmap needs the
Image object, not just an ImageDraw handle, and palette_rgb is no longer
needed since the shared _quantize step already maps whatever's on the
composited canvas to the frame's actual palette -- no per-icon color
resolution required anymore.
2026-07-27 17:22:59 +00:00
tfaour 270979949f Add Environment Canada as a third weather provider
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 4m32s
Build and push server image / deploy (push) Successful in 49s
app/weather/ec.py -- api.weather.gc.ca's MSC GeoMet OGC API
(citypageweather-realtime collection), the modern replacement for the
old dd.weatheroffice.gc.ca XML feed (that host no longer resolves).
Unlike Open-Meteo/NWS's simple lat/lon REST, this collection is only
queryable by bounding box, so _nearest_site widens the box
progressively and picks the closest of the ~844 sites by straight-line
distance -- capped at 300km, calibrated against a real bug caught in
development where an unconditional "nearest site, however far" matched
a Miami, FL query to a site in Ontario 1824km away once the box widened
to cover the whole country.

EC's own numeric icon codes get a small confirmed-against-live-data
mapping table plus the same keyword-on-condition-text fallback NWS
already uses for anything unmapped. Daily periods are named ("Today"/
"Tonight"/"Tuesday"/...) rather than dated, so dates are inferred by
walking them in issued order.

Verified end-to-end against the real live API (Toronto, rural
Saskatchewan, a US border city, and a rejected far-away match) and
through the browser (daily mode, composited panel preview). Test
fixtures mirror the actual response shapes captured live. docs/
widgets.md and CLAUDE.md's TODO updated -- EC is no longer a documented
gap.
2026-07-27 16:50:40 +00:00
tfaour 52ebafab78 Add standalone weather widget (current/hourly/daily/multi-city, pluggable providers)
Build and push server image / test (push) Successful in 1m11s
Build and push server image / build-and-push (push) Successful in 2m3s
Build and push server image / deploy (push) Successful in 52s
New widget type with four display modes -- current conditions, an
hourly forecast strip, a multi-day forecast, and several cities' current
day side by side -- backed by a pluggable provider registry (app/weather/,
mirroring the app/widgets/ dispatch pattern): Open-Meteo (worldwide) and
NWS (US-only) both wired up now, Environment Canada documented as the
next one to add given its more involved station/grid-lookup API.

The calendar widget's existing embedded weather strip is untouched and
still Open-Meteo-only; this lifts the same underlying icon-drawing
primitives (now shared via app/weather_render.py, calendar_render.py
still imports draw_weather_row unchanged) into a widget that can be
placed and sized on its own. Icons are redrawn in the panel's actual ink
colors (yellow sun/bolt, blue rain/snow) instead of flat black, and
build_multi_city's icon/font sizing now scales with how many cities need
to fit rather than the box's height alone -- both fixed after catching
them via live browser verification, along with a mode-switch cache-shape
crash and a mobile-width dialog overflow.

New WeatherWidgetConfig table (migration 24), grid footprint, widget
module, common.py fetch/cache helper, router endpoints (location set/
clear, city add/remove, preview), dialog template + JS, and full test
coverage (providers, widget render, HTTP endpoints, migration replay).
docs/widgets.md and CLAUDE.md's TODO updated accordingly.
2026-07-27 16:16:43 +00:00
tfaour 6118705c37 Fix corrupted text in CLAUDE.md
Two bullets had a stray "a \"coming up this week\" widget" phrase
overwriting their actual sentence ending (the AGPL bullet's "silently
accepting it)" and the mobile-breakpoint bullet's "below it"), likely
from an earlier bad edit. Restored both from git history; the phrase
still exists correctly once, as its own CURRENT TODO bullet.
2026-07-27 14:46:17 +00:00
tfaour 5247f5e512 Merge remote-tracking branch 'origin/main'
Build and push server image / test (push) Successful in 1m15s
Build and push server image / build-and-push (push) Successful in 5m1s
Build and push server image / deploy (push) Successful in 52s
Resolved CURRENT TODO conflict: kept Thomas's reformatted list and new
items, dropped the two scan-to-download lines this branch just finished.
2026-07-27 14:39:27 +00:00
tfaour 8bcc574f99 Update CLAUDE.md: mark scan-to-download TODOs done, add commit/push convention
Standing convention: commit and push once a task is verified working,
without waiting for a separate go-ahead each time -- see the new bullet
under Conventions specific to this repo.
2026-07-27 14:38:36 +00:00
tfaour c323402895 Fix scan-to-download auth and share every photo widget's current photo
The share QR's URL carried no auth params at all, so it silently fell
back through require_device's legacy-token resolution to whichever
frame happened to still be flagged legacy -- working only by accident
for a single frame, sharing the wrong frame's photos for any other, and
going fully dead once that frame's legacy flag was cleared.

Move the endpoint to manage.py, keyed on the frame's own manage_token
(same pattern /m/<manage_token> already uses) instead of device auth.
Since the server now resolves assets itself instead of trusting a
caller-supplied asset_id, it naturally generalizes to gather every
photo widget's current photo into one Immich share link, not just one
"primary" widget's.
2026-07-27 14:38:26 +00:00
tfaour 6fa3e2c2d2 Update CLAUDE.md
Updated todo
2026-07-26 23:39:33 -04:00
tfaour af513c1b5a Added a todo 2026-07-27 02:33:52 +00:00
tfaour c7b164e6cd Update CLAUDE.md 2026-07-26 18:40:40 -04:00
tfaour 5dfa6c8197 Update CLAUDE.md 2026-07-26 09:15:30 -04:00
tfaour e362519261 Update CLAUDE.md
Just adding some todos
2026-07-25 15:08:41 -04:00
tfaour 914eaed71c Add CLAUDE.md and docs/widgets.md; fix stale single-mode architecture docs
Repo-tracked context so a fresh Claude Code session (this machine or a
remote/cloud one) gets accurate project context without relying on this
session's local, machine-specific memory: repo conventions (no
co-author trailers, flag copyleft deps explicitly, scope security
gates broadly -- each backed by a real past incident), testing/deploy
workflow, and pointers into the existing docs.

docs/architecture.md's sequence diagram and boot-flow text still
described the pre-widget-system single-photo-queue model (e.g. "force-
advance to next queued photo") even though that was fully replaced by
the widget system across this branch's recent history -- fixed, and
added docs/widgets.md distilling the widget system's actual design
(data model, grid placement, compositor, button-action dispatch) as
current-state documentation, including the still-open Phase 6 cleanup
(legacy Frame columns not yet dropped) as a known gap.
2026-07-24 20:04:08 -04:00
tfaour 289d308b57 Disambiguate same-type widgets in the button-assignment dropdowns
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 2m0s
Build and push server image / deploy (push) Successful in 56s
Two widgets of the same type (e.g. two Photos widgets) both showed up
as plain "Photos" with no way to tell which was which. The buttons API
now includes each widget's grid placement plus the frame's grid dims;
the UI derives a rough position ("top-left", "right", etc.) from that
and only appends a number+position suffix when a type actually
repeats on the frame, leaving the common single-widget-per-type case
unchanged.
2026-07-24 18:00:01 -04:00
tfaour 8b9f636cce Mark whiteboard as (alpha); add the Phase 5 button-assignment UI
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 52s
Whiteboard rendering isn't fully reliable yet -- tag it (alpha)
everywhere it's user-facing (widget label, add-widget button, dialog
title, Settings' WebDAV section) via one shared WIDGET_LABELS map
(moved to common.js so both the Layout canvas and the new Configuration
tab section can use it).

Button assignments: a new "Button assignments" card on the
Configuration tab lets you assign an ordered list of (widget, action)
bindings to each physical NEXT/BACK button -- add/remove/reorder, all
autosaved. Backed by new GET/PUT /api/frames/{id}/buttons endpoints;
PUT replaces a button's whole list in one atomic, fully-validated call
rather than separate add/remove/reorder endpoints. Device-side
consumption already existed (routers/device.py's _run_button_actions);
this is the UI for what was previously only reachable via the default
migration mapping.
2026-07-24 17:13:51 -04:00
tfaour 9c8a87e90d Add a live preview thumbnail next to the frame name
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 48s
Small header thumbnail showing exactly what the frame is currently
displaying -- same widget compositor /frame/image uses, handed back as
a plain PNG (image_pipeline.render_panel/render_placeholder gain an
as_png option) instead of packed native-panel bytes. New session-authed
GET /api/frames/{id}/preview exposes it; click-to-refresh plus a slow
60s poll on the frame header so it doesn't hammer Immich/calendar
sources just for a header thumbnail.
2026-07-24 15:55:59 -04:00
tfaour 82f60ed428 Auto-reset device_token_ack when a reprovisioned frame is reclaimed
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 1m58s
Build and push server image / deploy (push) Successful in 53s
Once set, device_token_ack permanently locks out id-only requests
(auth.require_device) -- fine for a device that still has its token,
but a reprovisioned device has wiped its own token locally and had no
way back in short of a manual DB edit. The device's captive portal
always redirects the phone to /claim?device_id=... after
(re)provisioning, so reopen the handshake window there instead,
scoped to a logged-in owner/linked user of that frame.
2026-07-24 15:30:45 -04:00
tfaour 569bf733e9 Show save/error status inside the open widget dialog, not behind it
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 2m0s
Build and push server image / deploy (push) Successful in 54s
showStatus() always wrote to the page-level #result div, which sits
behind a <dialog>'s backdrop -- a save inside a widget's gear-icon
dialog produced a message the user couldn't see without closing the
dialog first. It now prefers a .dialog-result element inside whichever
<dialog> is currently open, falling back to #result everywhere else.

The dialog is a flex column with its body scrolling independently so
.dialog-result stays pinned as a visible footer regardless of scroll
position -- otherwise a save message on a long form (e.g. the calendar
dialog) could land off-screen below the fold with no visible feedback
at all.
2026-07-24 14:48:50 -04:00
tfaour cb11ffdd2d Fix Load Albums button in the photos widget dialog
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 56s
The dialog calls /albums via window.FRAME_API, but that's repointed to
this widget's own API base while a dialog is open (see frame_layout.js)
-- /albums is frame-level (lists the owner's whole Immich library, not
scoped to one photo widget), so it needs window.FRAME_BASE_API instead.
The button silently 404'd since the resulting URL never existed.
2026-07-24 14:39:01 -04:00
tfaour a33a3a71e4 Widget system Phase 4b: per-widget gear-icon config dialogs
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 1m57s
Build and push server image / deploy (push) Successful in 52s
Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.

Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.

API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.

UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.

Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
2026-07-24 14:31:24 -04:00
tfaour 63751a79ad Update .gitea/workflows/server-docker-build.yml
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m30s
Build and push server image / deploy (push) Successful in 54s
Fix user name
2026-07-24 13:39:02 -04:00
tfaour 86b94a9e64 Auto-deploy the server image to production over SSH after a build
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 1m56s
Build and push server image / deploy (push) Failing after 1s
Adds a deploy job to the existing build-and-push workflow: SSHes into
the deploy host as a dedicated espressoframeuser account and runs
docker compose pull && up -d. Runs only after build-and-push succeeds,
using a key/host pulled from repo secrets (DEPLOY_SSH_KEY, DEPLOY_HOST,
optional DEPLOY_PORT).
2026-07-24 13:34:50 -04:00
tfaour 77fe78d874 Fix Layout tab canvas not rendering on mobile
Build and push server image / test (push) Successful in 20s
Build and push server image / build-and-push (push) Successful in 1m58s
The placement canvas sized itself with CSS aspect-ratio plus
percentage widths/heights on the widget boxes. aspect-ratio isn't
supported on every mobile browser this app gets viewed from, and
without it a percentage height on an absolutely-positioned child
collapses to 0 against an indeterminate-height ancestor -- the canvas
rendered with no visible size at all, just unstyled labels and
buttons floating in normal document flow. Confirmed working on
desktop, broken on the reporter's phone.

Canvas and widget-box geometry is now computed and applied in pixels
from JS (measuring the wrap's own width, deriving height from the
grid's row/col ratio), recalculated on window resize. Also gives
.widget-box's color-mix() background a plain-color fallback for the
same class of older-browser gap.
2026-07-24 13:08:09 -04:00
tfaour 5d4bb53b8a Widget system Phase 4a: widget CRUD + grid placement UI
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m32s
Adds the actual "Android home screen" placement experience: a new
Layout tab with a pointer-driven canvas for dragging/resizing widgets
and adding new ones from a type picker. Backed by a new
routers/api_widgets.py (create/move/delete), which re-validates
bounds, minimum footprint, and no-overlap server-side regardless of
what the client already checked. A widget added without an explicit
position lands in the first open space that fits it
(grid.find_open_rect), so users don't have to hunt for empty space
themselves.

Also fixes a real latent bug this surfaced: changing a frame's
orientation swaps the widget grid's long/short axis, which left
existing widget placements out of bounds on the new grid with no
render-time safeguard. Orientation changes now reset the layout to a
single full-panel widget (keeping the first widget's type, dropping
the rest), with a client-side confirm before it happens.
2026-07-24 12:48:56 -04:00
tfaour 99069ba5fe Widget system Phase 3: calendar widgets become size-aware
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 1m58s
calendar_render.py's _build_* functions now take a real target box
and pick font sizes/margins from three discrete size tiers (nearest
pixel-area fit) instead of always laying out at full panel size and
resizing after the fact -- a calendar widget placed smaller than the
full panel gets an actually-legible layout instead of shrunk text.
Month view falls back to agenda below the smallest tier, where 7
columns can no longer stay readable.

The old photo-inlay split (inlay_region/_content_region/_paste_inlay)
is deleted along with it -- arbitrary widget placement already
subsumes what a fixed half-panel split did, and every call site has
passed photo_inlay=None since the Phase 2 cutover.

Also adds HTTP-level test coverage for GET .../preview/calendar,
which had none before this -- it's what caught a stale photo_inlay
kwarg left over from the _build signature change that would have
TypeError'd on every request.
2026-07-24 09:38:10 -04:00
tfaour 37bd657299 Widget system Phase 2: full cutover to widget-based rendering
Build and push server image / test (push) Successful in 49s
Build and push server image / build-and-push (push) Successful in 1m56s
device.py's mode-keyed dispatch is replaced by a real compositor:
load a frame's widgets, compute pixel rects via app/grid.py, render
each through its widget module, and composite with render_panel.
Physical NEXT/BACK buttons now execute each frame's assigned
FrameButtonAction rows instead of one hardcoded per-mode action.

api_frames.py, manage.py, and common.py's build_manage_content are
repointed to read/write the frame's widget config rows instead of
the old Frame columns, and every settings page (Photos/Calendar/
Whiteboard tabs) now pre-fills its form from the same widget config
the write endpoints actually save to -- previously the read and
write sides would have silently diverged. The old mode selector and
photo-inlay checkbox are removed along with their now-inert wiring;
arbitrary widget placement subsumes what the fixed inlay split did.

Ships together with Phase 1 (per-type render/action modules) since
splitting the read/write cutover across deploys would have left
settings changes with no visible effect.
2026-07-24 09:26:28 -04:00
tfaour f48daa71c8 Widget system Phase 1: per-type render/action modules
Build and push server image / test (push) Successful in 17s
Build and push server image / build-and-push (push) Successful in 2m2s
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).
2026-07-24 08:44:53 -04:00
tfaour 8bc0749b42 Widget system Phase 0: data model + migration
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m5s
First step of replacing Frame.mode (one renderer owns the whole panel)
with an Android-home-screen-style widget system -- a frame will hold N
independently placed/sized widgets (photos/calendar/whiteboard), each
with its own config/state, plus fully user-assignable NEXT/BACK button
actions. Full plan at .claude/plans/prancy-snacking-iverson.md.

This phase is additive only and changes no existing behavior -- nothing
reads these new tables yet:

- models.py: Widget (placement) + PhotoWidgetConfig/CalendarWidgetConfig/
  WhiteboardWidgetConfig (per-type 1:1 extension tables, matching this
  codebase's existing convention of dedicated tables for naturally-scoped
  state rather than one wide table) + FrameButtonAction (ordered
  (widget, action) bindings per physical button).
- grid.py: pure snap-to-grid placement math, defined relative to the
  panel's long/short axis so it stays valid across
  logical_render_size(orientation)'s genuine width/height swap for
  portrait, not just a rotation applied at the end.
- db.py: widget_locked(), the widget-scoped equivalent of frame_locked()
  -- deliberately still locks at frame granularity (not a new per-widget
  lock) to avoid a new class of multi-lock deadlock bugs.
- migration.py: _migration_16 creates the new tables; a separate
  _ensure_widgets_backfilled() (ORM-based, not raw SQL -- much less
  error-prone for this much per-mode branching) gives every existing
  frame a widget reproducing its exact current mode/settings, so
  upgrading changes nothing about what a frame displays or what its
  buttons do. calendar_photo_inlay frames specifically get two widgets
  (calendar + photo, split like the old inlay did) rather than silently
  losing the photo half.

10 new tests covering fresh-install backfill, re-run idempotency, the
photo-inlay two-widget case, whiteboard's check_now button mapping, and
migration_16's actual CREATE TABLE path against a simulated pre-existing
database (not just the fresh-install create_all() shortcut). Full suite
(69 tests) passes.
2026-07-24 08:27:47 -04:00
tfaour 1c67dd20d7 Auto-restart the whiteboard render sidecar if it crashes
Build and push server image / test (push) Successful in 16s
Build and push server image / build-and-push (push) Successful in 1m57s
Root cause of "whiteboard stuck on old content, no errors anywhere":
the Node sidecar had crashed at some point and, since it was just a
bare backgrounded process with nothing supervising it, stayed dead
permanently. Every refresh since then hit connection-refused, which
get_or_refresh_whiteboard treats as a soft failure and falls back to
the last successfully cached image -- so it looked exactly like a
caching bug from the outside, silently, forever, with no error visible
anywhere except a crash trace that had already scrolled out of the log
buffer.

Wrap it in a restart loop instead of a bare `&` so a future crash (a
still-unknown third jsdom/Excalidraw edge case, most likely) is a
few-second hiccup instead of a silent permanent outage.
2026-07-23 21:22:17 -04:00
tfaour 1100580c2c Live-update whiteboard/tasks source display; add firmware CI build check
Build and push server image / test (push) Successful in 15s
Firmware build check / build-check (push) Successful in 2m6s
Build and push server image / build-and-push (push) Successful in 2m26s
Whiteboard and tasks-source save/clear used to just tell the user to
reload the page to see the change. Both endpoints always assign a
successful "set" to the calling user, so the new state is fully known
client-side already -- rewrite the "Currently using/showing ..." block
in place instead, no server round trip or reload needed.

Also adds .gitea/workflows/firmware-build-check.yml: builds both board
variants (devkit, xiao) on every push touching firmware/**, unlike
firmware-release-build.yml which only builds on a version.txt bump.
Verified both builds succeed locally against the actual ESP-IDF
toolchain before wiring this in.
2026-07-23 19:08:01 -04:00
tfaour 31adc34a19 Add a real pytest suite, gating the CI build
Build and push server image / test (push) Successful in 1m18s
Build and push server image / build-and-push (push) Successful in 2m12s
64 tests covering: auth/setup and the CSRF gate, the "owner adds their
own data, anyone linked can mute it" permission pattern shared across
calendar-select/tasks-source/whiteboard-source, migration correctness
(fresh install, idempotent re-run, expected columns), battery estimate
outlier rejection, calendar_feed's fetch/merge/partial-failure handling,
webdav_client's fetch/list-directory, the whiteboard force-refresh
throttle bypass and browse endpoint, and render-size invariants across
calendar views/orientations.

No DB/HTTP fixtures need Docker, Node, or a real Immich/CalDAV/WebDAV
server -- a fresh temp SQLite file plus a couple of small local HTTP
servers as test doubles cover it all. Table data is wiped and reseeded
between tests rather than relying on SQLAlchemy's transaction-rollback
isolation pattern, which needs a pysqlite event-listener workaround
app/db.py's engine doesn't have and has no reason to gain just for tests.

Wired into .gitea/workflows/server-docker-build.yml as its own job that
build-and-push now depends on, so a failing suite blocks the image push
rather than just running alongside it for show.
2026-07-23 18:51:54 -04:00
tfaour b4ca795003 Add a whiteboard file picker and force-refresh
Build and push server image / build-and-push (push) Successful in 1m57s
File picker: an optional WebDAV browse root in Settings
(User.webdav_base_url) plus a plain-PROPFIND directory listing
(webdav_client.list_directory) power a "Browse..." panel on a frame's
Whiteboard tab, so a file can be clicked into rather than typing its
exact WebDAV URL. Manual URL entry still works unchanged either way.

Force-refresh: get_or_refresh_whiteboard takes a force flag that skips
the fetch throttle entirely; the Whiteboard tab's refresh button now
passes it, so clicking it always re-fetches and re-renders instead of
possibly just re-showing the same cached image from within the last
~20 minutes.
2026-07-23 18:20:39 -04:00
tfaour 8556221b08 Skip font inlining in whiteboard SVG export
Build and push server image / build-and-push (push) Successful in 2m6s
exportToSvg's font-embedding path (base64 @font-face rules) goes through
the browser FontFace API, which jsdom doesn't implement -- that's what
crashed fontFacesStylesGenerator after the previous global-shimming fix
got past startup. skipInliningFonts avoids that path entirely; resvg
already falls back to system fonts for rasterizing regardless, so
embedded fonts were never going to affect the final PNG.
2026-07-23 17:56:48 -04:00
tfaour dadd9ec164 Fix whiteboard render sidecar crash on startup
Build and push server image / build-and-push (push) Successful in 1m57s
@excalidraw/utils is a browser bundle -- it references devicePixelRatio,
location, matchMedia etc. as bare globals the way inline <script> code
would, not as window.foo. Only copying window/document/navigator onto
Node's global left everything else undefined, so the very first bare
reference threw a ReferenceError as soon as the module loaded. Copy
jsdom's entire window onto global instead, enable pretendToBeVisual so
jsdom actually populates devicePixelRatio/requestAnimationFrame, and
stub matchMedia since jsdom doesn't implement it at all.
2026-07-23 17:50:27 -04:00
tfaour c171047adf Split server Dockerfile into smaller layers
Build and push server image / build-and-push (push) Successful in 2m2s
The 413 from git.thumeit.com was on a single blob PUT, not the whole
image -- split the Node.js apt install and the render-service npm
install into several smaller RUN layers (purging curl/gnupg in the
same layer they're installed in, cleaning npm's cache after each
package) so no single pushed blob is as large as before.
2026-07-23 17:23:20 -04:00
tfaour afbe9db409 Revert "Push server image to local registry instead of git.thumeit.com"
This reverts commit 8ae09f238b.
2026-07-23 17:17:46 -04:00
tfaour 49794b4973 Revert "Configure BuildKit to allow plain HTTP to the local registry"
This reverts commit 1f62653118.
2026-07-23 17:17:46 -04:00
tfaour 1f62653118 Configure BuildKit to allow plain HTTP to the local registry
Build and push server image / build-and-push (push) Failing after 10s
Docker defaults to HTTPS for any non-docker.io registry, and
10.0.0.246:3000 (a bare LAN IP:port) isn't serving valid HTTPS -- the
push kept going out over HTTPS and failing. Adds a buildkitd config
telling BuildKit specifically to use HTTP for that host.

This only fixes the actual build+push step (BuildKit). The "Log in"
step runs a plain `docker login` through the classic Docker CLI/daemon
instead, which doesn't read this config -- that one still needs
10.0.0.246:3000 added to "insecure-registries" in the actual Docker
daemon's /etc/docker/daemon.json on whatever host runs the Gitea
Actions runner, followed by a daemon restart. That's runner-host
infrastructure outside this repo.
2026-07-23 17:12:33 -04:00
tfaour 8ae09f238b Push server image to local registry instead of git.thumeit.com
Build and push server image / build-and-push (push) Failing after 9s
Cloudflare (fronting git.thumeit.com) started rejecting image pushes
with 413 Payload Too Large once whiteboard mode's Node runtime + native
resvg bindings made the image significantly bigger than before. Switched
the build/push target to a LAN-local registry (10.0.0.246:3000) that has
nothing in front of it to hit that limit, and updated
docker-compose.yml.example to match.
2026-07-23 17:10:21 -04:00
tfaour 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.
2026-07-23 17:02:08 -04:00
tfaour 14cf212a60 Move Order and Display mode to the Photos tab
Build and push server image / build-and-push (push) Successful in 53s
Both are photos-specific settings (rotation order, how a photo's aspect
ratio gets reconciled with the panel), not device-wide configuration --
they belonged on the Photos tab next to the album/queue settings, not
Configuration. No backend change: both already save through the shared
partial-update /config endpoint regardless of which tab's form sends
them.
2026-07-23 09:07:49 -04:00
tfaour 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.
2026-07-23 08:17:25 -04:00
tfaour 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.
2026-07-23 07:58:13 -04:00
tfaour 01b9e9f1d0 Reject outlier readings in the battery estimate
Build and push server image / build-and-push (push) Successful in 51s
A single noisy ADC/regulator glitch (see firmware/main/battery.c)
survives the existing recharge filter: whichever way it reads, one of
the two steps around it (into a dip, or out of a spike) still looks
like an ordinary drop and got averaged straight into the remaining-
time estimate, letting one bad reading swing it dramatically.

Added a MAD-based modified z-score outlier check on top of the
existing recency-weighted average. Had to special-case the standard
MAD degenerating to exactly 0, which happens whenever more than half
the steps share the same value -- the norm for battery data (most
wakes cost the same small integer percent), and exactly the shape a
single spliced-in glitch among a steady discharge rate has, so the
naive case would have let the outlier this is for sail straight
through. Falls back to mean absolute deviation there instead.

Verified against constructed glitch scenarios in both directions
(spurious dip and spurious spike): estimate now comes out identical
to the same series with the glitch removed entirely.
2026-07-23 06:22:12 -04:00
tfaour 33af5408fd Render calendar emoji in full color
Build and push server image / build-and-push (push) Successful in 54s
Switched from the monochrome emoji font to color: NotoColorEmoji's
embedded CBDT bitmap glyphs, rasterized once at their native 109px
size and scaled to the target row height (unlike normal vector text,
color bitmap glyphs aren't stored at arbitrary sizes). Confirmed by
rendering an actual agenda row through the real quantizer that the
dithered-to-6-color result still reads clearly, not just muddy noise.

Falls back to the monochrome font if a deployment's Pillow/FreeType
wasn't built with embedded color bitmap support, so this degrades
instead of crashing or showing nothing.
2026-07-23 04:25:10 -04:00
tfaour 67d99dd6c0 Actually render emoji in calendar event titles; stack duplicate shared events
Build and push server image / build-and-push (push) Successful in 53s
The earlier fix stripped emoji instead of rendering them, which wasn't
what was asked for. Event titles now draw with two fonts: the usual
default font for text, and a vendored monochrome emoji font (Noto
Emoji, OFL-1.1) for actual emoji runs, so they show up as real glyphs
instead of a tofu box or nothing at all. Monochrome rather than color,
since reliably rendering COLR/CBDT color glyphs depends on how Pillow's
FreeType was built -- not something to depend on across deployments.

Also: events sharing the exact same title and time across different
calendars (e.g. a shared family event synced onto more than one
person's calendar) now collapse into one row instead of showing twice,
with a color bar split between every contributing calendar so it's
still clear whose event it is.
2026-07-22 23:13:34 -04:00
tfaour 7fc262f9c3 Strip emoji from calendar event titles before rendering
Build and push server image / build-and-push (push) Successful in 49s
ImageFont.load_default() has no emoji glyphs, and PIL/FreeType don't
skip an unsupported codepoint -- they substitute a visible ".notdef"
tofu box, which read as a rendering glitch rather than "not supported."
Stripped instead (covers the standard emoji Unicode blocks, skin-tone
modifiers, and the ZWJ used to combine them into one glyph), with
surrounding whitespace collapsed.
2026-07-22 22:58:06 -04:00
tfaour 27cd6b3703 Manual per-calendar color choice for calendar mode
Build and push server image / build-and-push (push) Successful in 49s
Each linked person can pin one of the panel's four non-black/white
colors (Yellow/Red/Blue/Green) to their own calendar instead of relying
on calendar_render.py's old auto-cycle-by-owner-name order -- owner-only,
like adding a calendar in the first place. Colors resolve against
whichever palette a frame actually renders with (including a custom
Advanced configuration override), so a pinned "Blue" stays this frame's
actual blue. Event color bars/dots are also bigger and rounded now
across agenda/week/month views, easier to tell apart at a glance.
2026-07-22 22:30:55 -04:00
tfaour 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.
2026-07-22 22:22:11 -04:00
tfaour acdb929a99 Move mode picker below device status bar; fix invisible calendar lines; add CalDAV calendar support
Build and push server image / build-and-push (push) Successful in 50s
Calendar rule/grid lines were light gray, which dithers away to
near-invisible on the 6-color e-ink palette -- now black.

CalDAV accounts (Nextcloud, Fastmail, iCloud, ...) can now be linked
alongside the existing single ICS subscription, since one account can
expose several calendars. A frame's Calendar tab now lists calendars
per person rather than one opt-in per person: your own row shows every
calendar you have available with a full add/remove toggle, while other
linked users' rows show only calendars they've included, toggleable
off (mute) but not on -- only a calendar's owner can add it to a
shared frame. FrameCalendar replaces the old single-boolean
UserFrame.calendar_included; existing opt-ins are migrated forward.
2026-07-22 22:01:05 -04:00
tfaour 95d69a5512 Rewrite battery-remaining estimate around per-wake drop rate
Build and push server image / build-and-push (push) Successful in 42s
The old estimate used a single linear percent/second rate from the
current discharge cycle's battery_history, which resets to empty on
every recharge -- so "not enough data yet" kept showing up despite the
frame having plenty of history overall, and the rate it did compute was
tied to whatever refresh interval produced it (changing the interval
didn't move the estimate until enough new history accumulated under
the new setting).

Now pulls the last 100 rows from the permanent battery_log table
instead, and averages the *per-wake* percent drop (not per-second) --
recharge jumps are skipped rather than counted as negative drain,
flat/zero-drop wakes still count so the rate isn't overstated, and
more recent steps are weighted more heavily. The per-wake rate then
converts to wall-clock time using the frame's current
refresh_interval_s and quiet-hours settings, so halving the refresh
interval roughly halves the estimate immediately, and quiet hours
correctly stretches it out (fewer wakes/day at the same per-wake cost).
2026-07-22 21:21:17 -04:00
tfaour 3a0007118c Move frame name/mode into the page header; Calendar gets its own tab
Frame name (pencil-icon inline edit) and the Photos/Calendar mode
selector now live in the page header, shared across all four per-frame
pages instead of being buried in the Configuration form -- so renaming
a frame or flipping its mode no longer requires navigating to a
specific tab first.

Calendar settings move out of a conditionally-hidden card on the
Configuration tab into their own dedicated tab (new /frames/{id}/
calendar route), visually greyed out when the frame is in Photos mode
but still fully usable so calendar settings can be configured ahead of
switching modes. Also wires the week-start setting into the UI for the
first time (the column/backend support landed earlier but had no
control anywhere).
2026-07-22 21:21:00 -04:00
tfaour 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.
2026-07-22 21:20:44 -04:00
tfaour 716776c3f2 Bump firmware to 1.3.0
Build and release firmware / build-and-release (push) Successful in 1m48s
Manage overlay now composited server-side.
2026-07-22 19:07:29 -04:00
tfaour 0f98d96d25 Untrack .claude/ (editor tooling, not project content) 2026-07-22 19:07:08 -04:00
tfaour 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.
2026-07-22 19:06:49 -04:00
tfaour 15e37c77cd Simplify firmware: manage overlay now composited server-side
Deletes manage_qr_overlay.c/.h; frame_client.c's manage-button flow is
now one fetch with &manage=1 instead of on-device QR/text generation
plus separate photo-info/face-labels requests.
2026-07-22 19:06:49 -04:00
tfaour 1fa1c68478 Add calendar frame mode data model (migration 7)
New columns, all ADD COLUMN with inert defaults -- no existing frame's
behavior changes until mode is explicitly switched to "calendar":

- users.calendar_ics_url: one personal iCal/CalDAV subscription per
  user, same shape as the existing per-user immich_url/immich_api_key.
- user_frames.calendar_included: explicit per-(user,frame) opt-in,
  default off. Being linked to a frame does not by itself contribute
  your calendar to it -- each person's calendar is their own data to
  share, not something a frame's controller decides on their behalf.
- frames.calendar_view/calendar_photo_inlay/calendar_browse_offset:
  per-frame display settings and NEXT/BACK navigation state.
- frames.calendar_checked_at/calendar_cached_events/calendar_fetch_summary:
  the throttled merge-fetch cache, same shape as the existing
  firmware_update_checked_at/firmware_gitea_latest_version pattern.
2026-07-22 19:05:02 -04:00
tfaour fc65b19cf2 Bump firmware to 1.2.4
Build and release firmware / build-and-release (push) Successful in 1m47s
Trimmed-mean battery ADC sampling to reduce noisy readings.
2026-07-22 16:57:34 -04:00
tfaour 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.
2026-07-22 16:51:37 -04:00
tfaour 845e4f9509 Reduce battery-reading noise with a trimmed-mean ADC sample
Sometimes a single reading came in noticeably off from the real trend
(a regulator/RF transient during sampling), and the next normal reading
would then look like a big jump relative to that bad one -- server-side,
enough to misfire the recharge-cycle heuristic (see the paired server
commit). Went from 8 raw-averaged samples to 16, sorted, with the 3
extreme samples on each end dropped before averaging the remaining 10 --
a handful of outliers can no longer skew the result the way a plain
average let them.
2026-07-22 16:51:28 -04:00
tfaour 02934b1d10 Bump firmware to 1.2.3
Build and release firmware / build-and-release (push) Successful in 1m48s
Fix stack buffer overflow in face-labels parsing.
2026-07-22 16:35:34 -04:00
tfaour f24c3b9c8e Gate firmware auto-check behind control+CSRF; skip faces with a null bounding box
Build and push server image / build-and-push (push) Successful in 39s
/api/frames/{id}/firmware/check could silently stage new firmware as a
side effect (the auto-apply path, when firmware_auto_update is on and
a newer release exists) but was gated by require_frame_view instead of
require_frame_control like its sibling firmware routes, and being a
GET, was exempt from the app's CSRF check (which only applies to
non-GET/HEAD/OPTIONS). A linked viewer without control -- or a
cross-site page riding a control-holding victim's session via a plain
GET -- could trigger an unreviewed firmware install. Now POST +
require_frame_control, matching /firmware/apply-latest; the frontend's
two callers (passive poll on page load, "Check now" button) both
already handle a 409 from a non-controller gracefully via the existing
apiError()/control-banner pattern, so this doesn't change UX for a
frame's actual controller.

Separately: Immich has been observed to return a face detection entry
with a null bounding-box field (a still-pending or otherwise
incomplete detection). Both places that do arithmetic on those fields
-- image_pipeline._face_aware_crop_box (crop_faces display mode) and
face_labels.compute_face_labels (manage-menu name labels) -- crashed
with an unhandled TypeError on such an entry, taking down that frame's
whole photo instead of the intended graceful fallback. Both now skip
any face missing a bounding-box field via a shared _has_bounding_box()
check; a face list with zero valid entries already degrades cleanly to
the plain center crop (the existing inf/-inf sentinel math already
handled "no faces" correctly, it just couldn't tell "none passed
Immich" apart from "one broken entry" before).
2026-07-22 16:21:37 -04:00
tfaour 38944a1287 Fix stack buffer overflow in face-labels parsing
fetch_face_labels() clamped the server-reported label count against
max_labels by casting the count to int first -- a value >= 2^31 (a
perfectly ordinary decimal in JSON) went negative under that cast, so
the comparison was always false and the clamp never fired. The loop
then ran with the full, unclamped count, writing past the caller's
fixed MANAGE_FACE_LABELS_MAX-element stack array on a crafted
/frame/face-labels response. Reachable by a compromised/malicious
tools server, or a MITM on the default plain-HTTP connection.

Fixed by comparing unsigned instead of casting to int.
2026-07-22 16:21:23 -04:00
tfaour 5b4fdbe330 Scope thumbnail access to the frame's own photos; validate Gitea repo URL
Build and push server image / build-and-push (push) Successful in 38s
Two fixes from a security pass over the server:

- /api/frames/{id}/thumbnail/{asset_id} accepted any asset id and
  fetched it via the frame owner's Immich credentials, unscoped to what
  that frame actually shows -- a user merely linked to view a frame
  could pull thumbnails for any asset in the owner's whole library, not
  just the frame's own album. Now scoped to current_asset_id/queue,
  matching the check device.frame_share and manage.manage_thumbnail
  already both apply.

- firmware_update_repo_url now has to be a plain http(s) URL. Unlike a
  one-off manual firmware upload (a deliberate, explicit act -- left
  alone), auto-update from a repo is a standing trust relationship: the
  frame keeps fetching from it and, with auto-update on, installs
  whatever it finds with nobody reviewing it first. Added a plain-
  language note next to the checkbox saying exactly that.
2026-07-22 12:57:11 -04:00
tfaour dcbc71e683 Show "Not enough data yet" instead of hiding the battery-estimate row
Build and push server image / build-and-push (push) Successful in 40s
Previously the row just disappeared whenever battery_estimate_s
couldn't be computed yet, which looked like the feature was gone.
Now it always shows once there's any battery reading at all, with a
placeholder until enough discharge history accumulates (matches the
"Not enough data yet." wording battery_chart.js already uses for the
same situation on the chart).
2026-07-22 11:35:38 -04:00
tfaour f4d2a23e8a Drop "On battery for", clarify the remaining-estimate label
Build and push server image / build-and-push (push) Successful in 38s
"On battery for" was clutter next to the actual number people care
about. Relabeled "Est. remaining" to "Est. battery life left" and
dropped the now-unused on_battery_since field from the /queue response.

battery_estimate_s itself is unchanged -- it still needs 2h of span and
a 2% drop within the current discharge cycle (reset on any 5%+ jump,
i.e. a recharge or reflash) before it'll show anything. A frame that's
been power-cycled/reflashed recently won't have an estimate yet; that's
expected, not a regression.
2026-07-22 11:29:17 -04:00
tfaour 55b53d5bb2 Fix migration runner crashing on a genuinely fresh database
Build and push server image / build-and-push (push) Successful in 39s
_migration_1() is Base.metadata.create_all() -- it already builds
today's full schema straight from models.py. Every migration after it
is an incremental ALTER/UPDATE meant to bring an *existing* install
forward from an older version; replaying them against a brand-new
database collided with columns create_all had already added ("duplicate
column name"), crashing on first boot.

Found while testing the device-status-bar change against a scratch DB.
Every real deployment has been migrating forward incrementally since
before this bug existed, so it never showed up in practice -- but any
brand-new install would have hit it. Fresh databases now jump straight
to the latest schema_version after create_all; existing databases keep
applying whichever migrations are still pending, same as before.
2026-07-22 10:48:36 -04:00
tfaour 60fcfca4a0 Make the device status card always visible, not just on Stats
Build and push server image / build-and-push (push) Successful in 39s
Moved out of the Stats tab's side column into a new horizontal bar
shared by every frame page (Photos/Configuration/Stats), sitting
between the page title and the tabs so it's on screen regardless of
which tab is active.

_device_status_bar.html is a new partial included via a device_status
block in app_base.html; device_status_bar.js is the fetch/render/poll
logic extracted from frame_stats.js and adapted to a wrapping row of
label/value pairs instead of a stacked list. frame_stats.html's Device
card and now-single-card .side-col are gone -- Battery history and
Lifetime stats just stack directly.
2026-07-22 10:46:05 -04:00
tfaour 996e06e2bc Bump firmware to 1.2.2
Build and release firmware / build-and-release (push) Successful in 1m51s
Read battery once, after the picture is pushed, instead of at boot.
2026-07-22 10:32:46 -04:00
tfaour 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.
2026-07-22 10:32:20 -04:00
tfaour ac4b57e611 Bump firmware to 1.2.1
Build and release firmware / build-and-release (push) Successful in 1m48s
Captive portal redirect fix.
2026-07-22 09:48:34 -04:00
tfaour 462b558bef Fix captive portal redirect: visible countdown, keep AP up until it finishes
Previously the softAP was torn down (esp_restart) only 1s after sending
the success page, while the page's own redirect timer waited 7s -- so
the AP (and the phone's captive-portal session with it) was gone long
before the redirect could fire. Now the page shows a live 10s countdown
before redirecting, and the device holds the AP up for 11s so the
countdown always completes. Also added a "Redirect now" button for a
phone that's already reconnected to normal WiFi.
2026-07-22 09:45:59 -04:00
tfaour 9f9ad34a40 Fix stray tab whitespace in DEFAULT_PALETTE_RGB
Build and push server image / build-and-push (push) Successful in 41s
2026-07-22 08:46:46 -04:00
tfaour e48ac50ea1 Color/contrast/dithering sliders + before/after render preview
Advanced configuration gains three sliders (PIL ImageEnhance factors
for color/contrast, 0-2, 1=unchanged; a 0-1 dithering strength) applied
to every photo this frame renders. Confirmed the parameter conventions
against a similar project (jwchen119/EPF: ImageEnhance.Color/Contrast,
1.0 baseline) before implementing; dithering strength isn't natively
exposed by PIL's quantize(), so it's implemented by blending the source
toward its own flat/undithered quantization before running Floyd-
Steinberg on the blend -- at 0 there's no quantization error left to
diffuse (exactly the flat result), at 1 it's the original unmodified
behavior, with a smooth continuum between rather than dithering being
an on/off toggle.

image_pipeline.py split into composition (_compose), enhancement
(_enhance), quantization (_quantize), and transpose+pack stages so
render_frame (device bytes) and the new render_preview_png (a normal
viewable PNG, upright logical orientation) share the same pipeline
instead of duplicating it. Named-face overlay label math (face_labels.py)
was already routed through the shared _placement_transform, so it
needed no changes for the new params.

Also added the requested before/after comparison: the Configuration
tab's new Preview card shows the current photo's untouched Immich
preview next to that same photo run through the frame's actual saved
rendering pipeline (two new GET endpoints, /preview/original and
/preview/rendered) -- immediate visual feedback for tuning the palette
and these new sliders. "Refresh preview" re-fetches after saving.

Schema migration v6 adds color_boost/contrast_boost/dither_strength,
defaulting to 1.0/1.0/1.0 -- reproduces the exact previous rendering
until a frame's Configuration tab changes one.

Verified against the live-shaped test database: the migration, sliders
persisting and clamping out-of-range input, both preview endpoints
(real JPEG passthrough / real PNG at correct logical size+orientation),
confirmed dither_strength=0 actually changes the rendered bytes vs.
default, and the standing legacy-device curl suite.
2026-07-22 08:46:22 -04:00
tfaour 83c59af1dd Update server/app/image_pipeline.py
Build and push server image / build-and-push (push) Successful in 46s
Fix pallette defaults
2026-07-22 01:33:56 -04:00
tfaour 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.
2026-07-22 01:32:52 -04:00
tfaour e802882fc1 Palette calibration: precise hex/RGB inputs instead of a color picker
Build and push server image / build-and-push (push) Successful in 41s
A native <input type="color"> swatch can't be typed into precisely --
no way to enter an exact measured value. Replaced with a small table:
a read-only preview swatch, a hex text field, and three 0-255 number
fields (R/G/B) per ink color, kept in sync live in both directions
(editing hex updates R/G/B and the swatch; editing any of R/G/B updates
hex and the swatch). Hex stays the field actually read at save time --
the server-side validation (#rrggbb via hex_to_rgb) is unchanged, this
is a client-side-only swap of the input widget.
2026-07-22 01:26:03 -04:00
tfaour 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.
2026-07-22 01:19:06 -04:00
tfaour c1c803b497 SMTP: implicit TLS (port 465) support + fix quarantined mail
Build and push server image / build-and-push (push) Successful in 38s
Two real fixes to app/mail.py, both found by testing against an actual
mail server rather than just a fake stub:

- Replaces the STARTTLS-only smtp_use_tls boolean with a three-way
  smtp_encryption ("none"/"starttls"/"ssl"). Implicit TLS (port 465,
  what Purelymail and most providers offer alongside 587/STARTTLS) is a
  different handshake entirely -- TLS from the first byte, not a
  plaintext connection that gets upgraded -- so it needs its own
  smtplib.SMTP_SSL code path, not just a skipped starttls() call.
  Schema migration v3 adds the column, backfills it from the old
  boolean, and drops the boolean (safe on a live, populated DB).

- Outgoing mail was missing Date and Message-ID headers -- email.mime
  doesn't set either automatically, and a missing Message-ID in
  particular is enough for a strict content filter (confirmed via a
  real Postfix+Amavis mail server's logs: SPF/DKIM/DMARC all passed
  cleanly, but Amavis quarantined the message as "BAD-HEADER-0" purely
  for the missing id) to silently swallow an otherwise-legitimate
  email, even though smtplib reports success -- the send genuinely
  succeeds to the relay, it just never survives the recipient's own
  filtering. Both headers are now set, with the Message-ID's domain
  matching the From address.

Verified: SMTP_SSL path against a hand-rolled implicit-TLS fake server
(self-signed cert, client-side verification relaxed only in the test
harness -- production code keeps ssl.create_default_context()'s real
verification), the v2->v3 migration against live data, the full admin
SMTP-save + test-email round trip over HTTP, and the standing legacy-
device curl suite.
2026-07-22 01:07:50 -04:00
tfaour 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.
2026-07-22 00:51:54 -04:00
tfaour a45444ab4b Bump firmware to 1.2.0
Build and release firmware / build-and-release (push) Successful in 1m54s
2026-07-22 00:34:07 -04:00
tfaour 8ac3fc0de3 Redesign phase D: sidebar app shell, per-frame tabs, namespaced API
Build and push server image / build-and-push (push) Successful in 43s
The web UI grows into the multi-frame world: a left sidebar lists the
user's frames (with an online dot driven by the same overdue math as
the Device panel; collapsible off-canvas with a hamburger on mobile),
and each frame gets three tabs -- Photos (album picker, now displaying,
the drag-to-reorder upcoming grid), Configuration (name/order/
orientation/refresh/quiet hours/timezone/smart crop + the firmware
card), and Stats (device telemetry, lifetime counters, battery chart).
Settings and Admin adopt the same shell. / becomes a routing hub:
first frame, empty-state onboarding page, setup/login, or the
manage-QR redirect.

The JSON API moves to /api/frames/{id}/... behind require_frame_view /
require_frame_control: any linked user (admins see all) can view; 404
for frames outside your view so ids aren't confirmed; mutations 409
with the holder's name unless you hold the soft control lock, and
POST take-control always flips it to you. Config saves are now partial
updates -- each tab posts only its own fields (checkboxes always sent
explicitly), so the split forms can't clobber each other.

All CSS moves to static/theme.css and the old 680-line inline script
block splits into static/*.js -- the Pointer Events drag-drop state
machine and the canvas battery chart ported intact, not rewritten. The
CSRF fetch wrapper now reads a <meta> tag. No build step, still vanilla.

Verified end-to-end: page/static/API suites, control-lock handoff in
both directions, partial-save field preservation, non-admin frame
isolation, and the legacy-device curl suite (still byte-identical
responses for the deployed frame).
2026-07-21 23:56:18 -04:00
tfaour 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.
2026-07-21 23:44:22 -04:00
tfaour 1e8d6803ac Redesign phase B: users, sessions, first-run setup, admin panel
Real identity on top of phase A's schema: scrypt-hashed passwords
(stdlib, no new deps -- parameters baked into each stored hash),
server-side sessions (sha256 of the cookie value stored, 30-day rolling
expiry), and per-session CSRF tokens enforced on every mutating
session-authed request -- via X-CSRF-Token for the JSON API (a fetch()
wrapper in base.html injects it, so the existing page scripts didn't
need touching) and a hidden form field for the HTML forms.

/setup runs once while no users exist: creates admin #1, links every
existing frame to them (owner + controller), and inherits the migrated
Immich creds onto their account -- per-user creds are now the primary
source, with env vars still winning as the operator fallback. /login,
/logout, /settings (display name, Immich creds, password change), and
/admin (enroll users, reset passwords, link users to frames, close a
frame's legacy-token window, delete) round out the pages, all in the
existing template/card style.

The legacy shared token stays accepted on browser routes so the
deployed frame's on-panel manage QR keeps working until phase C swaps
it for the limited manage page; token access renders without nav or
CSRF shim and is exempt from CSRF (explicit credential, not an ambient
cookie). Device routes untouched -- the legacy curl suite passes
verbatim.

Identity is provider-pluggable (identity_provider/provider_subject
already modeled) so OIDC can land later without schema surgery.
2026-07-21 23:28:14 -04:00
tfaour 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.
2026-07-21 23:21:38 -04:00
tfaour 6a0072e383 Add a "Check now" button to the Firmware update card
Build and push server image / build-and-push (push) Successful in 36s
GET /api/firmware/check's 15-minute throttle meant a genuinely new
Gitea release could sit invisible in the UI for up to that long even
though POST /api/firmware/apply-latest (unthrottled) would've picked
it up immediately. New ?force=true bypasses the throttle for an
explicit check; the button wires it up and surfaces errors instead of
failing silently like the passive poll.
2026-07-21 22:29:05 -04:00
tfaour fdb5dc7ab3 Bump firmware to 1.1.2a (test build)
Build and release firmware / build-and-release (push) Successful in 1m49s
2026-07-21 22:22:04 -04:00
tfaour 0e50529343 Bump firmware to 1.1.2
Build and release firmware / build-and-release (push) Successful in 1m49s
2026-07-21 22:17:12 -04:00
tfaour b988799fb3 Make the firmware repo URL Edit button smaller/inline-sized
Build and push server image / build-and-push (push) Successful in 36s
2026-07-21 22:14:55 -04:00
tfaour b8fd8f4ad4 Trim firmware card verbosity; Gitea repo URL becomes text+Edit once set
Build and push server image / build-and-push (push) Successful in 35s
Dropped the paragraph explaining the release workflow builds the
binaries -- not something the web UI needs to narrate. The repo URL
field now shows as plain text with an Edit button once a value is
saved, instead of always being an open input.
2026-07-21 22:10:04 -04:00
tfaour 7ea5c9a4fb Consolidate firmware UI; server learns board from the device, not a picker
Build and push server image / build-and-push (push) Successful in 37s
All firmware-related controls (manual upload, Gitea repo URL,
auto-update checkbox, detected board, Update frame button) now live in
one "Firmware update" card instead of being split across the main
Settings form and a separate card.

The board variant used to pick a Gitea release asset was a dropdown
the user had to set by hand and could get wrong. The device now
reports it itself via a new X-Frame-Board header (CONFIG_FRAME_BOARD_NAME,
"devkit" by default, "xiao" in sdkconfig.xiao) on every /frame/config
poll, stored as device_board_variant -- the server learns it instead.
Update checks/applies are gated on the board being known, since there's
nothing to fetch until a device has checked in at least once.
2026-07-21 21:58:59 -04:00
tfaour ad1fa99064 Fix firmware release workflow: build against ESP-IDF v6.0, not v5.3
The build container was pinned to espressif/idf:release-v5.3, but the
firmware is actually developed against v6.0.2 (this machine's
$IDF_PATH). That gap caused real compile failures in CI --
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown and
ESP_NETIF_CAPTIVEPORTAL_URI are both newer than v5.3 has, plus a
transitive stdbool.h include that changed between versions -- none of
it a real bug in the source.
2026-07-21 21:41:36 -04:00
tfaour ecc5b8d197 Fix firmware release workflow: docker cp instead of a bind-mount
The runner's job workspace lives in a named Docker volume, not a real
host path, so a nested docker run -v "\$PWD:/workspace" bind-mounted an
empty directory when talking to the host daemon (which has no notion of
paths inside the calling container) -- build_for_board.sh: No such file
or directory. docker create/cp/start streams the checkout into the
build container through the Docker API instead, which works regardless
of what backs either side's workspace.
2026-07-21 21:35:30 -04:00
tfaour 9e630c0c6e Fix firmware release workflow: run checkout outside the ESP-IDF container
espressif/idf:release-v5.3 has no Node.js, and actions/checkout (a Node
action) runs inside whatever container: the job specifies -- it failed
immediately with "node: not found" on the first real run. Checkout now
runs on the plain runner; only the two build steps spin up the ESP-IDF
image themselves via docker run, using the runner's already-mounted
docker socket.
2026-07-21 21:32:00 -04:00
tfaour ca06f8a8c7 Bump firmware to 1.1.1
Build and release firmware / build-and-release (push) Failing after 2m3s
2026-07-21 19:39:27 -04:00
tfaour 94d67f7767 Firmware CI releases + Gitea auto-update in the server
Build and push server image / build-and-push (push) Successful in 39s
New Gitea Actions workflow builds both board variants and publishes
them as release assets whenever firmware/version.txt is bumped. The
server can now poll that repo's releases (next to the existing manual
upload) and either surface an "Update frame" button or, with
"Automatically apply updates" checked, stage the new build itself --
the frame still only updates on its own next wake either way.
2026-07-21 19:25:08 -04:00
tfaour b95d03f56a Server web UI: make photo-card dragging actually feel like dragging
Build and push server image / build-and-push (push) Successful in 34s
The card being dragged never moved -- it just faded in place while a
static outline highlighted whatever was underneath the finger. Now the
card tracks the pointer 1:1 (translate + a slight scale-up "lift"),
gets a stronger shadow while airborne, and leaves its grid slot looking
like an empty gap until dropped (transform doesn't remove it from
layout flow, so the reserved space stays put -- no reflow needed until
drop). pointer-events:none while dragging so elementFromPoint's
drop-target hit-test sees through to the card underneath instead of
hitting the translated one.

Also: a short vibration tick when the touch hold-to-arm fires and
another on a successful drop (Chrome/Android only, iOS Safari has no
Vibration API -- harmless no-op there), and trimmed the arm delay from
350ms to 250ms now that there's actual feedback confirming the hold
registered.
2026-07-21 00:22:38 -04:00