/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).
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.
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.
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).
"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.
_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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The right column had grown to 5 stacked cards (Now displaying, Device,
Battery history, Firmware update, Stats) against the left's single
Settings form, running noticeably longer. Moved the collapsed Stats
card into a new left-column stack under Settings -- it's already
de-emphasized by design, so pairing it with the other
occasionally-checked column reads naturally, and it's small enough
(collapsed by default) to roughly balance the two columns' heights
without overcorrecting the other way.
Quiet hours only clamped what refresh_interval_s the *device* is told
to sleep for -- the separate elapsed-time check in
photo_queue.get_current() (used by /frame/image, /frame/photo-info,
/frame/face-labels, and /api/queue) had no quiet-hours awareness at
all, since it runs independent of the device. An open web UI tab
polling /api/queue overnight, or just loading the page during a quiet
window, would silently advance which photo is "current" on raw elapsed
time alone -- nothing reaches the panel until the device wakes after
quiet hours end, but the pointer moving mid-window still isn't what
"don't do anything overnight" implies.
get_current() now takes an in_quiet_hours flag that suppresses only the
elapsed-time trigger; an unset/invalid current photo is still picked
regardless (showing nothing is worse than showing something even at
3am). New _in_quiet_hours() helper in main.py, passed at all four call
sites.
New FrameStats (first_seen, device_wakes, photos_displayed,
photos_removed, battery_reports, recharge_cycles, ota_updates_applied,
config_saves), persisted alongside everything else in config.json.
Incremented at the existing route/photo_queue.py call sites that already
own each event -- no new instrumentation plumbing, no behavior depends
on these, purely informational. GET /api/stats serves them; the web UI
renders them into a native <details> "Stats" card (collapsed by default,
no JS needed for the expand/collapse), fetched once on page load like
the battery-history chart.
Verified: TestClient run through /frame/config (wakes + first_seen +
OTA-applied detection), /frame/battery (reports + recharge detection),
/api/config (saves); direct photo_queue.py unit checks for
advance/back/remove covering the "did the current photo actually
change" distinction (removing a queued-but-not-current photo bumps
photos_removed but not photos_displayed).
A press-and-drag gesture over a card's badge/button text also triggered
the browser's native selection highlight, distracting and occasionally
fighting the pointer-based drag tracking closely enough to break it.
Replaces the ad-hoc inline styling with a shared base template driven by
CSS custom properties (light/dark palettes), a card-based two-column
layout, and a persistent dark-mode toggle. Also moves quiet-hours'
timezone from the container's TZ env var into a proper web UI setting
(zoneinfo-backed), so it no longer needs a docker-compose.yml edit and
restart to change.
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.
Purely a server-side decision: GET /frame/config hands back a longer
refresh_interval_s while quiet hours are in effect (exactly the seconds
until they end), and clamps the normal interval so the device's next
wake lands at the boundary instead of wandering into the window, when
outside it but approaching. A device already mid-sleep when quiet hours
begin can still land one wake inside the window -- unavoidable without
touching the firmware, since it has no wall-clock awareness -- but from
that wake on it sleeps straight through to the end.
Window is "HH:MM"-"HH:MM", wrap-past-midnight aware (e.g. 22:00-07:00),
in the server's local timezone -- added tzdata to the Dockerfile since
python:3.12-slim doesn't include it and TZ would otherwise silently
resolve to nothing and fall back to UTC.
Also fixed the "overdue" device-status check to account for quiet hours:
without this it would falsely flag a device sleeping through a long
quiet window as unreachable.
battery_history stays cycle-scoped (reset on recharge, feeds the "on
battery for"/estimate numbers), but nothing kept a permanent record --
added battery_log, appended on every report and never reset, capped at
~2 years of hourly reports as a sanity bound rather than a real limit.
New GET /api/battery-log serves it; the web UI draws it as a plain
canvas line chart (no chart library) under a new "Battery history"
section, loaded once on page load.
Also caught up server/README.md, which never documented the OTA
firmware endpoints or the /api/queue response's current "device" shape
from the earlier status-panel work.
Two independent timers: a 1s tick re-renders "Last seen"/"On battery
for" from the already-fetched device data (so they count up smoothly --
1s ago, 5s ago, 1m ago...) without hitting the server that often, and a
10s poll re-fetches /api/queue to pick up real changes (new photo
displayed, queue edited elsewhere, a battery/firmware report) -- both
reuse the existing render functions, no new endpoints needed. The poll
skips itself while a drag-reorder is in progress so it can't yank the
grid out from under an in-flight drag.
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.