Fixed
-
Editing a page full of Home Assistant widgets no longer crawls. Every preview refresh
used to download Home Assistant's complete entity state list once per cell, and the trend
arrows and sparklines added a separate 24-hour history query per entity, one after another.
Opening the editor was worse still: each HA widget's entity dropdown pulled its own full
state dump, in sequence, before the page appeared. On a modest host (a NAS, a Pi) with a
busy HA install, Edit mode was close to unusable.The state list is now fetched once and shared through a short-lived cache, so six cells and
thirteen dropdowns cost one request instead of nineteen, and an unreachable HA fails fast
once instead of stacking timeouts. History queries run in parallel, keep within the render
budget, and are cached for a couple of minutes. Typing in the sensor widget's per-entity
overrides no longer re-renders the preview on every keystroke; it refreshes when you leave
the field, and drag-to-reorder now refreshes immediately.
[0.362.0], 2026-08-24
Added
-
The reTerminal E series can beep when you touch it (#258). E-ink repaints in seconds, so
a tap or a button press gives the person standing at the panel nothing to go on until the
whole pipeline has finished: dispatch, render, download, flash. Every display in the series
has a buzzer on the board that Tesserae has never used.Settings → Devices → the display → General now carries Beep on interaction, with a
tone (click, beep, chirp, low) and a volume next to it. The panel sounds it itself, the instant
the touch controller or a button reports the input, before the server is contacted: a beep the
server triggered would arrive with the repaint, which is the wait it exists to cover. The
button-only models (E1001, E1002, E1004) get it on presses; the E1003 gets it on taps too.The tones are data, not firmware: Tesserae sends the notes themselves, as
freq:mspairs,
so retuning one is a settings change rather than a firmware release, and Custom lets you
write your own (up to eight notes, 200-8000 Hz, a second in total, since it plays before the
tap is dispatched). The display holds no tone table and clamps whatever it is sent.Off by default, because a display that starts beeping after an update nobody asked for is
worse than a silent one. Needs firmware v1.22.0 or later; displays on older firmware ignore
the setting.
[0.361.0], 2026-08-24
Fixed
- Following the agent could land you on a dashboard that isn't there. The toast and the
editor's rail take the dashboard to open from the calls the agent makes, and a call names a
dashboard whether or not one exists: an agent that writes to a page before creating it gets a
404 and the toast offered to follow it there anyway. The same went for a page the agent had
just deleted, and for pages the canvas editor cannot open at all. Both surfaces are now handed
a dashboard id only when it names a canvas dashboard that exists, so the step still narrates
but there is nothing to follow.
Changed
- The rail no longer announces the end of a build that is still going. It called a run
finished after twelve seconds without a call, which is a pause, not an ending: the model thinks
between tool calls, and long code elements stream in bursts. A gap now reads as thinking, with
the ring still turning and the count still climbing, and only a silence long enough for the
server to close the run reads as finished. The toast follows the same two stages instead of
vanishing mid-build and popping back. - The follow toast is a tape of the last few steps rather than a single line that replaces
itself. Older steps dim, repeats fold (streaming a code element is one call per chunk), and the
header carries the dashboard name with the step count and elapsed time. A dot in the header
pulses while calls are landing and stalls while the agent is thinking, so the state is legible
without reading it.
[0.360.0], 2026-08-24
Fixed
-
A touch panel that is already awake now picks up a new dashboard instead of sleeping
through it (#242). After a tap, a touch display stays awake for its touch linger window,
and in that window it watches a data endpoint for small changes rather than polling for a
whole new frame. That works when the change is small enough to arrive as patched rectangles.
Anything bigger mints a new frame instead: a booking that repaints most of the screen, a page
edit, a renderer setting change, or an externalPOST /api/v1/pushfrom your own software.
None of those had any way to reach a display that was awake and watching, so the panel sat
there, went back to sleep, and only caught up on its next scheduled wake, up to a full sleep
interval later.The data endpoint now answers
"stale": truewith the current frame digest when the display
is showing something the server has moved past, which sends it to fetch the new frame there
and then. This is what makeswebhook_refreshland promptly on a screen-sized change, and it
also means a webhook receiver of your own that pushes back to Tesserae updates the panel
within a second or two of the push rather than at the next wake.Displays playing a deck page or a collection frame from their own cache are excluded: those
navigated there locally, and the live frame changing underneath is not a reason to pull them
back. Firmware v1.21.0 acts on the signal; older firmware ignores it and behaves as before.
[0.359.0], 2026-08-24
Added
-
You can watch the agent build. An MCP build used to be a silent canvas that
occasionally changed under you. Every authorised call to the MCP surface is now recorded on an
in-process activity bus (endpoint, what it touched, how long it took, whether it worked) and
fed to three new pieces of UI.In the canvas editor, a rail in the right sidebar leads with the step in flight, phrased as a
sentence ("Streaming code in, css, +260 B of 833 B") with a ring that sweeps while it runs.
Finished steps compress into ticks below it, repeats folding into one counted line, so
streaming a code element reads asStream code x8rather than eight rows. The agent's reads
fold the same way. A pill in the toolbar keeps the build visible with the sidebar collapsed.Elements now land one at a time instead of the whole canvas repainting: the editor diffs the
incoming document, so a new element reveals top-down like a panel refresh, an edited one
pulses, and a code element being streamed holds a running underline until the chunks stop.
Untouched widgets keep their live mount and no longer flicker, and the selection survives an
agent edit.Everywhere else in the admin, a toast names the dashboard being built, narrates each step, and
offers to open the editor, following after a short countdown by default. Following is a
checkbox on the toast and it sticks. It never navigates over unsaved edits, a form you are
typing in, or an open modal, and history never triggers it: a tab opened after a build
finished stays put.The rail also reports what a build costs the agent's context. Real token usage isn't available
to a self-hosted server (MCP carries no usage field, and the model's accounting lives in the
agent's client), so this measures the exact bytes each call sends and returns and estimates
tokens from them. Individually small steps stay quiet; a fold shows the sum, which is where it
earns its place: reading the widget catalog costs around 5,700 tokens, more than every write in
a typical build put together. The figure is always marked approximate, and runs low, since JSON
tokenizes worse than prose.The editor's rail reads a Server-Sent Events stream for immediacy; the admin shell polls a
snapshot instead, and only while its tab is visible, because every open tab would otherwise
hold a worker thread it never uses. Both surfaces are gated on themcpexperiment, so with
no agent connected nothing is wired up at all.
[0.358.0], 2026-08-24
Added
-
Seeed product links are affiliate links now, and say so. The seven SKUs covered by the
programme carry a Tesserae affiliate tag in their catalogurl, which is the single field the
README table and the generated compatibility matrix both read. A disclosure sits next to the
links on every surface that renders one, rather than in a footer.The wording claims a commission and explicitly not a discount, because there isn't one: the
affiliate parameter sets a tracking cookie and leaves the price identical (checked against the
store page with and without it). Seeed's programme does offer a follower discount, but it
arrives as a separate custom code. Nothing should promise readers money off until such a code
exists.Five Seeed SKUs keep plain product links: the EE04 pair, the XIAO 7.5" Panel and its BWR
sibling, and the C3 panel. The affiliate id looks account-level rather than per-product, so
extending it is a one-line change per manifest if wanted.The tag never reaches the app: no template renders a hardware
url, so a self-hosted Tesserae
contains no affiliate link.
[0.357.1], 2026-08-24
Fixed
-
The reTerminal Sticky was missing from the surfaces that list supported hardware. It has
shipped since 0.344.0 with a confirmed-on-hardware manifest, a firmware build and a flasher
entry, but it was absent from the README's Seeed table, absent from the confirmed-panel list on
the docs home page (which still said thirteen), and absent fromesp32_gray2_bin's tested note
despite being one of the panels that renderer is confirmed on. Its catalog entry also carried no
url, so it was the one Seeed SKU in the compatibility matrix rendered as plain text rather than
a product link. -
The M5Stack PaperS3 is listed in the README, pointing at M5's documentation rather than their
store: M5 have marked it end of life and it is sold out, so the entry is for people who already
own one rather than an invitation to buy.
[0.357.0], 2026-08-24
Added
-
M5Stack PaperS3. A 4.7" 960x540 touch e-ink handheld, added as kind
m5stack_papers3on
the existingesp32_gray_binpath: same 4-bpp linear-greyscale wire format as the reTerminal
E1003, 259200 bytes at this geometry, so the server side is a catalog entry and nothing else.
The firmware needed a driver family of its own, since this is the first panel with no controller
chip between the MCU and the glass.Composition is 540x960 portrait against a 960x540 firmware stride: the panel's source driver
scans landscape and the device is a portrait handheld, soesp32_gray_binrotates and packs
landscape rows. Nothing here has been on real glass. If a first frame lands upside down,
the display'sflipis the correction and it needs no code change.touchis false and there is no partial-refresh path, so this SKU offers neither tap echo nor
on-device touch controls yet. Both are follow-ups, sequenced behind a panel that paints
correctly.
Fixed
- Relay pairing now separates same-gamut SKUs by the stride a panel actually reports. When two
catalog SKUs share a protocol and gamut, the tie-break compared the reported geometry against the
manifest's composition dimensions only. A transposed SKU reports the row stride its firmware
reads, not the orientation it composes at, so it matched nothing and the answer fell through to an
alphabetical id sort. That was right for the pair that existed and right by accident. The
comparison now also acceptsnative_w/native_h, still exact and still in the declared axis
order (tolerating a swapped pair would have the Xteink X4 answer to the reTerminal E1001's report
as well as its own).
[0.356.0], 2026-08-23
Added
-
Tool descriptions are served, not baked in.
/api/mcp/instructionsalready served the
handshake instructions and canvas doc-shape, so those reached an installed bridge without a
release; each tool's own description did not, which is why a wrongcreate_schedulecontract sat
on PyPI for three weeks. The payload now carriestool_docs, a description per tool, and the
bridge prefers it over its embedded docstring. What still needs a release is a new tool (the
tool list is code) and any change to how the bridge handles a result. Additive key, noschema
bump: bumping would make older bridges treat the whole payload as unreadable. -
An out-of-date bridge says so in the agent session. 0.354.0 surfaced this in Settings, which
only helps someone who looks. The bridge now compares its own version against thebridge.latest
the server reports and, when behind, appends a note to the handshake instructions telling the
agent to ask the operator to runpipx upgrade tesserae-mcp. Silent when level, ahead (running
from a clone), or unparseable. -
tesserae-mcp 0.14.0 carries both, plus a
set_canvasdocstring so every registered tool has
a description of its own to fall back on.
[0.355.1], 2026-08-23
Fixed
- Changelog accuracy. The 0.355.0 entry listed the icon-reference work as unreleased; the
mcp-v0.12.0tag pointed at that commit, so it shipped inside 0.12.0.
[0.355.0], 2026-08-23
Added
- tesserae-mcp 0.13.0. The bridge has carried unreleased changes since 0.12.0 went to PyPI on
31 July, and two of them are functional rather than cosmetic:probe_widget_datanow truncates
long lists (a 24h Home Assistant history overflowed the tool-result cap, making the whole result
unusable) andlist_widgetssummarises the ~95k-character catalog. The rest are tool
descriptions, which the server cannot push to an installed bridge:create_schedule'snameand
fires_atcontract, andrender_report'sinjected_libssection and CSS-diagnostic wording.
Upgrade withpipx upgrade tesserae-mcp; an install that doesn't will now be told, by the card
added in 0.354.0.
[0.354.0], 2026-08-23
Added
- Settings flags an out-of-date MCP bridge.
tesserae-mcpis installed separately from
Tesserae, so an install could sit on an old bridge indefinitely with nothing to say so. The
handshake instructions and canvas doc-shape are served live from/api/mcp/instructions, so
those keep up on their own; what only a release moves is the tool list, each tool's own
description, and the bridge's client-side result trimming. Every bridge call already names its
version inUser-Agent, so Settings → System → MCP now records it and reveals a "Connected
bridge" card with the version, when it was last seen, and thepipx upgradeline when it is
behind the release this repo ships. The card appears only once a client has actually connected,
and opens on its own only when there is something to act on. A client that isn't the bridge is
shown as a connection without a version to compare, and a bridge ahead of this server (a clone)
is not nagged./api/mcp/instructionsalso names the shipped bridge, as an additive key needing
no schema bump.
Fixed
- The bridge install command pointed at an archived repo. Settings, the package README, and the
missing-SDK error all saidpip install git+https://github.com/dmellok/tesserae-mcp, which is
archived at 0.5.0; development moved into this monorepo. All three now say
pipx install tesserae-mcp, matching the install guide.
[0.353.0], 2026-08-23
Fixed
-
The element discriminator is documented. The canvas doc shape showed
"els": [ <element>, … ]
and never expanded<element>or named the field the type goes in, so the only way to learn it
was toget_canvasan existing page and read a real element back.DOC_SHAPEnow spells out that
the type goes inkindand nottype, and lists the kinds.add_elementsays the same, since it
is where the 422 is met. -
list_widgetsno longer overflows the tool-result cap. The full catalog runs to ~95k
characters, so it spooled to a file the caller then had to grep, for a question ("which widget do
I want") that is cheap to answer. Each widget is now summarised to its identity, description and
fragments;get_widget_options(key)already answers the option schema, for one widget rather than
every widget at once. The appearance / libraries / icons blocks pass through untouched, being
small and exactly what a summary cannot stand in for.section=narrows to one block and
full=Truereturns the unsummarised catalog.
[0.352.0], 2026-08-23
Fixed
create_schedule's tool doc contradicted the model. It describedfires_atas"HH:MM"and
never mentionedname, so a daily schedule written straight from the doc 422s twice over.
fires_atis a full datetime (only the time-of-day is read, so the date is a placeholder and
"2000-01-01T06:00:00"is the conventional way to write 6am), andnameis required alongside
idwithmin_length=1.
[0.351.0], 2026-08-23
Added
-
iconson a code element: explicit Phosphor opt-in, alongside the scan rather than replacing
it. The sandbox infers which icon weights to inline by scanning the element's own html/css/js.
That is a heuristic on author text, so it cuts both ways: it misses icons a script builds after
render, and it over-reaches, because the injected stylesheet owns.ph/.ph-*and an element
using.phas its OWN class name has that span restyled to the icon font, silently losing its
Latin text.Declaring the field takes the guess out and always wins:
falseinjects no icon CSS at alltrueinjects the regular weight["bold", "fill"]injects exactly those weights
Omitted (the default) keeps the scan, so every element built before this field behaves
identically. Narrower thanautolibs: false, which also refuses Chart.js, fonts and every other
bundle; non-icon libraries keep inferring either way.render_report'sinjected_libsmarks an
entryinferred: falsewhen the field chose it, so a report says which path was taken.
[0.350.0], 2026-08-23
Fixed
-
The CSS diagnostic reported a rewrite as a drop.
render_report's analysis diffs the authored
selectors against the ones the parser kept, but the parser rewrites what it keeps: legacy
a:beforecomes back asa::before.normSelcollapsed whitespace and quotes but not::, so
any rule written with the single-colon form was reported as "dropped by the CSS parser" while it
was applying perfectly well.decorate.js's in-sandbox
cssSelfCheckalready collapsed::in its own normaliser, so the two
analysers disagreed with each other about the same stylesheet. A diagnostic that contradicts the
render is worse than no diagnostic: it sends the author looking for a parser bug that isn't there.
[0.349.0], 2026-08-23
Fixed
-
probe_widget_datatruncates long lists. A large payload (a 24h Home Assistant history runs
to hundreds of KB) overflowed the MCP result limit with no truncation or pagination, so the whole
result was unusable and the only recourse was grepping a saved tool-result file. Lists are now
capped atmax_items(default 20), each trimmed list gaining a sibling<key>__truncatedwith
its real length so the shape stays self-describing rather than silently looking short. Scalars,
short lists and nesting are untouched.fieldsis never truncated, since the bindable dot-paths are the reason to call the tool at all.
full=Truereturns the whole payload for the cases that genuinely need every row.
[0.348.0], 2026-08-23
Fixed
-
ctx.optionswas always{}for code elements. decorate.js builds it fromel.options, and
the composer'skind == "code"branch never put options in the payload. A config input declared
withslot: "options"therefore rendered in the form, saved into the document, and did nothing at
render. Only a source's options travelled, and those go to the widget fetch rather than to the
author's code. -
A window-shadowing global blanked the whole element. Author JS ran at the sandbox's global
scope inside a baretry{}, which is not a function scope.var top = ...there cannot overwrite
the read-onlywindow.top: the assignment silently fails,topstays a cross-origin Window, and
the next property read throws "Blocked a frame with origin null", so the element rendered as red
error text. Same trap forname,status,length,self,parentandclosed, none of which
a dashboard author has reason to treat as reserved. The code now runs inside a function, where
they are ordinary locals.The tradeoff is that a top-level
function foo(){}is no longer reachable from an inline
onclick. The frame ispointer-events: noneand taps arrive through touch regions, so nothing
in a panel could call one anyway. -
console.lognever reachedrender_report(debug=1). The console hook dropped every level
that wasn'terrororwarning, so print-debugging a sandboxed code element was impossible.
Non-error levels now come through on their own budget rather than sharing the single event cap,
so a chatty element can never push a stack trace out of the report.
[0.347.0], 2026-08-23
Fixed
-
The calibration preview showed Spectra 6 on grayscale panels. v0.346.0 unhid the Calibration
tab for grey gamuts without making the surfaces behind it grey-aware, so three of them still
assumed colour.test_patterns._palette_forfell through to the six-colour E6 deck for any
unrecognised gamut, so the preview painted primaries a grey panel cannot produce, and the
profile's own ramp was then discarded by the length guard so none of the calibration being
applied was visible. The preview override readprofile.palette, which on a grey profile is an
untouched default, i.e. literal#ff0000/#00ff00. The solid-fill picker offered six inks
from the same fallback. -
The palette editor offered ink pickers a grey profile has no slots for. The grid is now one
cell per level, keyedlevel0..levelN, and its save route writes the ramp instead of reading
colour names it would never find. Live preview posts the same keys. -
Editing a grey profile wiped its ramp. Every settings route rebuilds the profile field by
field to save it, and none of them carriedgray, so touching an unrelated tone slider silently
reset a calibrated panel back to the even ramp. All five rebuild sites now carry it.
Added
strong-mid-gray4, a much larger correction thansoft-mid-gray4for a panel still washed
out with that one. Also not a measurement.
[0.346.0], 2026-08-23
Added
-
Grayscale calibration. The grey packers quantised against a perfectly even ramp (
i * 85for
4-level,i * 17for 16-level) and took no palette at all, while the colour packer has had
palette_overrideand a full tone-knob set for versions. Real e-paper grey waveforms are not
even in reflectance, so a panel whose middle levels print lighter than that renders washed out,
and error diffusion compounds it by diffusing error against values the panel never produces.A profile now carries a
grayramp:#rrggbbanchors, darkest first, describing what the
panel actually prints. That inverts the intuition, and is the thing to know before touching it:
a panel rendering too light is corrected by declaring its mid-levels lighter, because that
tells the quantizer those levels overshoot and it should reach for a darker one.Any two-or-more anchor count works and interpolates to whatever a gamut needs, so four patches
judged by eye drive a 16-level panel. The override changes which level each pixel gets and never
the wire format, since the packed bytes are level indices either way. A wrong-length override is
ignored rather than padded, which would silently remap every level above the gap.Bundled:
nominal-gray4/nominal-gray16(evenly spaced, the default, so nothing changes for
an existing panel) andsoft-mid-gray4/soft-mid-gray16, a starting point for a washed-out
panel. The soft ramps are not measurements of any particular unit and say so. -
Grey panels get a Calibration tab.
_palette_family_formapped only the colour gamuts to a
profile family and returned empty for everything else, so agray_4/gray_16device had no
picker at all and no way to reach a ramp.
Fixed
-
A palette whose first entry is not pure black could paint black pixels white. The 256-entry
palette image Pillow matches against was padded with zeroes, and Pillow considers every slot, so
the padding acted as pure black in the match. A black pixel took a padding slot instead of entry
0, and the packers, which clip an out-of-range index to the top of the range, resolved that to
white.Invisible while every palette's first entry happened to be
#000000, since Pillow settles the
tie on the lowest index. Not invisible for a measured colour profile, whose black is nearer
#1F2226, and it surfaced immediately on the first calibrated grey ramp. Padding now repeats the
last real entry, so a stray match lands on a colour the palette genuinely has.
[0.345.0], 2026-08-23
Fixed
-
The Sticky painted every frame repeated sideways, in the wrong orientation.
seeed_reterminal_stickydeclarednative_w/native_has the controller's 800x480 landscape
scan. Those fields mean the row stride the firmware reads, and this firmware transposes on the
device: it wants 120-byte portrait rows and rotates them itself. The renderer took the landscape
declaration at face value, rotated the composition 90 degrees and packed 200-byte rows.Both layouts are exactly 96000 bytes, so every size check passed and the frame was simply read
at the wrong stride. Now480x800, matching the composition, and the renderer's
orientation_mismatchbranch no longer fires.The test that covered this asserted the byte count only, which is the one property both layouts
share. It now asserts the row layout: a source whose top 8 rows are black must pack to 8*120
leading zero bytes, which fails under the landscape pack. -
Sticky instances migrated under v0.344.0 are corrected in place. An instance carries its own
copy of the kind's panel block and that copy wins, so correcting the manifest alone would never
have reached a device that had already migrated: it would have kept the landscape stride
indefinitely.migrate_retired_sticky_kindsnow also rewrites the stride on instances already on
the successor kind, and only when it is the exact wrong pair, so an operator's deliberate value
is left alone.
[0.344.0], 2026-08-23
Removed
-
seeed_stickyandseeed_sticky_grayretired. One physical product carried three catalog
entries. These two described it running CrossInk and were written from the V01 schematic and
Seeed's vendor peripheral demo rather than a validated unit: never confirmed on hardware, and
declaring a self-flagged provisionalportrait_flippedthe board does not use.
seeed_reterminal_stickysupersedes both, and the Sticky now appears once.The split was never cosmetic, which is why the merge is a removal rather than a fold. A kind is
an OTA lineage: release descriptors are signed with adevice_kind, rejected on
kind_mismatch, andauto_update_idssweeps every device of a kind. Two firmwares sharing one
kind would offer tesserae-device-firmware images to CrossInk units. Supporting a second firmware
again means a second kind, not a variant of this one. -
migrate_retired_sticky_kindsmoves instances off the retired ids at startup, before the
loader scan, since an instance pointing at a kind the catalog no longer carries doesn't load and
the device 404s. The panel block moves with it: instances carry a copy of their kind's panel, so
a device left on the retiredportrait_flippedwould paint every frame 180 degrees out. Fields
the operator changed are left alone, same rule as a kind heal. A retired mono instance also
picks upgray_4, since the successor packs 2-bpp and has no 1-bpp variant.Done as a migration rather than
deprecated_aliasesdeliberately. Under an alias both ids
resolve to the same object, so the old kind's panel is the new kind's, and a stale
portrait_flippedon an instance would read as a deliberate operator override and survive.
[0.343.0], 2026-08-23
Added
-
seeed_reterminal_stickydevice kind. A Seeed reTerminal Sticky running the
tesserae-device-firmware build, confirmed on real hardware. The wire format is byte-identical to
seeed_reterminal_e1001_gray: exactly 96000 bytes (800*480/4), row-major from top-left, 4 pixels
per byte MSB first, 2-bit0b00black to0b11white. The renderer and packer are shared
unchanged.orientationisportrait, not theportrait_flippedthe existing CrossInk Sticky entries
declare. The controller scans 800x480 landscape and the glass is mounted at 90 degrees, so the
firmware transposes on the device and the server renders ordinary 480x800 portrait. That mapping
was pinned on hardware rather than inherited.seeed_stickyandseeed_sticky_grayare
unchanged and continue to serve CrossInk units, which composite a base frame plus two planes and
may map the panel the other way round.Declares
touch: true, so tap dispatch and the editors' Interaction UI apply to it. A room
panel that shows what's on next and takes a booking tap is the case this matters for.Carries
auto_select: false. On the wire this SKU is indistinguishable fromxteink_x4_gray:
same protocol, gamut, 480x800 geometry and byte count. A device running this firmware names its
own kind at register and discover, but relay pairing infers a SKU from the wire signature
instead, and this id sorts ahead ofxteink_x4_grayin that tie-break. Without the flag an
Xteink X4 pairing over a relay would be relabelled a reTerminal Sticky, the failure v0.321.3
fixed forseeed_sticky_gray. -
touchdocumented inschema/hardware.schema.json. The key has gated touch dispatch and the
Interaction UI since #49, but was absent from the schema, so a manifest author had no way to know
it existed. Additional properties are permitted, so it validated either way and a SKU with a
digitizer silently came out untappable.
Fixed
-
A healed device kept its old kind's panel.
create_instancecopies the kind's panel block
into the instance file, and an instance's panel overrides its kind's, so moving a device to the
kind its firmware now declares left it describing the previous board. The heal rewrotekindand
dropped a stalerenderer_idbut never touchedpanel.Mostly invisible between siblings that share a panel, and wrong in two ways that are not. A
Sticky healing fromseeed_sticky_graytoseeed_reterminal_stickykeptportrait_flippedand
painted every frame 180 degrees out on a panel that was otherwise working. Worse, the case the
heal was written for, a device pairing under a generic protocol kind and coming back announcing
its SKU, kept the generic kind's 800x480 landscape default:esp32_clienthealing to
seeed_reterminal_e1004rendered at 800x480 instead of 1200x1600 portrait.Panel fields still equal to the old kind's value now move to the new kind's. Fields the user
changed are left alone, so a deliberate per-display override survives; same reasoning as the
stalerenderer_iddrop it sits beside.
[0.342.0], 2026-08-23
Added
-
crosspoint_graydevice kind. A CrossPoint e-reader painting a Tesserae dashboard as its
sleep screen over the LAN, with no cloud component. The reader draws the image twice, through
its greyscale LSB and MSB planes, quantising each pixel to 0-3, so the kind declares a 480x800
portrait panel on thegray_4gamut. Registering as a generic CircuitPython client left the
panel on a mono default and those levels were discarded server-side, before the file was
written.The renderer is shared with the CircuitPython kinds rather than forked: it selects its palette
from the bound panel's gamut, so the same pipeline emits four levels here and one bit elsewhere,
and the wire format (uncompressed indexed BMP) is identical. Verified end to end: a gradient
comes back as 4 bpp, uncompressed, 480x800, under 190 KiB, with exactly four grey values. -
GET /frame.bmphonours?button=. The route previously served the newest stored render
and nothing else, so a client whose only interaction is "download my sleep screen" saw the same
bytes for ever:/framehands back the last rendered artefact and the re-render checks hang off
the poll. A button now dispatches synchronously before the artefact is chosen, exactly as on the
poll, and the frame it produces is what that same response carries. Both routes share one
dispatch helper so they cannot drift, including theeventalias older firmware sends.frames.jsonentries carrybutton=refreshfor the same reason; without it the on-device
picker links to a frame that may be hours old. -
frame.bmpanswers aRange: bytes=0-0probe with a206and one byte, so a reachability
check does not pull a whole image through a client-side relay that may cap well below the frame
size. Any other range falls through to the full body, which is a valid answer to any range
request.HEADis deliberately not the mechanism: it promises a Content-Length with no body,
which such a relay reports as truncated.
[0.341.0], 2026-08-22
Fixed
-
A relay refusal says why. Registering an install against a relay reported only
POST https://... -> 400. The relay had already explained itself in the JSON error body it
returns, and the client parsed that body for itscodeand then discarded the human-readable
message. The message now reaches the error, truncated, so an operator sees
-> 400: install_pubkey requiredrather than a bare status. -
The relay container logs one line per request. Method, path and status. Without it a refusal
was invisible from the relay side: an operator had only whatever status their Tesserae install
reported and nothing to compare it against, which matters most when something between the two,
such as a reverse proxy, is the thing refusing. Paths only, never query strings or headers,
since tokens live in both.
[0.340.0], 2026-08-22
Fixed
-
A widget that serves its own media renders again (#255). v0.329.0 put plugin blueprint
routes behind the password gate, exempting only the static asset handler and endpoints a plugin
declared throughRENDER_SAFE_ENDPOINTS. An installed catalog widget that serves artwork from
its own blueprint therefore had that fetch refused during a render, which happens over loopback
with no session, and every panel drew a broken image. The declaration is an in-tree convention
its author never saw, and the widget is not in this repository, so the sweep that added it for
Picture Gallery could not have found it.A read of a plugin's own sub-route is allowed from loopback again. Read-only is the line
that keeps the original hole shut: every dangerous route found when this was tightened was a
mutation (a gallery folder delete callingshutil.rmtree, calendar feed writes, CalDAV
discovery against a caller-supplied URL), and those stay refused. A plugin's index stays refused
even on a read, since it lists loader errors and plugin contents and gtfs turns a query argument
on it into an outbound request.Narrower than before as a result: a plugin exposing a destructive action over GET would now be
reachable from loopback. Every such route in this repository is a POST.
[0.339.0], 2026-08-22
Fixed
-
A cold offline album can sync (#247). The version announced on
/statusand the version the
manifest endpoint served were computed from different inputs, so on an album whose frames had
never been rendered they could not match./statuscomputes without warming, because it runs on
every heartbeat and must stay cheap, which leaves each frame with an empty digest and
cache: false; the manifest endpoint warms as a side effect of the fetch, which fills both in.
Both were hashed into the version. Firmware requires the fetched manifest to carry the announced
version and rejects it otherwise, correctly, since the version is its only guarantee that it is
caching against what the server currently intends. So every fetch was rejected, nothing cached,
the frames re-warmed, and the device reportedsyncing, 0 of 0for ever while the event log
filled withalbum_warm.There is now one computation, derived from what a device syncs against: the album identity, its
ordered frame ids, its fit, and the playback block. Warming cannot move it./statusno longer
builds a manifest to get there, so the heartbeat path stops walking frames and stat-ing
artifacts entirely.fitis newly part of it. Changing it drops the warm cache and re-renders every frame to
different bytes, and the manifest body never carried it.The device's advertised caps are deliberately not part of it, though they still shape which
frames are offered for caching. Including them would let a card swap force a re-sync, but the
contract describescapacity_bytesonly as "the storage budget the server plans against" and
nothing promises it is constant: firmware reporting free rather than total space would move the
version on every beat, which is a permanent re-sync loop, the same failure this fixes and harder
to see. Both endpoints now read caps through one helper so they cannot drift either.
[0.338.0], 2026-08-22
Fixed
-
A render that finishes with broken images now says so (#255). The image wait resolves on
erroras well asload, which is right (a dead upstream must not hold a render hostage) but
meant a page full of broken-image glyphs completed silently: the frame reached the panel, the
server logged a success, and the only evidence was on the glass. Failed images are now counted
and named in a warning, and carried inrender_report?debug=1alongside the existing settle
timings.URLs are logged without their query string. A Home Assistant
entity_picturecarries its auth
token there, and a diagnostic is not a place to write someone's credential.
[0.337.0], 2026-08-22
Added
-
The relay image is published to GHCR.
ghcr.io/dmellok/tesserae-relay, multi-arch
(amd64 + arm64), so self-hosting no longer means cloning the whole repository to build a
480-line mailbox.:edgetracksmainand every build publishes an immutablesha-<commit>
tag;:latestappears with the first tagged relay release.The relay is versioned independently of Tesserae, from a
relay-v*tag. The two are coupled by
the wire contract rather than a version number, and tagging the image with an application
version would imply a lockstep that does not exist. Its workflow is path-filtered, so an
application commit does not rebuild it, and it runs the relay test suite before publishing.
[0.336.0], 2026-08-22
Added
-
The relay runs as a container, without Cloudflare (discussion #254). Self-hosting it
previously meant deploying the Worker to your own Cloudflare account. It now also runs on Node
with the filesystem standing in for R2:docker build -t tesserae-relay packages/relay, or
npm starton any machine with Node 18 or newer.The same
src/index.jsruns in both deployments. The Cloudflare coupling was only ever the R2
binding used as a blob store (get,put,delete,list) plus an optional analytics
binding, so the container swaps the backend rather than forking the Worker. The wire contract is
unchanged, which means a panel paired against one relay reaches the other by changing its base
URL, with no firmware change.Two differences the container handles itself. Cloudflare provides public HTTPS; a container
wants TLS in front of it, so it speaks plain HTTP and answersGET /healthz. And where the
Worker deployment expires abandoned pairing records with an R2 lifecycle rule, the container
sweepscode/andpair/in-process every hour against theexpires_atthe records already
carry. Frame blobs self-clean on upload in both.packages/relayalso gains atestscript; the suite runs on plainnode --testwith no
wrangler or Miniflare, and now covers the storage backend, the HTTP bridge, and the sweep.
[0.335.0], 2026-08-22
Added
- Add a REST device without pairing (discussion #240). Pairing needs the device to run the
/registerhandshake, which a digital sign, a browser tab, or a photo frame never will: they
only fetch an image URL. Those still have to exist as a device so a page can target them and
/preview/<id>.pngfollows their lineup. The REST branch of Add device now carries an "Add
without pairing" form with the same fields as the MQTT one; it puts the instance on the REST
transport and mints its access token in one step. Adding without that field still reads as MQTT,
so the existing path is unchanged.
[0.334.0], 2026-08-22
Fixed
-
The day calendar no longer draws tomorrow's events across today (#248). The widget fetches a
rolling window from now,hours_aheadwide, so the 24 hour default reaches into tomorrow.
All-day events were filtered to the day being drawn; timed events were not, so an event moved to
tomorrow stayed in today's list. Drawn against today's 0-24 axis it hit the clamp meant for
events that overlap today (an overnight shift, a multi-day trip), whose rule reads "neither edge
is today" as "runs straight through today", and painted the whole column while its label still
read the real 10:00-11:00. It looked like today's copy had gone stale, which is also why the
week view disagreed: that one buckets by date and was correct. Timed events are now filtered to
the day being drawn, and the client refuses to clamp an event that does not touch that day.
Overnight and multi-day events that genuinely occupy today are unaffected.One consequence worth knowing:
hours_aheadabove 24 no longer shows anything extra, because
the widget draws a single day's axis and anything beyond it is filtered out.
[0.333.0], 2026-08-22
Fixed
-
The admin UI no longer stalls behind abandoned device streams. A device that reopened
/streamfaster than the keepalive interval stacked one waitress thread per attempt. The SSE
generator only learns its client has gone when a write fails, and between writes it slept
holding the connection, so at a 25 second keepalive and a ~1.6 second reconnect loop roughly 16
of 24 threads sat on connections nobody was reading, each still scanning server state and
issuing Home Assistant queries every 2 seconds. Page loads queued behind them for 9 to 23
seconds. There is now at most one live stream per device (a new connection retires the older
one) and the keepalive is 5 seconds, so a dead peer is reaped promptly. -
OpenDisplay Home Assistant telemetry works at all. The poller runs on its own thread, where
there is no Flask application context, and the Home Assistant query it makes needs one. Every
poll raised "Working outside of application context" and was swallowed at DEBUG, so the
integration had never once recorded a reading. A poll where every configured tag fails now logs
a warning rather than leaving a total failure invisible. -
An always-on panel's polls are no longer scored against a wake prediction. Such a device has
no wake to predict, so every poll read as a missed prediction and reset smart-sync confidence
repeatedly for a device behaving correctly. Nothing consumed the prediction for it either.
Changed
- A firmware kind with no release feed is re-checked every 12 hours instead of every hour. A
404 is a stable fact about the kind, not a transient failure.
[0.332.0], 2026-08-22
Fixed
-
An always-on panel now polls on its awake cadence instead of its panel refresh floor. A
reTerminal E1003 set to stay awake with a 5 second cadence was told to poll every 60, so a
manual Send took up to a minute to appear. The cause wasrefresh_floor_s, which describes how
fast the glass can be repainted, being applied to how often the device asks. A poll is a
conditional GET that answers 304 while the frame is unchanged, and a 304 never reaches the
panel, so the two limits are about different things. The awake cadence is now used as-is.The 30 second content-poll floor also no longer applies to an always-on device. It exists to
stop a sleeping panel spinning its radio up for a change it could have waited for; a panel that
never sleeps is already associated, and holding it there discarded the pull-forward the floor
sits inside. Deep-sleeping devices are unchanged, floor included.
[0.331.0], 2026-08-22
Added
-
GET /api/v1/device/<id>/frame.bmpserves the frame's bytes directly. The normal frame path
is a two-hop envelope: JSON naming a content-addressed artefact, then a fetch of that URL. Some
clients cannot walk it. An e-reader pulling a dashboard as its sleep screen does so from a
declarative download handler that makes exactly one request, follows no redirects, and cannot
attach headers of its own. This route answers that shape: stable URL, image in the body, no
redirect. It serves an uncompressed indexed BMP whatever the device's configured frame format
is, so adding it changes nothing for that device's existing client; the same composition is
re-transformed into a second, decoder-free container.The route never answers
2xxwithout a complete body. Such clients stream to a temporary file
and rename it over the live sleep screen on any2xx, so a204, or a200with an empty body,
would replace a working screen with a zero-byte file. "No frame yet" is a404rather than the
204the envelope route uses, a failed conversion is a5xx, and a body over the client's 1 MB
download cap is refused with the measured size rather than served for the client to fail on. -
GET /api/v1/device/<id>/frames.jsonlists pullable frames for an on-device picker, so a
reader can fetch a dashboard on demand without a computer in the loop.
Changed
- Those two routes also accept the device token as
?k=<token>. The download handlers they
exist for own the request and cannot set headers, so the URL is the only channel. Opt-in per
route rather than a global auth change: a token in a query string lands in access logs, proxy
logs and browser history in a way a header does not, so it stays confined to the routes with no
alternative. Headers still win when both are present, and every other endpoint remains
header-only.
[0.330.0], 2026-08-22
Added
-
Mains-powered panels can stay awake. The 30-second floor under the sleep interval exists so
a typo can't flatten a battery, and every reachability limit follows from deep sleep: a Send, a
schedule fire or a touch cannot reach a sleeping device, so it lands whenever the panel next
happens to wake. A panel on a wall socket is paying that cost for a guarantee it doesn't need.
Devices whose firmware advertisescan_stay_awakeare now offered a Stay awake switch and an
awake poll interval (5-300s, default 15s) on their device card. With it on,next_poll_sis
derived from the awake cadence rather than the sleep interval and the 30-second content-poll
floor no longer applies, so a server that knows content is about to change can hand back
intervals in the single-digit seconds. A panel's declaredrefresh_floor_sis enforced here,
which is the first path that could ask for a cadence under 30s.The switch is gated on the advertised capability rather than on the model, because a reTerminal
on USB and the same board on its battery are the same kind, and only the firmware can tell them
apart. Server-side support only; firmware handoff in
notes/design-handoffs/firmware-always-on-prompt.md.
Changed
- Smart sync no longer holds renders for an always-on device. It exists to time a render to
land just before a panel wakes, which has no meaning when the panel is reachable continuously;
holding would only delay a frame the device could already have collected. Bound schedules and
rotations fire on their own cadence instead, and the device card explains why rather than
leaving the panel on "warming up" forever.
[0.329.0], 2026-08-22
Security
- Plugin routes no longer answer unauthenticated requests from loopback. The exemption that
lets the in-process renderer fetch/plugins/<id>/client.jswithout a session was matched by
path shape, and plugin-provided blueprints mount under that same prefix, so every route a plugin
registered inherited it: Picture Gallery folder deletes, Calendar feed writes, and CalDAV
discovery against a caller-supplied URL were all reachable with no password. Loopback is not
only the operator: the screenshot and remote-image flows run Chromium with local addresses
allowed and no request interceptor, so a rendered page could POST to127.0.0.1itself, and
there are no CSRF tokens behind the gate. The exemption is now matched on the resolved endpoint
and covers only the static asset handler, whose own allowlist bounds what it serves.
Added
- Plugins can declare
RENDER_SAFE_ENDPOINTS. A widget whose markup points at its own
plugin's media route needs that route served during a session-less render. Picture Gallery
declares its two read-only image endpoints; anything undeclared stays behind the gate.
[0.328.0], 2026-08-21
Added
-
Resync an offline album from Settings → Devices (#247). The collection version is a digest
of the manifest content, so it moves only when the album does. That leaves no lever for the case
where server and device disagree for a reason the content cannot express: an interrupted sync, a
swapped card, frames the device dropped. Until now the only workaround was editing the album, a
real change made for a fake reason. Resync folds a per-device token into the version after the
content digest, so the manifest body stays byte-identical and only the version the device
compares against moves. Firmware needs no support for it; it looks like an ordinary content
change. The device acts on it at its next wake, not immediately. -
The album line shows before a device has reported. It previously required a matching
playback report, so a fresh binding, or one whose device had gone quiet, showed nothing at all.
The line now appears whenever an enabled album is bound, with the state pill replaced by "no
report yet" until one arrives, and it carries the Resync control.
[0.327.0], 2026-08-21
Added
-
The offline-album sync path says what it did (#247). It was almost entirely silent on the
happy path, so a device that never requested a manifest and a device that requested one and got
nothing usable looked identical in a journal. The manifest endpoint now logs the device, album,
version and frame count, and the builder logs how many frames are cacheable. -
SyslogIdentifier=tesseraein the generated systemd unit (#247). journald tagged every line
python[<pid>], which is indistinguishable from any other Python on the machine. Existing
installs pick this up by re-runningscripts/install-systemd.sh.
Fixed
- Frames that could not be rendered were reported as exceeding the device's caps. Two different
situations: being over a device'smax_framesor byte budget is expected on a large album, while
having no render at all is a fault that leaves the frame unfetchable. They shared one log line, so
the second was invisible.
[0.326.2], 2026-08-21
Fixed
- Formatting only:
tests/test_device_telemetry.pywas left unformatted in 0.326.1, failing
ruff format --checkin CI. No behaviour change.
[0.326.1], 2026-08-21
Fixed
-
A device read as overdue after its sleep interval was changed (#246). The wake prediction is
stored when a heartbeat arrives, derived from the interval configured at that moment. Editing the
interval afterwards left the old prediction in place, so a panel moved from 1 minute to 1 hour
looked overdue for the best part of an hour, and the scheduler aimed its smart-sync render at a
time already past. Saving a device now re-derives the prediction from its last heartbeat. A
firmware-publishedsleep_until/next_sleep_sis left alone, since the device's own statement
outranks a server setting; the telemetry entry records which of the two it holds. Confidence
resets on reprojection, because past on-time wakes say nothing about a cadence that just changed. -
Offline albums offered frames the device could not fetch (#247). Frames are addressed by
digest, and a frame that failed to render carried an empty digest and measured zero bytes, so it
always fit the capacity budget and was always markedcache: true. The device was told to cache
something it had no way to request, cached nothing, and reported "0 of N frames cached" while the
server kept re-offering the same set. A frame with no digest is now never offered for caching.
[0.326.0], 2026-08-21
Added
-
calendar_core records why a fetch failed. The fetch path knew whether a feed was reachable
and threw it away, so a 401 was indistinguishable from a calendar with no events, and anything
reading that feed showed an empty result as though it were the truth. Outcomes now go to
feed_health.jsonnext to the feed cache: last success, the reason for the current failure, and
how long it has been failing. Written only when a fetch is genuinely attempted, which the cache
TTL bounds to once per feed per 15 minutes, so it is not hot-path I/O. -
Settings → Rooms shows feed health and what's on next (#90). A room whose feed is failing says
so on its row, with the reason, instead of appearing to be a free room. The Next column shows the
current booking's end or the next one's start.Next is read only when the feed's cache is already warm. A settings page must not make one network
call per room to render, so a cold feed shows a dash rather than blocking the page on up to a
15 second timeout each. The dash means "not known right now", never "free".
[0.325.0], 2026-08-21
Changed
-
Settings → Rooms redesigned (#90). The page rendered every room as a fully expanded
nine-field form stacked vertically, so eight rooms was eight identical forms with nothing
scannable and the add form buried at the bottom. A room is configured once and then checked on,
so the page is now a list of state: one row per room stating its feed, panels, booking method and
dashboard status, with editing behind a disclosure. Adding a room moved to the top and asks for
two fields instead of nine; everything else is set on the row afterwards.Panels are checkbox cards rather than a
<select multiple>, which hides its own state and is
hard to operate on a phone. Removing a room is a guarded disclosure, and deleting its dashboard
stays a separate, never-preselected choice. No state is conveyed by colour alone: every one is an
icon plus a word. Works without JavaScript throughout; the three per-room actions remain three
separate forms, linked by the buttonform=attribute rather than nested.Below 720px the column headers drop and each row restates its facts with its own inline labels
("Feed …", "Panels 2", "Booking Off"), since there are no headers left to read them against. A
failing feed leads that list, being the reason someone opened the page. -
A room has one booking method instead of two independent fields.
book_urland
book_caldavcould both be set, and neither the model nor the UI said which won. They are
replaced bybooking_mode(none/endpoint/caldav). Existing rooms migrate on load,
resolving the ambiguity the way dispatch actually did: the CalDAV toggle won over a URL.When a room's feed cannot be written to, the CalDAV option is disabled with the reason beside it
naming the feed, rather than a footnote. An.icsURL is read-only whatever credentials it
carries.
[0.324.0], 2026-08-21
Added
-
Room board (#90). Settings → Rooms builds one dashboard carrying a row per enabled room, for
a lobby or corridor display. It needs no widget of its own: a board is a generated page with a
room_statuscell per room, so it inherits every fix the widget gets. Booking is off on a board,
because a tap would book whichever room the finger landed on. Rebuild after adding or removing a
room, since the row geometry depends on how many there are. -
Booking from a room panel (#90). Set a room's booking endpoint and its panel shows a book
button; a tap POSTs there and repaints a few seconds later, so the panel shows the room it just
booked. The room id rides in the query string (?room=kestrel), because the webhook payload
carries the device and, only for a rotation-bound page, a page id; a room panel is normally bound
directly, so one endpoint serving several rooms could not otherwise tell which was tapped. An
operator's own query string is preserved. -
Book a room directly over CalDAV (#90). A room whose feed was added by CalDAV discovery and
carries a username and password can be booked with no endpoint in between: a tap writes the event
into the room's own calendar. Newroom_book:<id>action, side-effecting like the webhook
actions, so widget markup cannot aim it.One
PUTof oneVEVENT, reusing the collection URL, credentials and HTTP openercalendar_core
already stored, so no new credential storage and no new auth surface. Basic and digest only,
covering Radicale, Baikal, Nextcloud and iCloud app passwords; Google and Microsoft 365 need OAuth
with a registered application and use a booking endpoint instead.If-None-Match: *makes it a
create, never an overwrite, so a collision fails rather than replacing an existing meeting.
Availability is not pre-checked: between checking and writing someone else can book anyway, and
the calendar server is the only thing that can arbitrate. -
Rooms documentation at
docs/install/rooms.md: setup, the shared-calendar location filter,
the board, what a booking endpoint receives, the CalDAV requirements, and whyPOST /api/v1/push
from the receiving server beats the timed repaint.
Fixed
- CalDAV write failures pointed at the wrong problem. Found by running the booking path against
a real Radicale server rather than a stub. A collection that doesn't exist answers409, not
404, and was falling through to a bare "the calendar returned HTTP 409"; it now says no calendar
exists at that address, which is the likeliest real misconfiguration. A403was reported as
rejected credentials, sending an operator to re-check a password that was never wrong; it now says
the login was accepted and the write refused.401keeps the credentials message.
[0.323.0], 2026-08-21
Added
-
Rooms (#90, phase 1). Settings → Rooms. A room names a calendar feed, the panels showing
it, and optionally an endpoint that books it, then generates its dashboard. Twelve rooms used to
mean twelve hand-composed pages with the same widget options retyped into each.Rooms is a generator, not a runtime. It writes an ordinary page, with an ordinary widget in an
ordinary cell, bound to ordinary devices, and stops; nothing renders through a room. It holds
configuration and never a booking, so Tesserae does not become the source of truth for whether a
room is free, and does not inherit the availability contract that would come with it. Deleting
the feature would leave every generated page working exactly as a hand-composed one does.Regenerating preserves what rooms does not own: theme, style and font survive a rename, so
restyling a room's dashboard to match the rest of an office is not reverted. Removing a room
offers to remove its dashboard, and refuses to touch a page it did not generate.
[0.322.0], 2026-08-21
Added
- A widget can declare when its own data goes stale (#243).
next_poll_swas derived from
schedules and rotation steps only, so anything whose displayed state turns over on its own clock
was invisible: a room going free at 15:00, a bin collection at dawn, a countdown hitting zero. A
widget'sfetch()may now returnnext_change_at(ISO 8601 or a unix timestamp); the composer
records the soonest across the page, and the status path folds it in beside the schedule
projection under the same ceiling, floor and margin. The two sources are gathered independently,
so a scheduler fault no longer discards the widget's hint along with its own. - A frame invalidated by a declared change is re-rendered on the next poll (#243). Waking at the
right instant achieved nothing on its own, because/framereturns the last rendered artefact: a
panel told to come back at 15:00:20 would collect the frame composed at 14:45. When a declared
change is in the past and the stored frame predates it, the poll re-renders before serving. The
hint is consumed on the way out, so it is at most one synchronous render per change, never per
poll. plugin:<id>capability delegation (#244). A widget that fetches through another plugin makes
no requests of its own, but the delegate's request runs inside the widget's capability scope, so
declaring anyrequires:entry got the widget denied at the socket layer for a host it cannot
know. The only working declaration was no declaration, which is the opposite of what the catalog
asks for.plugin:<other_id>now means "that plugin's declared hosts are allowed while I run":
narrower thannetwork:*, visible in review, and bounded by the delegate's own declaration. An
undeclared or missing delegate grants nothing, so it cannot be used to inherit a legacy plugin's
implicit pass, and cycles terminate.
[0.321.3], 2026-08-21
Fixed
- Relay pairing could resolve an Xteink X4 to the new Seeed Sticky grayscale SKU. The two are
indistinguishable on the wire: sameesp32_bw_clientprotocol, samegray_4gamut, same 480x800
declared panel, sameesp32_gray2_binrenderer, byte-identical frames. With nothing in the
self-report to separate them, the id sort that settles ties handed the announcement to
seeed_sticky_gray, which would have pinned a confirmed X4 to an unconfirmed SKU's OTA lineage.
seeed_sticky_graynow declaresauto_select: false, the mechanism already used for the E1001's
two grayscale glass variants, so an ambiguous report keeps resolving toxteink_x4_grayand a
real Sticky reaches the kind by declaring it at register or discover.
[0.321.2], 2026-08-21
Fixed
- Guards against the action-surface drift that caused the 0.321.1 fixes. A button/touch
action has to be declared in four places (the registry, the touch editor's hardcoded picker,
the side-effecting set behind the provenance gate, and the feedback classifier), and missing
one failed silently. Missing the provenance-gate entry failed silently and opened a hole.
Tests now assert every registered action is offered in the picker, is explicitly classified as
side-effecting or safe, and has a feedback type, so a new action forces a decision on each
surface instead of inheriting a default.
[0.321.1], 2026-08-21
Fixed
webhook_refreshwas missing from the provenance gate.SIDE_EFFECTING_ACTIONSstill
listed onlywebhookandha, so awebhook_refresh:spec coming from raw widget markup
passed the origin check that exists to stop markup aiming HTTP requests. Both the build-time
manifest filter and the dispatch-time gate read that set, so the hole was present on both
paths. Config-declared actions were always fine; only markup origin was affected.webhook_refreshwas absent from the touch editor's action dropdown. The tap/swipe picker
instatic/touch_interaction.jscarries its own hardcoded list rather than reading the action
registry, so the action could be set through a device'sbutton_mapor the API but not chosen
in the UI.webhook_refreshfell through tonavfeedback classification, so a tap on one showed
navigation affordance instead of the webhook one. Now classified alongsidewebhook.- Added to the MCP action vocabulary (
/api/mcp/actions/describe), which previously documented
webhookalone.
[0.321.0], 2026-08-21
Added
webhook_refresh:<url>button action (#242). Fires the same POST aswebhook:, then
re-renders and repaints the panel a few seconds later, for buttons whose webhook changes the
state the dashboard reads: book a room, toggle a door, file a ticket. Plainwebhook:is
fire-and-forget and leaves the panel showing pre-action state until its next wake, which on a
deep-sleeping panel can be a long time. The repaint is deliberately delayed rather than
immediate: the POST carries no acknowledgement, so rendering inside the wake would usually read
the receiver's pre-action state and repaint the same frame. Default wait is 5 s, configurable
viabutton_webhook_refresh_delay_s; a burst of presses coalesces into one repaint. Existing
webhook:buttons are unchanged and still never repaint. Works in touch regions as well as
physical button maps, since both validate against the same action registry.
[0.320.1], 2026-08-20
Fixed
- Client-protocol guidance on sizing
sleep_interval_swas left describing the old behaviour.
It still advised setting the interval to the refresh cadence you wanted and accepting the
304s, which the projection work in 0.320.0 inverts: the interval is now a backstop, and
setting it well above the schedule interval lets the schedule drive the wakes. Replaced with a
sizing section covering what the ceiling actually covers (manual Send, webhooks, Home Assistant
events) and the case with no schedule bound at all, where it remains the refresh cadence.
[0.320.0], 2026-08-20
Changed
next_poll_snow means "new content is probably available then" (#241). It previously
only ever echoed the device's configuredsleep_interval_s, which knows nothing about when
the dashboard actually changes: a panel on a 15-minute grid against a 07:00 daily schedule
burned 95 wakes to catch one change, and could see it up to 15 minutes late. The REST status
path now consults the same projection engine that already backs the Companion API and the
scheduler, and pulls the returned value forward to the next due schedule or rotation step,
plus a margin so the client doesn't poll into the middle of the render. The configured
interval stays the ceiling and is never extended, since manual Send, webhooks, Home Assistant
events and data-change refreshes have no schedule to project.estimatedprojections are
ignored. Purely server-side: clients already honouringnext_poll_sget the benefit with no
firmware change, and clients ignoring it are unaffected.
Fixed
- CircuitPython renderers marked as partially tested.
circuitpython_bmpis confirmed on
Pi-class hardware through the displayio PyGame emulation path; the compatibility matrix still
listed it as untested. Held short of Tested because the microcontroller route via
adafruit_imageloadis unverified.
[0.319.0], 2026-08-19
Added
- Hardware catalog entry for the Seeed XIAO 7.5" ePaper Panel (C3). Seeed's integrated
7.5" panel with an ESP32-C3 on board, supported by device-firmware from v1.16.0. Registers as
xiao_epaper_panel_75_c3on theesp32_bw_clientprotocol with theesp32_bw_binrenderer,
unchanged: the frame is the same 48000-byte 1-bpp mono buffer thexiao_epaper_75kit and the
reTerminal E1001 already take. It needs its own kind rather than an alias because the kind id
names the OTA lineage and the two firmware images are not interchangeable. Panels running this
firmware could announce but not be registered, because nothing in the catalog matched the kind
they reported.
Changed
- The three XIAO 7.5" kinds now read differently in the kind picker. Two separate products
share this glass, and a third entry covers one of them on TRMNL BYOS firmware, so the names
had converged to the point of being unpickable. They are now "XIAO 7.5" ePaper Panel (C3)"
(integrated, Tesserae firmware), "XIAO 7.5" ePaper Panel (C3, TRMNL BYOS)" (same hardware,
TRMNL firmware), and "XIAO ePaper 7.5" (S3, mono)" (the ESP32-S3 Plus driver board sold as
the TRMNL 7.5" OG DIY Kit). The S3 entry's product link pointed at the C3 panel's page and
now points at the kit it actually describes. Kind ids are unchanged, so existing devices are
unaffected.