Commit Graph
20 Commits
Author SHA1 Message Date
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
Thomas Faour c1c657c0a9 Saved layouts feature
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s
2026-07-25 18:18:48 +00:00
Thomas Faour 3735c5bfa7 Add a text widget (rich text: bold/italic/underline, per-run color/highlight)
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 1m9s
A new self-contained widget type showing user-authored rich text -- no
live upstream to poll, like the static image widget, just word-wrapped
styled text instead of an uploaded image.

The dialog's contenteditable HTML is never stored or replayed as HTML:
app/text_content.py parses it server-side (on save) into a plain
paragraphs-of-styled-runs structure -- the actual sanitization
boundary, since raw HTML never round-trips back into any browser DOM
(the dialog rebuilds its editor from that same JSON via
createElement/textContent). app/widgets/text.py renders it with a
custom word-wrap/shrink-to-fit layout, using real vendored font weights
(app/fonts/NotoSans-{Regular,Bold,Italic,BoldItalic}.ttf, OFL-licensed
like the emoji fonts already there) rather than every other widget's
single ImageFont.load_default() -- the one widget type where that
distinction matters.
2026-07-25 14:21:52 +00:00
Thomas Faour b2f63601c0 Add a make-widget skill; belatedly document the static image widget
Build and push server image / test (push) Successful in 26s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 55s
The static-image widget shipped without updating docs/widgets.md's
per-type enumeration -- fixed, and added a skill encoding the full
file-by-file checklist a new widget type touches, so future ones (a
text widget is next) don't repeat either gap.
2026-07-25 11:56:14 +00:00
Thomas Faour 14c47aa2a0 Let a tasks widget merge multiple task lists, checkbox+color like calendar
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 2m8s
Build and push server image / deploy (push) Successful in 59s
Tasks widgets could only ever point at one CalDAV task list (a radio-
button picker, owner-only). Now they merge any number of included task
lists across every linked user, same checkbox-inclusion + optional
pinned-color shape a calendar widget already has for its calendars --
FrameTaskList mirrors FrameCalendar exactly, down to the same owner-
adds/anyone-mutes permission split (api_widget_task_list_select/
api_widget_task_list_color). Reused calendar_render._event_colors/
_draw_color_bar as-is for the per-task color bar -- a task dict's
owner_display_name/color_index is exactly that function's single-
source fallback shape.

Also added an opt-in "show tasks completed in the last 24 hours"
toggle (TaskWidgetConfig.show_completed): caldav_client.fetch_tasks
now accepts a completed_since cutoff and returns completed VTODOs
(with their completion time) instead of silently dropping them, and
_draw_tasks gives a completed task a filled checkbox + muted text
instead of the normal empty-box/due-date row.

Migration 18 splits the single-source TaskWidgetConfig columns
(added by 17, splitting tasks out of the calendar widget in the first
place) into frame_task_lists, carrying forward each widget's existing
single source as its first included list -- same shape migration 9
used carrying forward frame_calendars' old single opt-in.

Verified live in the browser (desktop + mobile): the new "Included
task lists" + "Recently completed" dialog sections, the show_completed
toggle actually persisting through a real HTTP round-trip, and no
regression in the calendar widget's own "Included calendars" dialog.
Full suite (192 tests, including new merge_tasks/config_save/migration
coverage) passes.
2026-07-25 03:39:26 +00:00
Thomas Faour b5c52004c8 Split the tasks feature out of the calendar widget into its own widget type
Build and push server image / test (push) Successful in 24s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s
Task lists used to be a week-view-only sub-feature bolted onto calendar
widgets (CalendarWidgetConfig.tasks_*), so a task list could only exist
tied to a calendar's view and only inside its footprint. Tasks are now
a standalone widget type (TaskWidgetConfig, app/widgets/tasks.py) that
can be placed and sized independently, same as photos/calendar/
whiteboard -- no separate "enabled" flag either, since being on the
grid at all is the on/off switch, matching every other widget type.

Migration 17 creates task_widget_configs, extracts any existing
calendar widget's configured task source into a new sibling tasks
widget (auto-placed in open grid space, source dropped+logged if truly
none is left), then drops calendar_widget_configs' now-dead tasks_*
columns in the same migration -- this project's usual same-migration-
drop convention. Also handles the rarer case of a database jumping
straight from before the widget system existed to after this split in
one boot, via the legacy Frame.calendar_tasks_* columns.

Verified live in the browser at desktop and mobile widths: adding a
Tasks widget, its own dialog (task-list source picker + preview), and
confirming the calendar widget's dialog no longer mentions tasks at
all. Full test suite (180 tests, including new coverage for the widget
render/actions, the migration's data-extraction path, and the
permission-boundary shape for tasks-source) passes.
2026-07-25 02:11:45 +00: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 4f4b2844e6 Firmware: WiFi fast-connect cache (skip scan + DHCP on the next wake)
After a successful home-WiFi connection, caches BSSID/channel and
IP/netmask/gateway/DNS in NVS. The next wake's first connect attempt
uses the cached BSSID/channel (skips the all-channel scan) and applies
the cached IP directly once the link comes up (skips DHCP) -- a couple
fewer seconds of radio-on time per wake, free every wake since nothing
about the network actually needs renegotiating most of the time.

Falls back to a normal scan+DHCP attempt, and clears the cache, if: the
fast attempt itself fails, or it "succeeds" at the WiFi layer but the
full fetch cycle then fails anyway (a stale cached IP/DNS/gateway that
associates but can't actually reach the server). Also cleared on
(re)provisioning and factory reset, since a new network shouldn't try
to reuse the old one's cache.

The static-IP path needed care to get right without touching untested
territory: esp_netif_set_ip_info() only posts IP_EVENT_STA_GOT_IP (what
the existing connect-wait logic blocks on) once the netif is already
up, which the internal netif-glue's own WIFI_EVENT_STA_CONNECTED
handler guarantees by running first (registered earlier, in
esp_netif_create_default_wifi_sta()) -- confirmed against ESP-IDF's own
static_ip example and esp_netif_handlers.c source rather than assumed.
Falling back after a failed fast attempt also needed an explicit
esp_netif_dhcpc_start() first: esp_netif_dhcpc_stop() leaves the netif's
DHCP status STOPPED rather than resetting to INIT, and left alone the
glue would silently re-post the stale cached IP on the next connect
instead of actually running DHCP (esp_netif_action_connected).

Version bumped to 1.1.0 (real feature, not just a fix); build-verified
clean on both board configs (devkit 8MB, XIAO 4MB), no new warnings.
2026-07-20 23:46:57 -04:00
tfaour 37145ef135 Docs: clarify battery divider resistor value is flexible, not fixed at 200k
It's a ratio divider -- what matters is the two resistors matching each
other, not hitting 200k exactly. Also note sdkconfig.xiao now enables
FRAME_BATTERY_ADC_GPIO by default, which the doc's "off by default"
line no longer reflected.
2026-07-20 23:08:31 -04:00
tfaour a3ab6c5f13 Firmware: OTA client, dual-board build (devkit/XIAO), version reporting, XIAO fixes
Build and push server image / build-and-push (push) Successful in 36s
- version.txt + esp_app_desc_t version reporting (X-Frame-Version header);
  new ota_update.c checks the server's advertised version against the
  running one and streams+applies an update via esp_https_ota, gated by
  bootloader rollback (marks the image valid only after a full successful
  cycle, so a bad update can't brick a wall-mounted frame).
- Dual-OTA partition tables: partitions.csv (8MB dev board, 2MB slots) and
  new partitions_xiao.csv (4MB XIAO, 1.875MB slots -- the dev board's
  table doesn't fit the XIAO's flash). New build_for_board.sh gives each
  board its own build dir + generated sdkconfig via SDKCONFIG_DEFAULTS
  layering, so switching boards never clobbers the other's config.
- fetch_photo_info()/fetch_face_labels() were using the short
  reachability-check timeout even though the manage-menu path can be the
  first (cold, TLS-handshake-paying) request of a wake cycle -- switched
  to the longer fetch timeout to stop spurious ESP_ERR_HTTP_CONNECT
  failures.
- XIAO: the RF switch that selects onboard vs. external antenna
  (GPIO3/14) isn't initialized by plain ESP-IDF the way Seeed's Arduino
  package does it, leaving WiFi unable to reliably reach the antenna at
  all -- new board_antenna.c powers the switch and selects the onboard
  antenna, gated behind FRAME_XIAO_ANTENNA_INIT (on by default in
  sdkconfig.xiao). Also remaps the EPD DC/RST/BUSY pins, since the dev
  board's defaults (GPIO9/10/11) aren't physically exposed on the XIAO.
2026-07-20 22:24:15 -04:00
tfaour 015993af00 Add battery level reporting (Kconfig-gated) and display orientation
Build and push server image / build-and-push (push) Successful in 32s
Battery (firmware + server, disabled by default): new battery.c reads
a 2x200k voltage divider via ADC oneshot with curve-fitting calibration
(the ESP32-C6's scheme), maps through a piecewise LiPo discharge curve,
and restores the pin to button duty after each read -- the settled
XIAO ESP32-C6 design shares the back button's GPIO0/A0, time-shared per
wake. Skipped entirely when on mains (a 2x100k VBUS divider into a
spare digital pin -- the 5V pin is dead on battery power, so presence =
mains, where the charging voltage would read misleadingly full) or when
the reading is implausible. The manage overlay gains a battery region
(static outline glyph + "NN%", below the manage QR, all menu levels),
and the device POSTs to the new /frame/battery endpoint after a
successful fetch; the server stores percent + as-of timestamp, exposed
via /api/queue and shown in the web UI. FRAME_BATTERY_ADC_GPIO /
FRAME_VBUS_SENSE_GPIO default to -1 (fully inert on the dev board);
compile-verified both disabled and enabled, hardware bring-up deferred
until the ordered XIAO + batteries arrive.

Orientation (server-side only): new config setting + web UI dropdown
(landscape / portrait / landscape_flipped / portrait_flipped). Photos
are composed/cropped at the logical hanging shape (portrait crops at
480x800, so face-aware crops match how the frame actually hangs), then
rotated losslessly into the panel's native 800x480 byte layout after
dithering -- the device never knows. Face-label anchors are transformed
through the same rotation (logical_to_native()) so they stay attached
to faces on rotated frames. Known documented limitation: the on-device
manage overlay still renders in native orientation, so it appears
sideways on a portrait-hung frame (QRs scan at any rotation; text reads
sideways).
2026-07-19 22:11:14 -04:00
tfaour d5de882b1e Fix stale documentation found by a doc-accuracy audit
firmware/README.md's HTTP vs HTTPS section still described the
public-CA-bundle trust approach that was tried and abandoned in favor
of pinning one specific certificate -- rewritten to match what's
actually there. docs/architecture.md was missing the back-photo button
entirely (sequence diagram and boot-flow bullets only covered next) and
still said the system talks "over plain HTTP" despite HTTPS support.
docker-compose.yml.example's MANAGEMENT_TOKEN comment understated its
scope (said "the web UI", omitting that every /frame/* endpoint is
gated too).
2026-07-19 15:20:12 -04:00
tfaour 3868d357ff Add back-photo button; consolidate reset/manage onto one hold-duration button
Build and push server image / build-and-push (push) Successful in 32s
Back button (new GPIO0, POST /frame/back): the server now tracks a
bounded history of previously-current photos (photo_queue.py), pushed
to on every advance (auto or forced) and popped by back_forced() --
symmetric with advance, so pressing next afterwards returns to right
where you were. frame_client.c's force_advance bool becomes a 3-way
fetch_action_t (NORMAL/ADVANCE/BACK) threaded through the whole fetch
path.

Also folds the separate reset and manage buttons onto one pin
(combo_button.c, replacing reset_button.c/manage_button.c entirely),
disambiguated by hold duration: quick press shows the management menu
(unchanged), ~3s hold-then-release soft-resets (esp_restart(), config
kept -- new), ~15s hold factory-resets (today's old reset behavior,
extended from 10s for clearer tier separation). Driven by a production
board (Seeed XIAO ESP32-C6) exposing only 3 of the ESP32-C6's 8
deep-sleep-wakeup-capable GPIOs -- next/back keep their own dedicated
pins where instant response matters most, everything else shares the
third pin via timing instead of needing its own. Same three-pin layout
now works on both the dev board and the production board.

Fixed a fast-tap bug in combo_button_check() before shipping: it only
did a live gpio_get_level() read to decide whether the button was
pressed at all, so a press fast enough to already be released by the
time boot reached that check was missed entirely (treated as "never
pressed" rather than "quick press"). Added the same latched
esp_sleep_get_gpio_wakeup_status() check the other buttons already use
for exactly this reason.
2026-07-19 12:53:26 -04:00
tfaour f74085cedf Add third button: "scan to manage" QR overlay on the current photo
Pressing the manage button (GPIO1) overlays a small QR code -- "SCAN TO
MANAGE" -- in the top-right corner of whatever photo is currently on
screen, linking to the server's config page, then reverts to the plain
photo after 30 seconds.

The overlay is spliced into the existing streaming fetch as chunks pass
through (frame_client.c's http_read_fn), rather than buffering the full
192,000-byte frame in RAM: only the small overlay rectangle itself
(~30KB) is ever held in memory, generated via new stride-parameterized
drawing helpers (epd_draw_*_ex in epd_draw.c) that let the existing
QR/text drawing code target an arbitrarily-sized buffer instead of a
full-frame one. epd7in3e.c is untouched -- it has no idea an overlay
exists.
2026-07-19 00:50:07 -04:00
tfaour b9649c35ec Skip redundant panel refreshes and fetch the image before the config check
The panel driver now splits writing a frame into its SPI buffer
(epd_write_frame(), which also computes a CRC32 as it streams) from
actually triggering the physical refresh (epd_turn_on_display()).
frame_client.c compares the new CRC against the last one that was
actually refreshed (persisted in NVS) and skips the refresh entirely
when they match -- e.g. a reboot redisplaying the same photo before the
server's refresh interval elapsed no longer causes a visible flash for
no visual change.

Also reorders the per-wake fetch cycle: the image fetch (15s timeout)
now goes before the config check (3s timeout), instead of after. The
config check's tighter timeout was intermittently tripping on
connection-setup latency that's common on the first request after
waking from a long deep sleep (e.g. stale ARP); putting the more
tolerant request first absorbs that latency, and the config check then
rides the connection it already warmed up.
2026-07-18 23:51:01 -04:00
tfaour d395cf3bb9 Add two physical buttons: factory-reset and next-photo
Build and push server image / build-and-push (push) Successful in 35s
Factory-reset (GPIO3, hold 10s): clears stored WiFi/server config and
restarts into provisioning -- the deliberate, USB-free replacement for
the earlier reverted RST-based auto-reprovisioning idea.

Next-photo (GPIO2, tap): wakes the device and forces the server to
advance immediately via a new POST /frame/advance, instead of waiting
for the refresh interval. Both buttons arm themselves as deep-sleep GPIO
wakeup sources so a press is noticed promptly even while asleep.

Also makes GET /frame/image side-effect-free: it now only advances once
refresh_interval_s has elapsed since the current photo was set (tracked
server-side), so a device reboot for any reason just redisplays the
current photo instead of silently skipping ahead. The server maintains a
small reorderable upcoming-photos queue, viewable and rearrangeable from
the web UI.
2026-07-18 23:28:36 -04:00
tfaour 7013311249 Add READMEs, docs, and LICENSE for publishing
Build and push server image / build-and-push (push) Successful in 32s
- LICENSE: MIT, with attribution notes for the vendored qrcode/epaper_fonts/
  dns_server code and the epd7in3e driver's transcription of Waveshare's
  register sequence.
- Top-level README.md: project overview, hardware list, quick-start
  pointing at firmware/ and server/, repo layout, license, Claude Code
  attribution.
- firmware/README.md: full rewrite (was still the stock ESP-IDF captive
  portal example's README) -- build/flash instructions, Kconfig reference
  table, first-boot walkthrough, and how to reset to provisioning mode via
  NVS erase (the only way in right now; a proper reconfigure trigger is a
  future addition).
- docs/hardware.md: wiring table + parts list + strapping-pin/SPI-speed notes.
- docs/architecture.md: sequence diagram and walkthrough of the full
  provision -> connect -> fetch -> display -> sleep cycle, plus the
  reasoning behind doing image processing server-side and reusing Immich's
  face detection instead of bundling a detector.
- server/README.md: fixed stale endpoint docs (missing GET /frame/config,
  POST /api/config still describing removed immich_url/api_key fields).
2026-07-18 16:47:30 -04:00