Changed
- Decks are now Lineups (#167). The page is renamed and regrouped
per display: each display with something lined up gets its own section
with a live-status header, the device's configured icon, and device
chips on every row; displays with nothing lined up don't appear. Kind
labels are now Rotation and Schedule, enable and delete sit behind an
overflow menu, and the standalone new-deck, new-timed-send and
new-timer-cycle buttons are gone: the setup wizard, with deck
suggestions folded in as its intro screen, is the sole entry point.
The stored records, MCP tools, and REST envelope are unchanged.
Changed
- The display setup wizard is a three-step flow. Behaviour, then
details (or pages), then a plain-language review, then a created
screen. It submits through the existing create endpoints in the
background so it can stay on the created screen, and the deck path
hands off to the deck editor with the new id. Step blocks now hide
properly per behaviour (previously every behaviour showed all of
step 2's fields).
Added
- 4-level grayscale on the Xteink X3. A new
xteink_x3_graydevice
kind matches CrossInk's default grayscale build: the same panel block
asxteink_x3with the renderer overridden to the 2-bpp
esp32_gray2_binframe (gray_4 gamut), so an X3 running that build
resolves and pairs. Marked unverified until a frame renders on real
hardware.
Changed
- The Decks page got its designed look. Implemented from a design
handoff: a dark on-air bar summarising what's live right now, filter
tabs, and one row per deck whose body shows the actual dashboards as
thumbnail screen cards (live previews from the composer). Timer cycles
show their step chain with the current step lit and the next-advance
time; timed sends carry their own 24-hour rail with fire marks and a
ticking now line (replacing the separate Next 24 hours panel); by-hand
decks get manual stepper buttons that move the display back or forward
a dashboard. A subtle one-shot flash marks a fresh push, with reduced
motion respected.
Changed
- One editor for every deck (#167). The separate timer-cycle form is
gone: cycle cards' Edit, the New timer cycle button (which preselects
timer advance), and old/rotations?edit=links all open the deck
editor, which gains a Page-conditions fold (author per-page conditions
with the usual picker; bad JSON is rejected with a message, and saves
that omit the fields preserve what's stored) and a smart-sync render
lead input. The wizard's cycle path creates directly and fine-tuning
happens in the editor afterwards.
Changed
- Rotations and schedules are decommissioned as separate concepts
(#167). A rotation is now stored and edited as a plain timer deck on the
cycle trigger; a schedule is a one-page timer deck on the interval or
daily trigger. The Decks page sections are relabeled Timer cycles and
Timed sends and show every timer deck regardless of how it was created;
deck cards show the navigable (manual and both) decks. Everything keeps
working: the old forms, MCP tools, and REST envelope operate on the same
records as views, bound cycles advance panel-by-panel with pre-warmed
frames and manual-hold respect, unbound cycles keep page-binding
fall-through delivery, and bound timed sends target their own panels.
Added
-
The wizard now creates, not just prefills. Its final step shows a
plain-language review and a Create button that submits through the
normal endpoints, then lands on the deck list with the new card
highlighted; Advanced options still opens the full prefilled form. The
cycle path also gained per-dashboard display times: one minutes input
per picked dashboard instead of a single shared value (the full cycle
form always had per-step dwell; the wizard now matches). -
One card for every deck. The Decks page now renders every shape (by
hand, timer cycle, timed send) as the same card: a teal status pane with
a live progress bar (through the current dwell, until the next fire, or
toward home return), the kind as a chip, a one-sentence summary, and a
uniform action row (Send now, Play step, Edit, Enable/Disable, Delete).
Filter chips narrow the list by kind; the three separate listings are
gone. Editing expands the matching form inline via the card's Edit. -
A "Help me choose" wizard on the Decks page. A guided stepper asks
one question per screen (what the display should do, which dashboard or
dashboards, the time or cadence, and a name), teaches what each answer
creates, and lands on the matching form with everything filled in so the
last step is just pressing Create. It composes the existing forms;
nothing new to submit through. -
The Decks page reads as one list. The Timed sends and Timer cycles
headings are gone: timer decks flow directly below the navigable deck
cards, with a single actions row up top (Help me choose, New deck, New
timed send, New timer cycle). Copy now leads with button press, the most
common navigation input, ahead of tap and swipe. -
Companion API: devices now carry their icon (#184).
GET /api/app/v1/devicesreturns each device's resolved Phosphor slug (the
same identity the device pickers and Settings cards use), so native
clients can render Display cards with the configured device icon. The
field is always present; kind defaults apply until the user overrides
per instance. -
Physical buttons now work on timer decks (#167).
rotate_next,
rotate_prev, andstep:<n>act on the device's bound timed deck when
no rotation targets it, with the same manual-hold behaviour rotations
have; the hold lasts until the deck's next dwell boundary. Aboth-mode
deck now responds to rotate presses that don't match a page link
(previously a silent no-op), and the/framerotation envelope reports
the deck's position so firmware stays informed. When several timed decks
are bound, the highestadvance_prioritywins. -
Timer decks can now fire on schedule-style triggers (#167). Besides the
classic anchor cycle, a deck's timer advance supports anintervaltrigger
(cooldown floor since the last fire, optional wrap-around time-of-day
window) and adailytrigger (once per local day at a set time, with the
backfill guard), plus a whole-deck fallback page for when every page's
conditions fail. A deck with no bound devices fires to the page's own
devices. These are the shapes the upcoming schedule/rotation migration
maps onto; existing decks are unchanged (cycleis the default).
Changed
-
One Decks page for everything a display shows over time (#167). The
Schedules and Rotations pages fold into the Decks page as sections, with
their forms, timeline, status pills, and every action intact; the old
URLs and in-app links redirect to the right section, deep edit links
included. The top nav has a single Decks entry. The MCP
rotation/schedule tools keep working as compatibility adapters and are
marked deprecated in the agent docs;create_decknow documents the
timer and schedule-style triggers as the preferred path. -
Schedules and rotations now live in the deck store (#167). On first
start after updating,rotations.jsonandschedules.jsonrecords are
migrated intodecks.jsonas tagged timer decks and the source files are
renamed to*.json.migrated(kept as rollback artifacts). Nothing
user-visible changes: the Rotations and Schedules pages, MCP tools,
physical-button behaviour, manual holds, and firing semantics all work
exactly as before through compatibility projections, and migrated records
do not appear on the Decks page. Rotations that repeat a page across steps
migrate too (linkless decks may now repeat pages). An id shared between a
legacy record and an existing deck gets a-rotation/-schedule
suffix, noted in the log. After the move, the Schedules and Rotations
pages show a one-time dismissible notice explaining what changed and
where the backups live. -
Priority now arbitrates across schedules, rotations, and timer decks on
the same tick (#167). The scheduler collects all three into one fire
pass sorted by priority, so a higher-priority rotation or deck advance
beats a lower-priority schedule for the panel. Previously the three fired
in fixed passes and a schedule always won the tick regardless of priority.
Records with equal priority keep the existing landing order (rotation,
then deck, then schedule), so default-priority setups behave as before.
Fixed
- Deleted canvas-born dashboards no longer resurrect after a restart. The
legacy standalone-canvas migration runs on every startup and re-created any
canvas whose page id was free, so deleting such a dashboard only lasted until
the next update. Deleting a page now also drops the same-id legacy canvas
doc; canvases that were never deleted keep migrating exactly as before.
Added
-
The Companion API contract now supports selected Apple Reminders lists as
one domain source. Servers advertise accepted schemas through
personal_data.sources; the new strictreminderssnapshot carries up to
20 named list groups and 200 incomplete items in aggregate without exposing
EventKit calendar identifiers. The deprecatedreminders.fridgesource and
its legacy feature flag remain available only for the already published
fridge widget; new Companion and widget integrations useremindersonly.
The server rejects duplicate list IDs and aggregate item overflow instead of
truncating them, while an empty list set remains a fresh enabled snapshot;
deleting the source is reserved for disabling the integration. -
Physical buttons now work on relay-paired panels (#180). A button press
rides the status JSON the panel already posts to its relay mailbox
(button+button_event_id, the same fields as the REST status body); the
home instance dispatches it through the normal button pipeline when it pulls
the status and uploads the resulting frame for the panel's awake-window
re-poll. After a press the home poller drops to a fast interval for a burst
so follow-up presses (deck navigation) aren't collapsed by the relay's
latest-only status slot, and presses older than five minutes are ingested as
telemetry but not dispatched. No relay Worker changes; requires firmware to
include the two fields on button wakes. -
Offline-album playback state on the Devices card. The server now ingests
the collection playback report a storage-capable display sends on its
heartbeat (state, cached/total frames, synced version) and shows it on the
device's Status tab while that album is bound, with state/version transitions
recorded in the events log. Shown as an observation with its age, never as a
claimed current frame.
Fixed
-
Expired personal-data snapshots no longer retain raw values. The store
removes list names and Reminder contents atexpires_atwhile preserving a
metadata-only tombstone for theexpiredstatus. Reminder due dates are now
also validated as realYYYY-MM-DDcalendar dates for both the generic and
deprecated source schemas. -
Revoking a remote panel now actually cuts it off. The relay Worker's
revoke deleted the panel's mailbox but left its token record, so a revoked
panel kept authenticating and saw an empty-mailbox204it couldn't tell
apart from "freshly paired" — the contract's revoked-token401never
happened. Revoke now deletes the token record (plus the pairing records
holding the plaintext token), so the panel's next poll is a real401and
firmware can drop its pairing unaided. Completing a pairing for a device
that already has a token also invalidates the old token, so a re-paired
panel leaves exactly one working credential. -
Deleting a relay panel from Settings → Devices now revokes its relay
pairing too. Previously only the Cloud relay page's revoke button talked
to the relay; the plain device delete left the mailbox and token lingering. -
Large offline albums no longer overflow constrained firmware receive
buffers. The/collectionmanifest is now paged (at most 64 frame entries
per response,?cursor=continues) instead of listing every folder frame in
one document; every cache-eligible frame still lands on page one, so
single-page slice-1 firmware is unaffected. -
Companion webpage sends no longer time out on asset-heavy sites because
of repeated DNS safety checks. The strict public-only Chromium guard now
classifies each hostname once per page attempt while continuing to validate
every distinct redirect and subresource host and to fail closed on lookup
errors. -
Relay deliveries now show up in the events log. Each sealed frame or
config upload to a relay mailbox records a device event row
(relay://<id>/frame,relay://<id>/config), and upload failures record
error rows, so the relay hop of a push is as auditable as an MQTT or REST
publish. Uploads are deduplicated by digest, so steady pushes don't churn
the log. -
Relay pairing now honours the panel's self-reported colour gamut when
picking the device kind. A remote panel reporting a non-default gamut
(a grayscale or BWR firmware build) is created from the most specific
hardware-catalog kind for its protocol + gamut, so frames pack at the
right bit depth: an 800x480 4-gray panel now receives 96000-byte 2-bpp
frames instead of the mono kind's 48000-byte 1-bpp frames, which its
firmware rightly refused to paint. The operator's explicit kind choice
still wins, and gamut-less reports behave exactly as before. -
RSS feeds and webpage screenshots behind bot protection no longer 403
(#178). The RSS widget now falls back to fetching through the headless
browser's network stack (a genuine browser TLS fingerprint, same approach
the Reddit widget already used) when a plain fetch is refused, and sends
browser-shaped headers on the plain path. Webpage screenshots no longer
advertise the HeadlessChrome user agent on external sites, which
Akamai/Cloudflare-class protection blocks on sight.
Added
-
Pairing codes for remote panels can now live longer than 10 minutes.
"Add a remote panel" gains a "Code valid for" choice (10 minutes to 24
hours, relay-clamped) for when someone has to travel to the remote
location before entering the code. Relay Worker redeploy required. -
Remote relay panels are now configured exactly like local displays. A
remote panel's card on Settings → Devices carries the full set of controls
(sleep interval, button wake, quiet hours, orientation, panel dims,
calibration), and config edits now actually reach the panel: the home
instance seals the device's config document with the pairing key and uploads
it to a new relay config mailbox, which the panel fetches on its next wake
(conditional GET; the status response now carries the current config etag).
The relay tab links each remote panel to its device card, the card's badge
reads "Relay" with relay-appropriate connection details, and the MQTT/REST
transport switch is disabled for relay panels since flipping one would
orphan it. Relay Worker redeploy required for the config mailbox routes. -
Photos sent from the Companion app can now be framed. The Companion API
accepts an optional focus + zoom on image uploads (contract 0.6
image_framing, Fill only) and resolves it into a separate source crop for
each target panel, so one send to a portrait and a landscape display keeps
the chosen subject on both. Focus coordinates address the photo the way the
phone displays it (EXIF orientation is normalized first), History returns
the original framing intent, and resend republishes the retained framed
frame exactly. The capability is advertised with a mandatory
image_framing_max_zoombound so clients never hard-code the editor range. -
Run a panel at another location, without a VPN or opening your network. A new
cloud-relay transport lets a remote e-ink panel show your home instance's dashboards
over the internet. Both ends connect outbound to a small relay mailbox (a Cloudflare
Worker, hosted or self-hosted), so your home network never accepts an inbound
connection. Frames are sealed end-to-end (X25519 + AES-256-GCM), so the relay stores
ciphertext only and can never read a dashboard. Pairing is done remotely through the
relay, no LAN access to the panel needed. See the remote-panel guide. Panel firmware
must support decrypting relay frames. -
Installing a template now asks its questions with real controls, not blank text boxes.
A template's declared inputs are resolved against the installing server's own widget option
schemas, so a question about Home Assistant sensors renders as a picker over your entities,
a location question gets the location search, and selects get their real options. It reuses
the sameauto_fieldcontrols and coercion as widget configuration, so the two stay in sync.
The author's declared type is only a fallback, which is the correct inversion: they can't
know what is valid on your system. Secret inputs stay masked text (an API key has no picker),
and inputs targeting a raw URL source's transport fields fall back to plain text.Each question is also labelled by the element it configures, so a dashboard with three
sensor tiles asks "Kitchen: Entities" and "Bedroom: Entities" rather than "Entities" three
times; an untitled element falls back to its position ("bottom left of the dashboard").
Questions are grouped by the value they replaced, so one API key shared by two sources is
asked for once and fans out to both, while three tiles watching three different sensors
stay three separate questions. -
Phone photos no longer land on the panel sideways.
fit_to_panelnow normalizes EXIF
orientation before cropping and fitting. Cameras commonly store a landscape pixel buffer
plus an orientation tag instead of rotating the pixels, and Pillow does not apply that tag
on open, so a portrait phone photo rendered rotated. Doing this before the crop also makes
normalized crop coordinates mean the same thing on both sides of the wire, which the
Companion's proposed focus/zoom framing depends on. -
Templates can be reported for takedown. Every template in the browser gets a Report
button that files a takedown request into the same review channel, with Take down and
Dismiss buttons beside it. Anyone can file one, including the install that published the
template, which is how an author pulls their own work back; a self-report is flagged in the
review message (advisory, since install ids are forgeable). Taking a template down from
either message closes any open reports against it, and requests are rate-limited per
install and per IP. -
The Share dialog now warns that the preview image is a live render. The screenshot
submitted with a template shows whatever the dashboard was displaying at that moment, and
it reaches reviewers immediately and the public catalog on approval. The dialog says so
beside the image, suggests duplicating the dashboard with placeholder values if it shows
anything private, and confirms once more at submit. -
Large dashboards can be shared again: template previews are downsampled to fit. A
1600x1200 render with a photo background exceeded the submission size cap and was rejected
outright. Previews are now resampled to at most 1200px on the long edge (with a palette
fallback) before submitting, which keeps them readable in the Discord review embed rather
than shrinking them to card size, and the server cap is 1MB to match. A render already
inside the budget is sent untouched at full resolution: flat dashboard art compresses
better at native size, so resampling it would have made the file bigger and the reviewer's
view worse. -
Community templates get their own page, grouped by resolution. Browse now links to a
dedicated Templates page where templates are grouped by canvas size, each group labelled
with the devices that fit those dimensions (portrait mounts matched as "rotated") and the
resolutions of your own registered panels pinned to the top. Fixes the Install button
landing on a 404: the editor URL now comes from the server rather than a hardcoded client
path (the share dialog's preview URL had the same stale path). -
Settings → System gains an Experiments card. Every experiment flag (canvas editor,
MCP API, template marketplace) is now a labelled toggle with its description and resolved
state; flips take effect immediately with no restart. Flags pinned by a
TESSERAE_EXPERIMENT_*env var render locked with the reason. Also fixes the MCP card's
Disable button, which posted a value that parsed truthy and silently re-enabled the API
instead of disabling it. -
Template marketplace (experimental): share and install community dashboard templates.
Behind the newtemplatesexperiment flag and the master online switch. Sharing (a new
action in the panels editor) exports a canvas dashboard through a sanitizer that strips
request headers and secret-flagged options (a newsecretflag oncell_options;
rest_serviceurl/headers carry it), clears install-specific values (HA entities,
locations) into declared install-time inputs, inlines small page-asset backgrounds, and
runs a credential lint that blocks anything key-shaped; submissions go to api.tesserae.ink
and are human-reviewed before appearing publicly. Browse gains a Community Templates
section: cards show a stable pseudonymous author (sponsors get an emblem and a custom
name), installing fills the template's declared inputs (secrets entered masked, staying
local) and creates a new unbound dashboard. Removing a template from the catalog never
touches dashboards already created from it. The privacy page documents exactly what a
submission contains. -
Panel view: preview a dashboard as the e-ink panel will actually paint it. The
dashboard editor's live preview gains an HTML view / Panel view toggle. Panel view
quantises and dithers the render to the target panel's colour palette, so you see the
exact per-pixel output (dithering, palette reduction) before pushing, per preview group's
gamut. Also adds a shared source-crop primitive (a normalized crop + rotate applied before
the panel fit) that the Send and Companion image paths will build framing on. -
Companion dashboard listings now include their Phosphor icon name. The
optionaliconfield uses the same bare identifier as the web dashboard
list, so companion clients can render a consistent icon and safely fall back
when a dashboard has no icon. -
Icon references that resolve to no glyph are named instead of rendering a blank box.
render_reportnow always includesicon_invalid(mirroringtap_invalid): unknown slugs or
weights oniconelements, badicon-transform bind-table values, and a heuristic scan of
code/html/svg markup forph-<name>classes that aren't real Phosphor icons, each with the
element id and reason. TheGET /api/mcp/iconssearch normalises its query to slug form
(ph-prefix stripped, underscores as dashes), soph-heartandcalendar_heartnow match
instead of returning zero results. The canvas renderer falls back to bold for an unknown icon
weight rather than building a class that matches no stylesheet. Agent docs (server and bridge)
spell out the two markup traps: regular weight needs both classes (ph ph-heart), and a wrong
slug fails silently. -
Render diagnostics:
render_reportgainsdebug=1, so silent render failures name
themselves. The diagnostics section reports, per render: console errors/warnings from every
frame (a throwing code-element script surfaces tagged[code-el <id>], including uncaught
async errors and CSP-blocked loads inside the sandbox), uncaught page errors, failed and
4xx/5xx network requests with URLs, per-font-face load status
(loaded | pending-at-capture | failed | never-requested, with the@font-facesrc), authored
element CSS the browser silently dropped (selector + declaration + reason, via a re-parse
diff), which vendored bundles each code element inlined, and the settle record that gated the
screenshot (goto / compose-signal / image-wait / font-wait outcome + elapsed ms). One
render_report(debug=True)call now names problems that previously took pixel-diffing dozens
of renders.render_previewandrender_reportalso acceptfresh=1to bypass the last-good
fallback and widget data caches (?fresh=1now works on/compose/<id>itself), so a stale
cached result can't derail an investigation. A live-Chromium regression suite pins the
acceptance case (throwing script + 404 font + dropped CSS rule, one call names all three) and
that identical page content yields identical font/asset outcomes across runs. -
The MCP surfaces the icon set and code-element toolkit to agents.
list_widgets()now
returns the vendored code-element libraries (Chart.js incl. the datalabels and Sankey plugins,
canvas-gauges, Day.js, qrcode, marked, chroma, SVG.js, Phosphor) and an icon descriptor, and a
newGET /api/mcp/icons?q=searches the 1500+ Phosphor names so an agent picks real slugs
instead of guessing. Thetesserae-mcpbridge exposes the search as alist_iconstool.
Previously these were only described in the prose instructions, so agent-built dashboards
under-used icons, charts, and the toolkit. -
Companion API 0.5: send a public image URL or a webpage to your displays. Community
clients can push a public image URL, or a server-rendered screenshot of a public webpage,
to explicit displays as asynchronous jobs, with the same History, resend, and photo
layout-mode handling as an uploaded image. Both run a strict public-only URL policy with no
client override: private, loopback, link-local, reserved, and embedded-credential
destinations are refused, including redirect hops (re-validated during the fetch, and by a
per-request interceptor during the webpage render). Webpage sends need a browser pool and are
advertised only when one is present. -
Companion clients can inspect the exact frame waiting for a sleeping
display. A REST device withhas_pending_rendernow includes an optional
pending_renderrevision and authenticated preview URL. The existing device
preview endpoint accepts that revision while preserving its default
last-served meaning, so clients can present Current and Next without guessing
from global History or racing a newer render. -
Companion display cards distinguish served and pending frames. REST
/framepolls now retain the last frame handed to each device, including
matching304 Not Modifiedconfirmations. The Companion device preview uses
that served frame whilehas_pending_renderreports when a newer render is
waiting for the panel's next wake; MQTT and push transports continue to use
the latest server render. -
Companion API 0.4 adds canonical History and all five photo layout modes.
Community clients can page through the same push History shown in the web UI,
fetch retained composition previews, and idempotently resend an entry to its
original display snapshot while respecting quiet hours. Successful jobs may
include the exact new History event IDs, and image uploads now advertise and
accept Fit, Fill, Blur, Stretch, and Center. -
The opt-in daily heartbeat now includes a bucketed count of paired companion apps. Same
0/1/2-3/… bucketing as the device count, derived server-side from the live companion
tokens (never a client name, install id, or app version), so adoption of the community
companion app is visible without the app itself ever contacting api.tesserae.ink. Sent only
when online features are enabled, like the rest of the heartbeat; see the privacy page. -
REST devices work on publicly-hosted instances (opt-in). A new Settings → Server → Network
toggle, "Allow REST clients on public networks" (off by default), lets a device fetch its
rendered frame over a public address using a signed, short-lived URL the server hands it after
it authenticates. Off, render artifacts stay reachable only from your LAN or an authed session;
on, the operator accepts that a device's signed frame URL is fetchable from the internet until
it expires. Unsigned public access is always refused, and no firmware change is needed.
Fixed
-
Panel view toggle now actually switches. Selecting Panel view appeared to do
nothing: the preview iframe'sdisplay: blockoverrode thehiddenattribute the
toggle set, so the live render stayed on top of the quantised image, which itself
rendered at full panel resolution outside the frame. Both are fixed (explicit
[hidden]override on the iframe; the panel image reuses the raster preview's
letterboxedobject-fit: containbox). The HTML-view button is also renamed from
"Fit view", which read as a sizing control. -
Saving a REST device no longer emits a misleading MQTT publish failure.
REST instances can retain dormant MQTT topics for a later transport switch;
device settings now follow the active transport, save to disk, and return on
the next status poll without attempting a broker publish. -
CalDAV discovery handles compressed responses. A Nextcloud behind a proxy or CDN
can return a gzip- or deflate-compressed body; Python's HTTP client doesn't request or
decompress that, so the parser saw a binary blob and discovery failed with "wasn't valid
CalDAV XML." Discovery and the feed fetch now request an uncompressed body and decode
gzip/deflate if the server compresses regardless, and the diagnostic log includes the
response's content type and encoding. -
CalDAV discovery tolerates a stray BOM or whitespace before the XML declaration.
Some servers (notably Nextcloud behind certain PHP / output-buffering setups) emit a
newline ahead of<?xml, which a browser ignores but a strict parser rejects, so an
otherwise-valid calendar listing failed with "wasn't valid CalDAV XML." Discovery now
trims anything before the first tag, and logs the raw response start when a body still
won't parse. -
The Schedules "Next 24 hours" timeline and last-fired times respect the configured
timezone. They were computed from the server's clock (UTC on a typical Docker install),
so the "Now" marker, hour ticks, and projected fire times sat an offset away from the
operator's wall clock and reloading didn't help. They now use the configured app timezone. -
Grayscale and Spectra 6 / ACeP panels report their real palette to companion and MCP
clients. The colour-capability lookup only matched the canonical gamut ids, so a panel
declared with a chemistry alias (spectra_6,acep_7colour) fell through and was reported
as monochrome; it now resolves aliases and returns the full 6/7-colour palette. Three
grayscale kinds that already render multiple grey levels but were taggedmonoare corrected
(reTerminal E1001 grayscale and Xteink X4 grayscale to 4-level; reTerminal E1003 to a new
16-levelgray_16gamut), and editing a grayscale panel's settings no longer rewrites it to a
colour gamut. -
The refresh button now works on a plain bound dashboard. Pressing refresh on a
display that isn't driven by a rotation or deck re-renders whatever dashboard it is
currently showing and sends it, instead of doing nothing. A device that has never
displayed a dashboard still no-ops, since there is nothing to refresh. -
Layout preset thumbnails no longer distort on extreme panels. The little
split-pattern hints in the dashboard editor now use a fixed aspect ratio
instead of the panel's, so a very wide or small display (e.g. a 296x128
Magtag) gets readable, uniform preset tiles. The interactive custom-layout
board still matches the real panel shape. -
Companion Activity now keeps exact History and display identity. Multi-panel
dashboard pushes preserve every canonical History event ID in their terminal
Job, and button-triggered fetch rows snapshot the originating device ID, so
clients no longer need title/time heuristics to suppress duplicate Activity
cards or identify the display that fetched a frame. -
Companion display previews now match each display's selected photo layout.
The server retains a separate logical-screen PNG after Fit, Fill, Blur,
Stretch, or Center and device underscan are applied, so portrait displays no
longer show the unfitted source composition in the iOS app. Hardware-only
row-stride rotation and mount compensation remain confined to the device
artifact, while preview ETags, upgrade backfill, and artifact pruning track
the logical frame independently. -
The Webpage widget captures JavaScript pages after their data loads. It was
screenshotting the moment the embedded page fired itsloadevent, before a data-driven
page (a weather dashboard, an SPA) ran its post-load fetch and painted, so the panel showed a
blank frame even though the editor preview looked right. The widget now waits a short settle
window after load, with a "Settle delay (seconds)" cell option (default 2) you can raise for
slow pages. -
Photo sends to portrait-native ESP32 panels keep their visual orientation.
Send-page and Companion uploads are now fitted into the display's composition
dimensions before the renderer reconciles that composition with the
firmware-native row stride. Landscape photos sent to devices such as the
reTerminal E1004 no longer rotate 90 degrees clockwise while History remains
upright. -
Battery analytics now separate charging from drain phases. The Device batteries page
shows liveCharging/Charge rate/Full invalues without labelling a positive charge
ramp asDrain rate, and retains the preceding clean discharge slope asLast drain rate
when available. Robust Theil-Sen fits prevent a single glitched reading or post-unplug voltage
relaxation drop from dominating either estimate, while raw chart samples remain unchanged. -
v2 staleness is anchored to layout, not pixels (live bench round 3, 2026-07-25). A region
report against a superseded frame digest now dispatches when that frame's untrimmed region-id
set matches the live one, resolved through a new ~10-generation digest lineage per device; a
dashboard whose pixels re-render every 30 s no longer drops every tap that races a render.
stalemeans the layout genuinely changed or the digest is too old to resolve. -
Extraction races can no longer kill touch. A capture whose code-element mirrors hadn't
posted by screenshot time extracts zero regions; that empty result no longer overwrites a
populated sidecar for an unchanged composition, and an empty manifest rebuild for a page whose
cached manifest had regions serves the cached manifest re-anchored (with a warning) instead of
a structurally-valid 0-region manifest the device would hold until the next good redraw. -
Anti-aliasing jitter no longer mints frames. The composition diff gained a per-channel
tolerance (10/255, below the 16-level gray quantization): chart canvases and browser text
jitter between captures of visually identical content inflated the changed area past the patch
budget, forcing a full-frame mint and a full e-ink flash on every re-render. Sub-tolerance
re-renders now hold the digest and stage nothing; real changes ride patches as designed; a
divert that still fails logs its reason at warning level. -
v2 region reports now dispatch (live bench round 2, 2026-07-25). Sidecar action specs from
code-element markup are raw JSON strings; the manifest builder and the region-id resolver
classified and dispatched the unparsed string, so every served manifest carried
action.type: '{"action"'garbage and every/tapregion report failed. Specs now pass
through the samecoerce_actionnormalisation the v1 dispatch path uses, in the one helper
both the builder and resolver share — HA actions classify as tier 1 /haand dispatch.
/tapregion reports also speak the firmware's wire vocabulary:okon success,stale/
deduped/ha_failedunchanged, and specific diagnostics (no_action_for_region,
action_error,provenance_blocked,resolver_exception) instead of a muteerror. -
/frame/dataanswers 200 for every known digest. A frame with no slots and nothing staged
returns an empty values document instead of 404, so a device can't latch data-off mid-linger
and miss the patch a tap stages a second later; 404 now means an unknown digest only. -
Manifest region trim is priority-ordered and audited. Over-budget frames keep navigation,
then sliders, then taps, then swipes (document order within each class), and the dropped
region ids are logged by name instead of tail sections silently going dead. -
Protocol v2 manifest delivery survives re-renders (live bench, 2026-07-25). Every
/frame
200 for a proto-2 device now carries the manifest block: non-interactive frames get a valid
empty manifest instead of silence (a manifest-less 200 reads as "v1 server" and latched the
device out of region dispatch), a lost sidecar re-anchors the last built manifest for the same
composition, and pixel-only re-renders keep the manifest digest stable so the device re-anchors
without a re-fetch./frame/manifestand/frame/dataalso keep answering for a
just-superseded frame digest for a ~60 s grace window, so a device mid-linger on the old digest
is not orphaned by a re-render (its artifacts and sidecars are prune-protected for the window). -
/statusvalue/patch envelopes now gate on the sticky capability. A beat that omits the
overlayadvert, or a pure-v2 firmware that only sendsproto, no longer loses
overlay_values/overlay_patches; patch reconciles and push diverts likewise accept
proto >= 2as patch-capable alongside overlay schema 2.
Added
- Protocol v2 server surfaces (device-owned touch). Devices advertising
proto: {v: 2}
(sticky, persisted) get: interaction manifests (GET /frame/manifest?digest=, plus a pointer
on/frameresponses) with stable region ids, tier/type classification, feedback modes, and
text regions; region-id action reports onPOST /tapvalidated by re-minting ids from the
frame's own sidecar; a Server-Sent Events channel (GET /stream) carrying values / patches /
sync envelopes with keepalives; and state bundles (GET /bundle) projecting the warmed deck
cache into digest-addressed frame states with a navigation links table. v1 responses stay
byte-identical for devices that don't advertise the capability. Contract:
docs/protocol-v2-touch.md.
Removed
- Schema-1 overlay specs (protocol v2 cleanup, see
docs/protocol-v2-touch.md). The
GET /frame/overlay/<digest>endpoint and thebuild_specdocument builder are gone: v1
firmware probing the endpoint gets a 404, which its contract has always defined as
feature-off, so it degrades to dispatch-without-echo until the protocol-v2 interaction
manifest ships. The atlas store, values document, patch documents, touch dispatch, and the
overlay capability handshake are unchanged; non-touch rendering is byte-identical.
Added
-
Frame patches now actually stage under real dithering, and periodic small changes ride them
too. The patch diff moved to composition space (before per-renderer dithering): the .bin
family's default error-diffusion dither made a one-tile change perturb the packed bytes of
nearly the whole frame, so the wire-space diff always blew the budget and every reconcile fell
back to a full frame — the schema-2 path never engaged on hardware. Composition rects now map
through the same transform chain as tap targets and the blob is cut from the new artifact.
On top of that, scheduled and push-triggered re-renders whose visual diff is small (a header
clock tick) are delivered as patches on the current digest for schema-2 REST devices showing
the same page: no full e-ink flash per clock tick, and stable digests mean a tap fired around
a render can no longer be dropped as stale. Explicit repaint intents (resend, force publish)
and big diffs still mint a new digest. -
Overlay capability survives restarts. The advertised overlay schema persists in the
device-facts store and re-seeds the status cache at startup, so a patch-capable panel isn't
demoted to full-repaint reconciles between a server restart and its next heartbeat.
Fixed
- Overlay values
seqis now milliseconds. Second-granularity seqs made two value changes
inside one second dedup to a single repaint under the firmware's newest-wins rule.
Added
-
Post-action frame patches (overlay schema 2). After a touch action fires a Home Assistant
service call, the server re-renders the page headless, diffs the wire framebuffer against the
frame on glass, and stages only the changed rects as a patch document (patcheson
GET /frame/data,overlay_patcheson/status, blob viaGET /frame/patch/<digest>).
Capable firmware partial-refreshes those rects: state text catches up within a couple of
seconds, with no full download, no full e-ink flash, and the digitizer live throughout.
Documents are anchored to the served frame digest with a strictly increasingseqand are
dropped the moment a newer frame lands, so a patch can never revert a pending push. Caps:
12 rects / 256 KB per document; past that the server falls back to a normal full frame. -
Overlay value slots: attribute paths, value maps, code-element support. Slot keys accept
an attribute path (ha:light.desk:attributes.brightness), a slot can declare
data-overlay-map='{"on":"1","off":"0"}'to render non-numeric states with the numeric glyph
atlas, and slots inside code-element sandboxes are now collected through the same mirror
mechanism as their touch regions.
Changed
- The post-HA repaint no longer runs inside the touch wake. The synchronous re-push (full
render + download + flash while touch was locked, roughly one action per 10 s) is replaced by
a debounced background reconcile that re-renders whatever the device is showing: rotation
step, deck page, or the last directly-pushed page (previously, devices without a rotation
never repainted at all and kept 304ing until the next schedule). Patch-capable devices
reconcile ~0.4 s after the last tap of a burst; everything else gets one coalesced full push
~3 s after (app.touch_patch_debounce_s/app.touch_repush_debounce_s).
Added
- Pages own their update cadence (discussion #140). Every dashboard gains an "Updates"
setting on the Dashboards list (only when pushed / every minute / 5 min / 15 min / hourly /
daily). The scheduler re-renders the page on that cadence and delivers only to panels
currently showing it (resolved from deck position, rotation position including manual holds,
or a device bound to exactly one page); nobody showing it means no render at all. Freshness is
now a property of the page's content, not a side effect of rotation dwell: a clock page set to
"every minute" stays live through a 15-minute rotation step. Delivery respects quiet hours,
and unchanged renders still answer battery panels' polls with 304. Default is "only when
pushed" (no behaviour change for existing pages).
Fixed
- Deck home-return now respects quiet hours on its promote fast path. The timer-driven
return-to-home could repaint a panel inside its quiet window when a pre-warmed frame was
available (the push fallback already gated itself); the return now defers until the window
ends on both paths. Hand navigation and the Push button remain quiet-exempt (user-initiated).
Added
-
Deck editor: pick pages, done ("dense rail + inspector"). Decks get a dedicated
create/edit surface (Decks -> New deck / Edit): a flip-order rail of page cards with
thumbnails, click-to-append page library with suggested clusters, an inspector for the
selected card (home toggle, return-after slider, per-page refresh override, reposition /
remove), inline settings bar (name, device chips, cadence, Advanced with entry page + a
live-derived navigation-graph view), and a one-line behavior summary. Navigation derives
automatically from the flip order; no graph authoring needed. Fully submittable without
JavaScript (membership checkboxes + numeric order fallback). -
Deck home card with idle return. Mark a member page as home and set "return here after"
(slider, 0-120 min): the deck returns to it after that long without a button press or tap,
enforced server-side for server-navigated panels and shipped in the sync manifest (home
block) so SD-cache firmware enforces it offline. Push sends the home page to the panel first;
a fresh device's entry page defaults to home. -
Deck sync manifests: swipe triggers and capacity awareness. Link tables now carry swipe
triggers (authored directions mirrored from the graph; paging defaults where silent: swipe
left = next, swipe right = back), and when a device's advertised SD capacity can't fit the
whole deck, overflow pages are markedcache: falsewith ring-from-home priority instead of
letting the card overfill mid-sync.
Added
- Rotations re-render at every dwell boundary, including onto themselves (discussion #140).
Previously a rotation only rendered on step transitions, so a step's widget data froze for
its whole dwell (a device poll fetches the already-rendered frame; it never triggers a render),
and a single-step rotation rendered exactly once, ever. A rotation now fires whenever a new
dwell window begins, even when the step is unchanged, so a one-page rotation with a 5-minute
dwell simply means "keep this page fresh every 5 minutes". The min-hold flap guard still
applies to step changes but not to self-fires. Alongside this, rotation fires now exclude
panels sitting inside a manual button/touch hold (previously any fire could yank a paged-away
panel back mid-hold; now the rejoin pass restores them only when the hold lapses).
(Supersedes therefresh_minutessetting that briefly shipped in v0.190.0; rotations saved
with it still load, the field is ignored and dropped on next save.)
Fixed
- Devices paged away by button/touch now rejoin their rotation (discussion #140). The
scheduler only pushed on step transitions, so on a rotation dominated by a long-dwell step a
panel that was manually paged away (physical button / touch) stayed on the manual page
indefinitely: the manual hold (override_until) was recorded but nothing acted when it lapsed.
A per-tick rejoin pass now pushes the rotation's current page, device-targeted, to any device
whose manual hold has lapsed, then clears the hold; devices still inside their hold are
untouched, and a device already on the current step just has its hold cleared. Hold length
remains "rest of the day" by default, orapp.button_hold_secondswhen set.
Added
-
Decks navigate without a hand-built graph. Where a deck page's graph is silent, sync
manifests now synthesize default links (left/rightto prev/next in deck order, wrapping,
plus left-half/right-half tap zones on touch panels when the page has no zones or markup touch
regions of its own), and server-side button handling applies the same prev/next default for
deck-bound devices, so a graph-less deck navigates identically on-device and via the server
(previouslyrightfell through to the rotation map and read as "refreshed but didn't
navigate"). Explicit graph links always win. MCPcreate_deckwith a bare page set now derives
the graph from the pages'page:<id>tap/swipe links automatically (bridge 0.8.2 documents it). -
Push button on the Decks page. One click warms every page for every bound device, sends the
entry page to the panel(s), and seeds the nav position, so a new deck goes live immediately
instead of waiting for the scheduler's warm tick. Deck cache hygiene is documented in
client-protocol.md: digests are content-addressed, so only pages with volatile content
(clocks, "last updated") churn the deck version.
Fixed
-
Completed firmware updates stayed labelled as queued. The Firmware page now distinguishes
retained canary/fleet rollout membership from a release that is still newer than the device.
Once a device reports the imported version it reads as up to date, while pending rows name the
actual imported release instead of a potentially newer online Available version. -
Deck heartbeat reports could revert a freshly pushed dashboard. v0.187.1's report promotion
promoted whatever page the panel said was on glass, so a heartbeat arriving between a push and
the device's next fetch clobbered the pending frame with the older deck frame, and the new
dashboard (and its touch zones) never landed. Promotions from reports and from touch
reconciliation now carry a recency guard: what's on glass only wins the live slot when it isn't
older than what's pending there. A touch on stale glass still dispatches against the frame the
finger actually touched; it just can't revert the pending push. -
Buttons and touches were permanently deduped after a device power cycle. The firmware's
wake-event counter is RTC-backed and restarts at 0 on any power cycle (battery pull, crash,
reflash), usually without re-pairing since the token survives in NVS; the server's dedup rule
treated anything<=the persisted high-water mark as a retry, so a restarted counter had every
subsequent button and touch silently swallowed. Dedup is now equality-only (a genuine retry
resends the same id; a lower id is a restart or an offline-queue replay and dispatches), and the
re-pair paths (/registeron an existing id,/discoverMAC claim) additionally clear the
dedup state outright. -
Touches were dropped as stale after deck local navigation. When firmware paints a deck page
from its SD cache, the panel shows a frame the server never served via/frame, so the touch
stale-check rejected every subsequent stroke, and a routine conditional poll could even repaint
the panel backwards to the pre-nav frame. A digest that matches a deck-cached render is now
reconciled instead of dropped: the frame is promoted into the live slot (ETag polling 304s, nav
position recorded) and the stroke hit-tests against its composition.deck_page_idreports do
the same promotion, and report ingestion now runs before button/touch dispatch on/frameand
/statusso same-wake events resolve from the page actually on glass. Digests matching nothing
are still dropped as stale.
Added
-
Per-device overlay target budgets (firmware v1.9). The overlay capability now carries the
device's own tap-echo buffer size (overlay: {schema, max_targets}, 32 on current E1003
firmware); the server trims target lists to the advertised value, treating absence as the v1.8
baseline of 8. When a frame has more touch regions than the budget, navigation targets
(page:/step:/rotate_*) win the echo slots ahead of miscellaneous actions, and
survivors always emit in document order. Spec size is guarded against the firmware's 8 KB parse
buffer. The MCP device list now reportsoverlay: {max_targets}so agents can design within
the real per-panel budget. -
MCP surfaces the deck-cache and overlay capabilities.
/api/mcp/devicesentries now carry
the hardwarekindplus firmware capability flags from live heartbeats:overlay: true
(instant tap echo + live value slots apply on that panel) anddeck_cache: {capacity_bytes}
(radio-off deck navigation).render_reportextractsoverlay_slotsalongsidetap_regions
(also in?view=touchand?fields=), so an agent can verify adata-overlay-keyannotation
survived the render. The tesserae-mcp bridge (0.8.1) documents the live-value-slot vocabulary
and its server-enforced guardrails (8 slots, 2 font buckets, numeric charset, 47-char values,
ha:keys only, widget-markup-only extraction) in the handshake instructions. -
Overlay value slots + glyph atlases (hybrid render mode, schema 1 slice 2). Widgets can mark
an elementdata-overlay-key="ha:<entity_id>"(optionaldata-overlay-suffix) and capable touch
firmware repaints just that slot with live values during a wake, no full re-render. The slot's
box, alignment, font size, and weight are extracted in the same Playwright pass as touch regions
(sidecar v3); glyph atlases are rasterized through the same browser + Inter faces as the
composition so blitted text is pixel-identical, packed 4bpp to the firmware's strip contract, and
served content-addressed at/frame/overlay/atlas/<digest>. Live values come from
GET /frame/data?digest=(pre-formatted strings via the ha_core plugin) and piggyback as
overlay_valueson/statusresponses for capability-advertising devices. Firmware caps
honoured server-side (8 slots, 2 atlases by largest group, 32 glyphs, 47-char values); every
failure path degrades the spec to rect-only. Contract updated in docs/dev/client-protocol.md. -
Overlay specs for touch boards (hybrid render mode, schema 1). Firmware with fast partial
refresh (reTerminal E1003 first) advertisesoverlay: {schema: 1}and fetches
GET /api/v1/device/<id>/frame/overlay/<digest>: a rect-only draw list of tap-echo targets
derived from the frame's touch-region sidecar, transformed server-side into wire-framebuffer
pixel space (rotation, flip, scaling, underscan all applied at spec-build time, so the firmware
uses coordinates verbatim). A tap inside a target inverts and partial-refreshes that rect locally
in a few hundred milliseconds while the stroke still dispatches to the server as normal. Works
for both live frames and deck-cached frames; capability is sticky per device. Value slots and
glyph atlases are the next schema slice. Contract in docs/dev/client-protocol.md ("Overlay
specs"). -
XIAO ePaper 7.5" black/white/red variant.
bwr_3is now a canonical packer gamut: the
esp32_binrenderer packs tri-colour panels to the native 2-bpp layout (96000 bytes at 800x480,
MSB-first, 0b00 black / 0b01 white / 0b10 red; the reserved 0b11 is never emitted), sharing the
BWRY 2-bpp path. Newxiao_epaper_75_bwrhardware SKU on the sameesp32_bw_clientprotocol as
the mono board, so reflashing a unit between mono and BWR firmware migrates its registration
automatically (same-protocol kind heal) while keeping the device row, token, and history. -
Deck cache sync for devices (on-device SD frame cache). Firmware with local storage can now
cache a bound deck's pre-rendered frames and navigate button/touch links on-device (wake, read
card, paint; no WiFi round trip). New device-facing surface: adeck_cachecapability advertised
in heartbeat bodies (current-state per beat, withdrawn the moment a card disappears), a
GET /api/v1/device/<id>/deckmanifest (page frame digests, byte sizes, TTLs, and the link graph;
cold pages are warmed on demand), digest-addressed frame fetch at
GET /api/v1/device/<id>/deck/frame/<digest>with immutable cache headers, adeck.version
envelope on/statusresponses so firmware knows when to re-sync, anddeck_page_idreporting on
/statusbodies and/framequery params so locally-navigated pages keep the server's nav
position truthful. Devices that never advertise the capability see byte-identical responses
everywhere. Contract documented in docs/dev/client-protocol.md ("Deck cache sync"). -
2-bit grayscale renderer + reTerminal E1001 grayscale variant. New
esp32_gray2_binrenderer
packs compositions to 4-level grayscale at 2 bpp (96000 bytes for 800x480; MSB-first, 0b00 black,
0b11 white, linear) for UC8179-class mono panels driven in their 4-gray waveform mode, and a
seeed_reterminal_e1001_grayhardware SKU pairs it with the grayscale firmware build. Same
esp32_bw_clientprotocol as the mono SKU, so the registration variant picker offers both and an
already-registered E1001 migrates automatically when its firmware re-declares the gray kind. -
Stale device kind auto-heals on re-pair (#121). A device that first registered under a generic
protocol kind (e.g.esp32_client) and later comes back declaring its hardware-catalog SKU
(e.g.seeed_reterminal_e1004) is now moved to the declared kind instead of staying pinned to the
one it first paired as. Applies on/registerre-pair and on the/discoverMAC-claim path a
re-flashed device actually hits; restricted to kinds sharing the same wire protocol, so a heal can
only refine which board, never move a device across protocols. Fixes devices being silently exempt
from per-kind OTA rollouts because releases are keyed by the SKU kind while the instance sat under
the generic one. The device's cached render is invalidated on a move so/frameserves 204 until
the next push repaints at the new kind's geometry. -
OTA rollout UI (Settings → Firmware) (#121). A guard-railed admin page over the same per-kind
rollout state the CLI writes (never a parallel store). Per device kind it shows the current release
and its verified manifest (fw_version, key_id, sha256, size, image host), and the rollout controls:
import + verify adescriptor-<kind>.json(rejected with the verifier's reason if the signature or
key doesn't check out, or the kind is unknown), set a canary from the kind's OTA-capable devices,
promote to the whole kind (disabled until a canary reportsconfirmedon that version, with a
confirm dialog stating how many devices will be offered it), and pause/withdraw. A fleet view lists
each device's firmware version and OTA phase chip, floatingrolled_back/failedto the top;
devices that never advertised OTA support show "USB update only". Every action is event-logged. The
page never fetches the image bytes; it shows the URL and the devices fetch it themselves.
When online mode is on, each kind also shows whether a newer firmware has been published
(api.tesserae.ink's per-kind update check, cached hourly) with an "available" badge, and a
one-click "Import from release" that fetches the release's signed descriptor (host-allowlisted),
verifies it against your trust anchor, and sets it as the release, no manual download. The check
now sends the reported firmware version (?current=) so api.tesserae.ink can aggregate version
distribution. When online mode is off the check never runs and the page discloses that turning it
on pings api.tesserae.ink with the kind ids and reported firmware versions; rollout itself stays
fully offline. -
OTA state reporting is ingested server-side (#121). A device that speaks OTA reports where it is
in the update lifecycle on theotaobject of its heartbeat (phase/reason/target_fw/
attempt_id/detail, per the contract's State reporting section). The server now records the
latest report on the device's live status, shows it as a chip on the Devices card (green
confirmed, redfailed/rolled_back, amberrejected, neutral in-progress), and appends an
event-log row on each lifecycle transition (a terminal report re-sent every heartbeat logs once). A
capability-only oridlebeat leaves the last outcome standing. The report is advisory; the
device's own first-boot checks remain the acceptance gate.
Fixed
-
OTA release path crashed on the Docker image, and shipped no trusted keys (#121). Two packaging
gaps blocked the first canary.app/ota/release.pyimportedpackaging.versionat module scope,
butpackagingwas never declared and is absent from the slim image, so the release CLI and the
/statusrelease-delivery path (which importsis_newer) both raisedModuleNotFoundError. The
version comparison now uses a small internal plain-SemVer helper (app/semver.py) with no
third-party dependency. Separately, the Dockerfile did not copyota/, soload_trusted_keys()
found an empty registry at its defaultota/keysdir; the image now includes it. -
Resend from History never reached devices served by a per-device renderer clone (#119). A
resend replayed the stored composition as an unbound fan-out, and unbound pushes deliberately skip
clone renderers (<base>__<device>, the #83 guard), so for a bound device the resend published
nothing: the device's latest-frame entry kept pointing at the newer frame and its REST/frame
poll answered 304 against the resent frame's differing ETag. The push history row already snapshots
the delivery targets, so a resend now replays those exact targets (and the matching panel dims)
through the fan-out. -
Canvas editor: HA entity filter, icon picker, and per-entity overrides were dead (#130). The
canvas config drawer injects a widget's options form after page load, but the canvas editor template
is standalone and never loaded the icon-picker / entity-overrides modules, and the multi-select
filter wiring lived only in the grid editor. So for the Home Assistant "Entities" widget the filter
box did nothing, the icon picker didn't open, and the per-entity label / icon / number-format
overrides never rendered. The filter is now a shared component wired in both editors, the two modules
load on the canvas page, and the drawer initializes all three after injecting the form. -
Canvas preview failed behind a non-default external port (#129). When the server was reached on a
port that differs from its internal bind (a reverse proxy, k8s Service, or Docker port map sending
external 4567 to the container's 8765), the canvas preview and other render routes built the internal
loopback fetch from the browser's port, so the headless renderer hit127.0.0.1:<external>where
nothing listens and the render was refused. ThetesseraeCLI now records its real bind port in
TESSERAE_BIND_PORT(previously set only by the Home Assistant add-on), so the renderer always
fetches/composeon the port Flask actually binds. -
Status bar showed the wrong device's battery (#125). Four gaps fed the same symptom, a status
bar falling back to the lowest battery / signal across all devices (and the wrong temperature /
humidity). (1) Per-device render detection only scanned grid cells, so a canvas dashboard pushed once
per panel instead of per device; it now scans canvas elements too. (2) Even when a canvas push fanned
out per device, the canvas render path dropped the target device before the widget fetch, so every
panel still resolved the aggregate; the target now threads through to the fetch. (3) Editor previews
(the dashboards-list hover thumbnail and the live compose iframe) carry no target device, so both
grid and canvas previews showed the aggregate; a preview now defaults to the page's first bound
device and shows a real one. (4) The per-device fan-out decision read the plugin registry off
current_app, which is absent on the scheduler and rotation push threads, so scheduled refreshes
silently dropped the fan-out and a grid panel kept showing the aggregate even when a manual Send was
correct; the push manager now holds a direct registry accessor.
Added
fetch_latestbutton action. Re-downloads and repaints the latest frame
already rendered for the device without running the composer, publishing a
new artefact, moving the rotation, or setting a manual override. The action
bypasses a matchingIf-None-Matchfor its/frameresponse, so its meaning
does not depend on a particular firmware clearing its cached ETag first.- MCP: rotations, schedules, and decks. The agent MCP surface (
/api/mcp) and the
tesserae-mcpbridge now expose list / create / delete for rotations and schedules, and list /
create / delete / suggest for decks, so an agent that builds pages can also wire how they cycle
(rotations), when they push (schedules), and how they group for instant navigation (decks).
suggest_decksderives a ready-made deck from thepage:<id>tap / swipe links already on page
elements, so the agent can offer a deck once it has wired inter-page navigation. - Decks page reshaped around the canvas. The Decks page is now a management surface, not a graph
editor: you author navigation in the canvas editor (tap / swipe "go to page" on tiles), and the
Decks page suggests a deck from those links, then owns the deck-level concerns, refresh cadence
(deck default + per-page), devices, entry, enable, delete. A Sync from links button re-derives
a deck's graph (and touch zones) from the current page links, and the raw JSON graph editor is
demoted to an "Advanced" fold for manual tweaks. - Per-page refresh cadence in decks. A deck page can set its own
refresh_interval_minutes(in the graph editor or via MCP), overriding the deck's default, so a
volatile tile can re-warm every few minutes while a photo page in the same deck refreshes rarely.
0warms the page only on first navigation; unset inherits the deck cadence. - Decks: pre-rendered, navigable page groups. A new Decks page (next to Rotations) groups pages
into a small linked graph that Tesserae keeps pre-rendered per bound device, so a button press or
touch that moves between them serves an already-rendered frame instead of rendering on the fly. Each
page links to others by a physical button name or a touch zone; the scheduler re-warms a deck's
pages in the background on the cadence you set, so their data stays current. Removes the on-the-fly
render latency from navigation (the download and e-ink repaint still happen). Build a deck under
Decks: name it, bind devices, set the refresh cadence, and define the page graph. Decks can also be
suggested automatically: when pages link to each other via tap / swipe "go to page" actions set
in the canvas editor, the Decks page offers a one-click deck for each cluster, with the graph and
touch zones derived from those links. - OTA per-kind rollout: manual promote + canary (#121). Beyond staging a build for a single
device, an operator can now set a signed build as a device kind's release and roll it out
deliberately:python -m app.ota.release set(offered first to the canary devices you list),
promote(to every device of the kind),pause,clear,list. On/statusthe server offers a
device its kind's release when the device is eligible (a canary, or the release is promoted) and the
release firmware is newer than the version the device reports; a per-device staged descriptor still
wins. The device-side apply/verify/rollback firmware is tracked separately, so nothing reaches a
device until that ships. - OTA production signing (Cloudflare Worker) and trusted-key registry (#121). A Cloudflare Worker
(packages/ota-signer/) signs firmware descriptors with an Ed25519 key held only in a Worker
secret and serves the images from R2, co-locating signing with storage; its output is byte
identical to the Python signer. Published public keys live inota/keys/<key_id>.pub, and
python -m app.ota.stagenow verifies a descriptor's signature against the key matching its
key_idbefore staging, refusing a mis-signed one (--insecure-skip-verifyto override). Keys are
keyed bykey_idso they rotate without a re-flash. The staging gate is server-side; firmware
embeds its own key set as the real trust anchor. - OTA capability handshake and
/statusdelivery (Phase 2, #121). A device advertises OTA
support with anota: {schema: N}object in its register/status body; the server hands back a
staged, signed descriptor on the always-200/statusresponse only when the device advertised a
compatible schema and the descriptor targets its kind. Descriptors are staged per device with
python -m app.ota.stage(pipeline:signthenstage), held indata/core/ota_pending.json,
and picked up on the next heartbeat./frameis untouched, so the image channel stays byte-clean
for every device kind. The production key, R2 image hosting, and OTA state reporting are later
slices. - Button wake window for ESP32 devices (#123). A per-device
button_wake_sconfig field (0-60
seconds, default 0) that keeps the device awake for a moment after a button press changes the page,
so scrolling several pages doesn't pay a fresh wake and Wi-Fi cycle per press. Set it under
Settings → Devices. The value is delivered on the/frameresponse a button wake already fetches
(and via the config block on register/status), so firmware reads it without an extra request; 0
keeps the current behaviour of sleeping immediately, and it applies only to button wakes, not
scheduled refreshes or rotation. Firmware support lands separately. - OTA update contract (Phase 1). The signing and verification half of the over-the-air
firmware update flow: an Ed25519-signed{payload, signature}descriptor binds the target device
kind, firmware version, image URL, size, and SHA-256.app/ota/carries the signer (with a
python -m app.ota.signCLI), the reference verifier (signature, then manifest shape, then target,
then image digest, each failure a stable reason code), and the wire contract at
docs/ota/contract.md.tests/fixtures/ota/publishes a test-only key and four signed fixtures
(valid, wrong key, truncated, digest mismatch) so device firmware can self-test the verifier before
the live pipeline exists. Descriptor delivery on/statusbehind a capability flag, and image
hosting, are separate follow-up slices.
Fixed
- Calendar discovery: adding one collection no longer hides the rest (#124). After a CalDAV
discovery, adding a discovered calendar redirected to a bare page that dropped the other found
collections. The add now re-renders the discovery list (the added one marked "already added", the
rest still one-click addable), so a server with several calendars can be added in a few clicks.