github dmellok/tesserae v0.363.0

6 hours ago

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:ms pairs,
    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 external POST /api/v1/push from 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": true with 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 makes webhook_refresh land 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 as Stream code x8 rather 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 the mcp experiment, 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 catalog url, 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 from esp32_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_papers3 on
    the existing esp32_gray_bin path: 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, so esp32_gray_bin rotates and packs
    landscape rows. Nothing here has been on real glass. If a first frame lands upside down,
    the display's flip is the correction and it needs no code change.

    touch is 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 accepts native_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/instructions already 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 wrong create_schedule contract sat
    on PyPI for three weeks. The payload now carries tool_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, no schema
    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 the bridge.latest
    the server reports and, when behind, appends a note to the handshake instructions telling the
    agent to ask the operator to run pipx upgrade tesserae-mcp. Silent when level, ahead (running
    from a clone), or unparseable.

  • tesserae-mcp 0.14.0 carries both, plus a set_canvas docstring 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.0 tag 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_data now truncates
    long lists (a 24h Home Assistant history overflowed the tool-result cap, making the whole result
    unusable) and list_widgets summarises the ~95k-character catalog. The rest are tool
    descriptions, which the server cannot push to an installed bridge: create_schedule's name and
    fires_at contract, and render_report's injected_libs section and CSS-diagnostic wording.
    Upgrade with pipx 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-mcp is 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 in User-Agent, so Settings → System → MCP now records it and reveals a "Connected
    bridge" card with the version, when it was last seen, and the pipx upgrade line 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/instructions also 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 said pip 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 to get_canvas an existing page and read a real element back. DOC_SHAPE now spells out that
    the type goes in kind and not type, and lists the kinds. add_element says the same, since it
    is where the 422 is met.

  • list_widgets no 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=True returns the unsummarised catalog.

[0.352.0], 2026-08-23

Fixed

  • create_schedule's tool doc contradicted the model. It described fires_at as "HH:MM" and
    never mentioned name, so a daily schedule written straight from the doc 422s twice over.
    fires_at is 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), and name is required alongside
    id with min_length=1.

[0.351.0], 2026-08-23

Added

  • icons on 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 .ph as 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:

    • false injects no icon CSS at all
    • true injects 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 than autolibs: false, which also refuses Chart.js, fonts and every other
    bundle; non-icon libraries keep inferring either way. render_report's injected_libs marks an
    entry inferred: false when 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:before comes back as a::before. normSel collapsed 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 cssSelfCheck already 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_data truncates 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 at max_items (default 20), each trimmed list gaining a sibling <key>__truncated with
    its real length so the shape stays self-describing rather than silently looking short. Scalars,
    short lists and nesting are untouched.

    fields is never truncated, since the bindable dot-paths are the reason to call the tool at all.
    full=True returns the whole payload for the cases that genuinely need every row.

[0.348.0], 2026-08-23

Fixed

  • ctx.options was always {} for code elements. decorate.js builds it from el.options, and
    the composer's kind == "code" branch never put options in the payload. A config input declared
    with slot: "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 bare try{}, which is not a function scope. var top = ... there cannot overwrite
    the read-only window.top: the assignment silently fails, top stays 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 for name, status, length, self, parent and closed, 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 is pointer-events: none and taps arrive through touch regions, so nothing
    in a panel could call one anyway.

  • console.log never reached render_report(debug=1). The console hook dropped every level
    that wasn't error or warning, 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_for fell 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 read profile.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, keyed level0..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 carried gray, 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 than soft-mid-gray4 for 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 * 85 for
    4-level, i * 17 for 16-level) and took no palette at all, while the colour packer has had
    palette_override and 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 gray ramp: #rrggbb anchors, 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) and soft-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_for mapped only the colour gamuts to a
    profile family and returned empty for everything else, so a gray_4 / gray_16 device 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_sticky declared native_w/native_h as 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. Now 480x800, matching the composition, and the renderer's
    orientation_mismatch branch 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_kinds now 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_sticky and seeed_sticky_gray retired. 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 provisional portrait_flipped the board does not use.
    seeed_reterminal_sticky supersedes 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 a device_kind, rejected on
    kind_mismatch, and auto_update_ids sweeps 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_kinds moves 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 retired portrait_flipped would 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 up gray_4, since the successor packs 2-bpp and has no 1-bpp variant.

    Done as a migration rather than deprecated_aliases deliberately. 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_flipped on an instance would read as a deliberate operator override and survive.

[0.343.0], 2026-08-23

Added

  • seeed_reterminal_sticky device 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-bit 0b00 black to 0b11 white. The renderer and packer are shared
    unchanged.

    orientation is portrait, not the portrait_flipped the 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_sticky and seeed_sticky_gray are
    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 from xteink_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 of xteink_x4_gray in 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 for seeed_sticky_gray.

  • touch documented in schema/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_instance copies 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 rewrote kind and
    dropped a stale renderer_id but never touched panel.

    Mostly invisible between siblings that share a panel, and wrong in two ways that are not. A
    Sticky healing from seeed_sticky_gray to seeed_reterminal_sticky kept portrait_flipped and
    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_client healing to
    seeed_reterminal_e1004 rendered 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
    stale renderer_id drop it sits beside.

[0.342.0], 2026-08-23

Added

  • crosspoint_gray device 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 the gray_4 gamut. 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.bmp honours ?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: /frame hands 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 the event alias older firmware sends.

    frames.json entries carry button=refresh for the same reason; without it the on-device
    picker links to a frame that may be hours old.

  • frame.bmp answers a Range: bytes=0-0 probe with a 206 and 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. HEAD is 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 its code and then discarded the human-readable
    message. The message now reaches the error, truncated, so an operator sees
    -> 400: install_pubkey required rather 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 through RENDER_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 calling shutil.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 /status and 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. /status computes 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 reported syncing, 0 of 0 for ever while the event log
    filled with album_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. /status no longer
    builds a manifest to get there, so the heartbeat path stops walking frames and stat-ing
    artifacts entirely.

    fit is 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 describes capacity_bytes only 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
    error as well as load, 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 in render_report?debug=1 alongside the existing settle
    timings.

    URLs are logged without their query string. A Home Assistant entity_picture carries 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. :edge tracks main and every build publishes an immutable sha-<commit>
    tag; :latest appears 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 start on any machine with Node 18 or newer.

    The same src/index.js runs 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 answers GET /healthz. And where the
    Worker deployment expires abandoned pairing records with an R2 lifecycle rule, the container
    sweeps code/ and pair/ in-process every hour against the expires_at the records already
    carry. Frame blobs self-clean on upload in both.

    packages/relay also gains a test script; the suite runs on plain node --test with 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
    /register handshake, 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>.png follows 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_ahead wide, 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_ahead above 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
    /stream faster 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 was refresh_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.bmp serves 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 2xx without a complete body. Such clients stream to a temporary file
    and rename it over the live sleep screen on any 2xx, so a 204, or a 200 with an empty body,
    would replace a working screen with a zero-byte file. "No frame yet" is a 404 rather than the
    204 the envelope route uses, a failed conversion is a 5xx, 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.json lists 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 advertises can_stay_awake are now offered a Stay awake switch and an
    awake poll interval (5-300s, default 15s) on their device card. With it on, next_poll_s is
    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 declared refresh_floor_s is 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.js without 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 to 127.0.0.1 itself, 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=tesserae in 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-running scripts/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's max_frames or 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.py was left unformatted in 0.326.1, failing
    ruff format --check in 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-published sleep_until / next_sleep_s is 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 marked cache: 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.json next 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 button form= 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_url and
    book_caldav could both be set, and neither the model nor the UI said which won. They are
    replaced by booking_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 .ics URL 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_status cell 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. New room_book:<id> action, side-effecting like the webhook
    actions, so widget markup cannot aim it.

    One PUT of one VEVENT, reusing the collection URL, credentials and HTTP opener calendar_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 why POST /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 answers 409, 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. A 403 was 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. 401 keeps 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_s was 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's fetch() may now return next_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 /frame returns 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 any requires: 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 than network:*, 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: same esp32_bw_client protocol, same gray_4 gamut, same 480x800
    declared panel, same esp32_gray2_bin renderer, 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_gray now declares auto_select: false, the mechanism already used for the E1001's
    two grayscale glass variants, so an ambiguous report keeps resolving to xteink_x4_gray and 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_refresh was missing from the provenance gate. SIDE_EFFECTING_ACTIONS still
    listed only webhook and ha, so a webhook_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_refresh was absent from the touch editor's action dropdown. The tap/swipe picker
    in static/touch_interaction.js carries its own hardcoded list rather than reading the action
    registry, so the action could be set through a device's button_map or the API but not chosen
    in the UI.
  • webhook_refresh fell through to nav feedback classification, so a tap on one showed
    navigation affordance instead of the webhook one. Now classified alongside webhook.
  • Added to the MCP action vocabulary (/api/mcp/actions/describe), which previously documented
    webhook alone.

[0.321.0], 2026-08-21

Added

  • webhook_refresh:<url> button action (#242). Fires the same POST as webhook:, 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. Plain webhook: 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
    via button_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_s was 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_s now means "new content is probably available then" (#241). It previously
    only ever echoed the device's configured sleep_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. estimated projections are
    ignored. Purely server-side: clients already honouring next_poll_s get the benefit with no
    firmware change, and clients ignoring it are unaffected.

Fixed

  • CircuitPython renderers marked as partially tested. circuitpython_bmp is 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_imageload is 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_c3 on the esp32_bw_client protocol with the esp32_bw_bin renderer,
    unchanged: the frame is the same 48000-byte 1-bpp mono buffer the xiao_epaper_75 kit 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.

Don't miss a new tesserae release

NewReleases is sending notifications on new releases.