Skip to content

v0.259.0

Latest

Choose a tag to compare

@github-actions github-actions released this 04 Aug 09:43

Changed

  • Decks are now Lineups (#167). The page is renamed and regrouped
    per display: each display with something lined up gets its own section
    with a live-status header, the device's configured icon, and device
    chips on every row; displays with nothing lined up don't appear. Kind
    labels are now Rotation and Schedule, enable and delete sit behind an
    overflow menu, and the standalone new-deck, new-timed-send and
    new-timer-cycle buttons are gone: the setup wizard, with deck
    suggestions folded in as its intro screen, is the sole entry point.
    The stored records, MCP tools, and REST envelope are unchanged.

Changed

  • The display setup wizard is a three-step flow. Behaviour, then
    details (or pages), then a plain-language review, then a created
    screen. It submits through the existing create endpoints in the
    background so it can stay on the created screen, and the deck path
    hands off to the deck editor with the new id. Step blocks now hide
    properly per behaviour (previously every behaviour showed all of
    step 2's fields).

Added

  • 4-level grayscale on the Xteink X3. A new xteink_x3_gray device
    kind matches CrossInk's default grayscale build: the same panel block
    as xteink_x3 with the renderer overridden to the 2-bpp
    esp32_gray2_bin frame (gray_4 gamut), so an X3 running that build
    resolves and pairs. Marked unverified until a frame renders on real
    hardware.

Changed

  • The Decks page got its designed look. Implemented from a design
    handoff: a dark on-air bar summarising what's live right now, filter
    tabs, and one row per deck whose body shows the actual dashboards as
    thumbnail screen cards (live previews from the composer). Timer cycles
    show their step chain with the current step lit and the next-advance
    time; timed sends carry their own 24-hour rail with fire marks and a
    ticking now line (replacing the separate Next 24 hours panel); by-hand
    decks get manual stepper buttons that move the display back or forward
    a dashboard. A subtle one-shot flash marks a fresh push, with reduced
    motion respected.

Changed

  • One editor for every deck (#167). The separate timer-cycle form is
    gone: cycle cards' Edit, the New timer cycle button (which preselects
    timer advance), and old /rotations?edit= links all open the deck
    editor, which gains a Page-conditions fold (author per-page conditions
    with the usual picker; bad JSON is rejected with a message, and saves
    that omit the fields preserve what's stored) and a smart-sync render
    lead input. The wizard's cycle path creates directly and fine-tuning
    happens in the editor afterwards.

Changed

  • Rotations and schedules are decommissioned as separate concepts
    (#167). A rotation is now stored and edited as a plain timer deck on the
    cycle trigger; a schedule is a one-page timer deck on the interval or
    daily trigger. The Decks page sections are relabeled Timer cycles and
    Timed sends and show every timer deck regardless of how it was created;
    deck cards show the navigable (manual and both) decks. Everything keeps
    working: the old forms, MCP tools, and REST envelope operate on the same
    records as views, bound cycles advance panel-by-panel with pre-warmed
    frames and manual-hold respect, unbound cycles keep page-binding
    fall-through delivery, and bound timed sends target their own panels.

Added

  • The wizard now creates, not just prefills. Its final step shows a
    plain-language review and a Create button that submits through the
    normal endpoints, then lands on the deck list with the new card
    highlighted; Advanced options still opens the full prefilled form. The
    cycle path also gained per-dashboard display times: one minutes input
    per picked dashboard instead of a single shared value (the full cycle
    form always had per-step dwell; the wizard now matches).

  • One card for every deck. The Decks page now renders every shape (by
    hand, timer cycle, timed send) as the same card: a teal status pane with
    a live progress bar (through the current dwell, until the next fire, or
    toward home return), the kind as a chip, a one-sentence summary, and a
    uniform action row (Send now, Play step, Edit, Enable/Disable, Delete).
    Filter chips narrow the list by kind; the three separate listings are
    gone. Editing expands the matching form inline via the card's Edit.

  • A "Help me choose" wizard on the Decks page. A guided stepper asks
    one question per screen (what the display should do, which dashboard or
    dashboards, the time or cadence, and a name), teaches what each answer
    creates, and lands on the matching form with everything filled in so the
    last step is just pressing Create. It composes the existing forms;
    nothing new to submit through.

  • The Decks page reads as one list. The Timed sends and Timer cycles
    headings are gone: timer decks flow directly below the navigable deck
    cards, with a single actions row up top (Help me choose, New deck, New
    timed send, New timer cycle). Copy now leads with button press, the most
    common navigation input, ahead of tap and swipe.

  • Companion API: devices now carry their icon (#184). GET /api/app/v1/devices returns each device's resolved Phosphor slug (the
    same identity the device pickers and Settings cards use), so native
    clients can render Display cards with the configured device icon. The
    field is always present; kind defaults apply until the user overrides
    per instance.

  • Physical buttons now work on timer decks (#167). rotate_next,
    rotate_prev, and step:<n> act on the device's bound timed deck when
    no rotation targets it, with the same manual-hold behaviour rotations
    have; the hold lasts until the deck's next dwell boundary. A both-mode
    deck now responds to rotate presses that don't match a page link
    (previously a silent no-op), and the /frame rotation envelope reports
    the deck's position so firmware stays informed. When several timed decks
    are bound, the highest advance_priority wins.

  • Timer decks can now fire on schedule-style triggers (#167). Besides the
    classic anchor cycle, a deck's timer advance supports an interval trigger
    (cooldown floor since the last fire, optional wrap-around time-of-day
    window) and a daily trigger (once per local day at a set time, with the
    backfill guard), plus a whole-deck fallback page for when every page's
    conditions fail. A deck with no bound devices fires to the page's own
    devices. These are the shapes the upcoming schedule/rotation migration
    maps onto; existing decks are unchanged (cycle is the default).

Changed

  • One Decks page for everything a display shows over time (#167). The
    Schedules and Rotations pages fold into the Decks page as sections, with
    their forms, timeline, status pills, and every action intact; the old
    URLs and in-app links redirect to the right section, deep edit links
    included. The top nav has a single Decks entry. The MCP
    rotation/schedule tools keep working as compatibility adapters and are
    marked deprecated in the agent docs; create_deck now documents the
    timer and schedule-style triggers as the preferred path.

  • Schedules and rotations now live in the deck store (#167). On first
    start after updating, rotations.json and schedules.json records are
    migrated into decks.json as tagged timer decks and the source files are
    renamed to *.json.migrated (kept as rollback artifacts). Nothing
    user-visible changes: the Rotations and Schedules pages, MCP tools,
    physical-button behaviour, manual holds, and firing semantics all work
    exactly as before through compatibility projections, and migrated records
    do not appear on the Decks page. Rotations that repeat a page across steps
    migrate too (linkless decks may now repeat pages). An id shared between a
    legacy record and an existing deck gets a -rotation / -schedule
    suffix, noted in the log. After the move, the Schedules and Rotations
    pages show a one-time dismissible notice explaining what changed and
    where the backups live.

  • Priority now arbitrates across schedules, rotations, and timer decks on
    the same tick
    (#167). The scheduler collects all three into one fire
    pass sorted by priority, so a higher-priority rotation or deck advance
    beats a lower-priority schedule for the panel. Previously the three fired
    in fixed passes and a schedule always won the tick regardless of priority.
    Records with equal priority keep the existing landing order (rotation,
    then deck, then schedule), so default-priority setups behave as before.

Fixed

  • Deleted canvas-born dashboards no longer resurrect after a restart. The
    legacy standalone-canvas migration runs on every startup and re-created any
    canvas whose page id was free, so deleting such a dashboard only lasted until
    the next update. Deleting a page now also drops the same-id legacy canvas
    doc; canvases that were never deleted keep migrating exactly as before.

Added

  • The Companion API contract now supports selected Apple Reminders lists as
    one domain source.
    Servers advertise accepted schemas through
    personal_data.sources; the new strict reminders snapshot carries up to
    20 named list groups and 200 incomplete items in aggregate without exposing
    EventKit calendar identifiers. The deprecated reminders.fridge source and
    its legacy feature flag remain available only for the already published
    fridge widget; new Companion and widget integrations use reminders only.
    The server rejects duplicate list IDs and aggregate item overflow instead of
    truncating them, while an empty list set remains a fresh enabled snapshot;
    deleting the source is reserved for disabling the integration.

  • Physical buttons now work on relay-paired panels (#180). A button press
    rides the status JSON the panel already posts to its relay mailbox
    (button + button_event_id, the same fields as the REST status body); the
    home instance dispatches it through the normal button pipeline when it pulls
    the status and uploads the resulting frame for the panel's awake-window
    re-poll. After a press the home poller drops to a fast interval for a burst
    so follow-up presses (deck navigation) aren't collapsed by the relay's
    latest-only status slot, and presses older than five minutes are ingested as
    telemetry but not dispatched. No relay Worker changes; requires firmware to
    include the two fields on button wakes.

  • Offline-album playback state on the Devices card. The server now ingests
    the collection playback report a storage-capable display sends on its
    heartbeat (state, cached/total frames, synced version) and shows it on the
    device's Status tab while that album is bound, with state/version transitions
    recorded in the events log. Shown as an observation with its age, never as a
    claimed current frame.

Fixed

  • Expired personal-data snapshots no longer retain raw values. The store
    removes list names and Reminder contents at expires_at while preserving a
    metadata-only tombstone for the expired status. Reminder due dates are now
    also validated as real YYYY-MM-DD calendar dates for both the generic and
    deprecated source schemas.

  • Revoking a remote panel now actually cuts it off. The relay Worker's
    revoke deleted the panel's mailbox but left its token record, so a revoked
    panel kept authenticating and saw an empty-mailbox 204 it couldn't tell
    apart from "freshly paired" — the contract's revoked-token 401 never
    happened. Revoke now deletes the token record (plus the pairing records
    holding the plaintext token), so the panel's next poll is a real 401 and
    firmware can drop its pairing unaided. Completing a pairing for a device
    that already has a token also invalidates the old token, so a re-paired
    panel leaves exactly one working credential.

  • Deleting a relay panel from Settings → Devices now revokes its relay
    pairing too.
    Previously only the Cloud relay page's revoke button talked
    to the relay; the plain device delete left the mailbox and token lingering.

  • Large offline albums no longer overflow constrained firmware receive
    buffers.
    The /collection manifest is now paged (at most 64 frame entries
    per response, ?cursor= continues) instead of listing every folder frame in
    one document; every cache-eligible frame still lands on page one, so
    single-page slice-1 firmware is unaffected.

  • Companion webpage sends no longer time out on asset-heavy sites because
    of repeated DNS safety checks.
    The strict public-only Chromium guard now
    classifies each hostname once per page attempt while continuing to validate
    every distinct redirect and subresource host and to fail closed on lookup
    errors.

  • Relay deliveries now show up in the events log. Each sealed frame or
    config upload to a relay mailbox records a device event row
    (relay://<id>/frame, relay://<id>/config), and upload failures record
    error rows, so the relay hop of a push is as auditable as an MQTT or REST
    publish. Uploads are deduplicated by digest, so steady pushes don't churn
    the log.

  • Relay pairing now honours the panel's self-reported colour gamut when
    picking the device kind.
    A remote panel reporting a non-default gamut
    (a grayscale or BWR firmware build) is created from the most specific
    hardware-catalog kind for its protocol + gamut, so frames pack at the
    right bit depth: an 800x480 4-gray panel now receives 96000-byte 2-bpp
    frames instead of the mono kind's 48000-byte 1-bpp frames, which its
    firmware rightly refused to paint. The operator's explicit kind choice
    still wins, and gamut-less reports behave exactly as before.

  • RSS feeds and webpage screenshots behind bot protection no longer 403
    (#178). The RSS widget now falls back to fetching through the headless
    browser's network stack (a genuine browser TLS fingerprint, same approach
    the Reddit widget already used) when a plain fetch is refused, and sends
    browser-shaped headers on the plain path. Webpage screenshots no longer
    advertise the HeadlessChrome user agent on external sites, which
    Akamai/Cloudflare-class protection blocks on sight.

Added

  • Pairing codes for remote panels can now live longer than 10 minutes.
    "Add a remote panel" gains a "Code valid for" choice (10 minutes to 24
    hours, relay-clamped) for when someone has to travel to the remote
    location before entering the code. Relay Worker redeploy required.

  • Remote relay panels are now configured exactly like local displays. A
    remote panel's card on Settings → Devices carries the full set of controls
    (sleep interval, button wake, quiet hours, orientation, panel dims,
    calibration), and config edits now actually reach the panel: the home
    instance seals the device's config document with the pairing key and uploads
    it to a new relay config mailbox, which the panel fetches on its next wake
    (conditional GET; the status response now carries the current config etag).
    The relay tab links each remote panel to its device card, the card's badge
    reads "Relay" with relay-appropriate connection details, and the MQTT/REST
    transport switch is disabled for relay panels since flipping one would
    orphan it. Relay Worker redeploy required for the config mailbox routes.

  • Photos sent from the Companion app can now be framed. The Companion API
    accepts an optional focus + zoom on image uploads (contract 0.6
    image_framing, Fill only) and resolves it into a separate source crop for
    each target panel, so one send to a portrait and a landscape display keeps
    the chosen subject on both. Focus coordinates address the photo the way the
    phone displays it (EXIF orientation is normalized first), History returns
    the original framing intent, and resend republishes the retained framed
    frame exactly. The capability is advertised with a mandatory
    image_framing_max_zoom bound so clients never hard-code the editor range.

  • Run a panel at another location, without a VPN or opening your network. A new
    cloud-relay transport lets a remote e-ink panel show your home instance's dashboards
    over the internet. Both ends connect outbound to a small relay mailbox (a Cloudflare
    Worker, hosted or self-hosted), so your home network never accepts an inbound
    connection. Frames are sealed end-to-end (X25519 + AES-256-GCM), so the relay stores
    ciphertext only and can never read a dashboard. Pairing is done remotely through the
    relay, no LAN access to the panel needed. See the remote-panel guide. Panel firmware
    must support decrypting relay frames.

  • Installing a template now asks its questions with real controls, not blank text boxes.
    A template's declared inputs are resolved against the installing server's own widget option
    schemas, so a question about Home Assistant sensors renders as a picker over your entities,
    a location question gets the location search, and selects get their real options. It reuses
    the same auto_field controls and coercion as widget configuration, so the two stay in sync.
    The author's declared type is only a fallback, which is the correct inversion: they can't
    know what is valid on your system. Secret inputs stay masked text (an API key has no picker),
    and inputs targeting a raw URL source's transport fields fall back to plain text.

    Each question is also labelled by the element it configures, so a dashboard with three
    sensor tiles asks "Kitchen: Entities" and "Bedroom: Entities" rather than "Entities" three
    times; an untitled element falls back to its position ("bottom left of the dashboard").
    Questions are grouped by the value they replaced, so one API key shared by two sources is
    asked for once and fans out to both, while three tiles watching three different sensors
    stay three separate questions.

  • Phone photos no longer land on the panel sideways. fit_to_panel now normalizes EXIF
    orientation before cropping and fitting. Cameras commonly store a landscape pixel buffer
    plus an orientation tag instead of rotating the pixels, and Pillow does not apply that tag
    on open, so a portrait phone photo rendered rotated. Doing this before the crop also makes
    normalized crop coordinates mean the same thing on both sides of the wire, which the
    Companion's proposed focus/zoom framing depends on.

  • Templates can be reported for takedown. Every template in the browser gets a Report
    button that files a takedown request into the same review channel, with Take down and
    Dismiss buttons beside it. Anyone can file one, including the install that published the
    template, which is how an author pulls their own work back; a self-report is flagged in the
    review message (advisory, since install ids are forgeable). Taking a template down from
    either message closes any open reports against it, and requests are rate-limited per
    install and per IP.

  • The Share dialog now warns that the preview image is a live render. The screenshot
    submitted with a template shows whatever the dashboard was displaying at that moment, and
    it reaches reviewers immediately and the public catalog on approval. The dialog says so
    beside the image, suggests duplicating the dashboard with placeholder values if it shows
    anything private, and confirms once more at submit.

  • Large dashboards can be shared again: template previews are downsampled to fit. A
    1600x1200 render with a photo background exceeded the submission size cap and was rejected
    outright. Previews are now resampled to at most 1200px on the long edge (with a palette
    fallback) before submitting, which keeps them readable in the Discord review embed rather
    than shrinking them to card size, and the server cap is 1MB to match. A render already
    inside the budget is sent untouched at full resolution: flat dashboard art compresses
    better at native size, so resampling it would have made the file bigger and the reviewer's
    view worse.

  • Community templates get their own page, grouped by resolution. Browse now links to a
    dedicated Templates page where templates are grouped by canvas size, each group labelled
    with the devices that fit those dimensions (portrait mounts matched as "rotated") and the
    resolutions of your own registered panels pinned to the top. Fixes the Install button
    landing on a 404: the editor URL now comes from the server rather than a hardcoded client
    path (the share dialog's preview URL had the same stale path).

  • Settings → System gains an Experiments card. Every experiment flag (canvas editor,
    MCP API, template marketplace) is now a labelled toggle with its description and resolved
    state; flips take effect immediately with no restart. Flags pinned by a
    TESSERAE_EXPERIMENT_* env var render locked with the reason. Also fixes the MCP card's
    Disable button, which posted a value that parsed truthy and silently re-enabled the API
    instead of disabling it.

  • Template marketplace (experimental): share and install community dashboard templates.
    Behind the new templates experiment flag and the master online switch. Sharing (a new
    action in the panels editor) exports a canvas dashboard through a sanitizer that strips
    request headers and secret-flagged options (a new secret flag on cell_options;
    rest_service url/headers carry it), clears install-specific values (HA entities,
    locations) into declared install-time inputs, inlines small page-asset backgrounds, and
    runs a credential lint that blocks anything key-shaped; submissions go to api.tesserae.ink
    and are human-reviewed before appearing publicly. Browse gains a Community Templates
    section: cards show a stable pseudonymous author (sponsors get an emblem and a custom
    name), installing fills the template's declared inputs (secrets entered masked, staying
    local) and creates a new unbound dashboard. Removing a template from the catalog never
    touches dashboards already created from it. The privacy page documents exactly what a
    submission contains.

  • Panel view: preview a dashboard as the e-ink panel will actually paint it. The
    dashboard editor's live preview gains an HTML view / Panel view toggle. Panel view
    quantises and dithers the render to the target panel's colour palette, so you see the
    exact per-pixel output (dithering, palette reduction) before pushing, per preview group's
    gamut. Also adds a shared source-crop primitive (a normalized crop + rotate applied before
    the panel fit) that the Send and Companion image paths will build framing on.

  • Companion dashboard listings now include their Phosphor icon name. The
    optional icon field uses the same bare identifier as the web dashboard
    list, so companion clients can render a consistent icon and safely fall back
    when a dashboard has no icon.

  • Icon references that resolve to no glyph are named instead of rendering a blank box.
    render_report now always includes icon_invalid (mirroring tap_invalid): unknown slugs or
    weights on icon elements, bad icon-transform bind-table values, and a heuristic scan of
    code/html/svg markup for ph-<name> classes that aren't real Phosphor icons, each with the
    element id and reason. The GET /api/mcp/icons search normalises its query to slug form
    (ph- prefix stripped, underscores as dashes), so ph-heart and calendar_heart now match
    instead of returning zero results. The canvas renderer falls back to bold for an unknown icon
    weight rather than building a class that matches no stylesheet. Agent docs (server and bridge)
    spell out the two markup traps: regular weight needs both classes (ph ph-heart), and a wrong
    slug fails silently.

  • Render diagnostics: render_report gains debug=1, so silent render failures name
    themselves.
    The diagnostics section reports, per render: console errors/warnings from every
    frame (a throwing code-element script surfaces tagged [code-el <id>], including uncaught
    async errors and CSP-blocked loads inside the sandbox), uncaught page errors, failed and
    4xx/5xx network requests with URLs, per-font-face load status
    (loaded | pending-at-capture | failed | never-requested, with the @font-face src), authored
    element CSS the browser silently dropped (selector + declaration + reason, via a re-parse
    diff), which vendored bundles each code element inlined, and the settle record that gated the
    screenshot (goto / compose-signal / image-wait / font-wait outcome + elapsed ms). One
    render_report(debug=True) call now names problems that previously took pixel-diffing dozens
    of renders. render_preview and render_report also accept fresh=1 to bypass the last-good
    fallback and widget data caches (?fresh=1 now works on /compose/<id> itself), so a stale
    cached result can't derail an investigation. A live-Chromium regression suite pins the
    acceptance case (throwing script + 404 font + dropped CSS rule, one call names all three) and
    that identical page content yields identical font/asset outcomes across runs.

  • The MCP surfaces the icon set and code-element toolkit to agents. list_widgets() now
    returns the vendored code-element libraries (Chart.js incl. the datalabels and Sankey plugins,
    canvas-gauges, Day.js, qrcode, marked, chroma, SVG.js, Phosphor) and an icon descriptor, and a
    new GET /api/mcp/icons?q= searches the 1500+ Phosphor names so an agent picks real slugs
    instead of guessing. The tesserae-mcp bridge exposes the search as a list_icons tool.
    Previously these were only described in the prose instructions, so agent-built dashboards
    under-used icons, charts, and the toolkit.

  • Companion API 0.5: send a public image URL or a webpage to your displays. Community
    clients can push a public image URL, or a server-rendered screenshot of a public webpage,
    to explicit displays as asynchronous jobs, with the same History, resend, and photo
    layout-mode handling as an uploaded image. Both run a strict public-only URL policy with no
    client override: private, loopback, link-local, reserved, and embedded-credential
    destinations are refused, including redirect hops (re-validated during the fetch, and by a
    per-request interceptor during the webpage render). Webpage sends need a browser pool and are
    advertised only when one is present.

  • Companion clients can inspect the exact frame waiting for a sleeping
    display.
    A REST device with has_pending_render now includes an optional
    pending_render revision and authenticated preview URL. The existing device
    preview endpoint accepts that revision while preserving its default
    last-served meaning, so clients can present Current and Next without guessing
    from global History or racing a newer render.

  • Companion display cards distinguish served and pending frames. REST
    /frame polls now retain the last frame handed to each device, including
    matching 304 Not Modified confirmations. The Companion device preview uses
    that served frame while has_pending_render reports when a newer render is
    waiting for the panel's next wake; MQTT and push transports continue to use
    the latest server render.

  • Companion API 0.4 adds canonical History and all five photo layout modes.
    Community clients can page through the same push History shown in the web UI,
    fetch retained composition previews, and idempotently resend an entry to its
    original display snapshot while respecting quiet hours. Successful jobs may
    include the exact new History event IDs, and image uploads now advertise and
    accept Fit, Fill, Blur, Stretch, and Center.

  • The opt-in daily heartbeat now includes a bucketed count of paired companion apps. Same
    0/1/2-3/… bucketing as the device count, derived server-side from the live companion
    tokens (never a client name, install id, or app version), so adoption of the community
    companion app is visible without the app itself ever contacting api.tesserae.ink. Sent only
    when online features are enabled, like the rest of the heartbeat; see the privacy page.

  • REST devices work on publicly-hosted instances (opt-in). A new Settings → Server → Network
    toggle, "Allow REST clients on public networks" (off by default), lets a device fetch its
    rendered frame over a public address using a signed, short-lived URL the server hands it after
    it authenticates. Off, render artifacts stay reachable only from your LAN or an authed session;
    on, the operator accepts that a device's signed frame URL is fetchable from the internet until
    it expires. Unsigned public access is always refused, and no firmware change is needed.

Fixed

  • Panel view toggle now actually switches. Selecting Panel view appeared to do
    nothing: the preview iframe's display: block overrode the hidden attribute the
    toggle set, so the live render stayed on top of the quantised image, which itself
    rendered at full panel resolution outside the frame. Both are fixed (explicit
    [hidden] override on the iframe; the panel image reuses the raster preview's
    letterboxed object-fit: contain box). The HTML-view button is also renamed from
    "Fit view", which read as a sizing control.

  • Saving a REST device no longer emits a misleading MQTT publish failure.
    REST instances can retain dormant MQTT topics for a later transport switch;
    device settings now follow the active transport, save to disk, and return on
    the next status poll without attempting a broker publish.

  • CalDAV discovery handles compressed responses. A Nextcloud behind a proxy or CDN
    can return a gzip- or deflate-compressed body; Python's HTTP client doesn't request or
    decompress that, so the parser saw a binary blob and discovery failed with "wasn't valid
    CalDAV XML." Discovery and the feed fetch now request an uncompressed body and decode
    gzip/deflate if the server compresses regardless, and the diagnostic log includes the
    response's content type and encoding.

  • CalDAV discovery tolerates a stray BOM or whitespace before the XML declaration.
    Some servers (notably Nextcloud behind certain PHP / output-buffering setups) emit a
    newline ahead of <?xml, which a browser ignores but a strict parser rejects, so an
    otherwise-valid calendar listing failed with "wasn't valid CalDAV XML." Discovery now
    trims anything before the first tag, and logs the raw response start when a body still
    won't parse.

  • The Schedules "Next 24 hours" timeline and last-fired times respect the configured
    timezone.
    They were computed from the server's clock (UTC on a typical Docker install),
    so the "Now" marker, hour ticks, and projected fire times sat an offset away from the
    operator's wall clock and reloading didn't help. They now use the configured app timezone.

  • Grayscale and Spectra 6 / ACeP panels report their real palette to companion and MCP
    clients.
    The colour-capability lookup only matched the canonical gamut ids, so a panel
    declared with a chemistry alias (spectra_6, acep_7colour) fell through and was reported
    as monochrome; it now resolves aliases and returns the full 6/7-colour palette. Three
    grayscale kinds that already render multiple grey levels but were tagged mono are corrected
    (reTerminal E1001 grayscale and Xteink X4 grayscale to 4-level; reTerminal E1003 to a new
    16-level gray_16 gamut), and editing a grayscale panel's settings no longer rewrites it to a
    colour gamut.

  • The refresh button now works on a plain bound dashboard. Pressing refresh on a
    display that isn't driven by a rotation or deck re-renders whatever dashboard it is
    currently showing and sends it, instead of doing nothing. A device that has never
    displayed a dashboard still no-ops, since there is nothing to refresh.

  • Layout preset thumbnails no longer distort on extreme panels. The little
    split-pattern hints in the dashboard editor now use a fixed aspect ratio
    instead of the panel's, so a very wide or small display (e.g. a 296x128
    Magtag) gets readable, uniform preset tiles. The interactive custom-layout
    board still matches the real panel shape.

  • Companion Activity now keeps exact History and display identity. Multi-panel
    dashboard pushes preserve every canonical History event ID in their terminal
    Job, and button-triggered fetch rows snapshot the originating device ID, so
    clients no longer need title/time heuristics to suppress duplicate Activity
    cards or identify the display that fetched a frame.

  • Companion display previews now match each display's selected photo layout.
    The server retains a separate logical-screen PNG after Fit, Fill, Blur,
    Stretch, or Center and device underscan are applied, so portrait displays no
    longer show the unfitted source composition in the iOS app. Hardware-only
    row-stride rotation and mount compensation remain confined to the device
    artifact, while preview ETags, upgrade backfill, and artifact pruning track
    the logical frame independently.

  • The Webpage widget captures JavaScript pages after their data loads. It was
    screenshotting the moment the embedded page fired its load event, before a data-driven
    page (a weather dashboard, an SPA) ran its post-load fetch and painted, so the panel showed a
    blank frame even though the editor preview looked right. The widget now waits a short settle
    window after load, with a "Settle delay (seconds)" cell option (default 2) you can raise for
    slow pages.

  • Photo sends to portrait-native ESP32 panels keep their visual orientation.
    Send-page and Companion uploads are now fitted into the display's composition
    dimensions before the renderer reconciles that composition with the
    firmware-native row stride. Landscape photos sent to devices such as the
    reTerminal E1004 no longer rotate 90 degrees clockwise while History remains
    upright.

  • Battery analytics now separate charging from drain phases. The Device batteries page
    shows live Charging / Charge rate / Full in values without labelling a positive charge
    ramp as Drain rate, and retains the preceding clean discharge slope as Last drain rate
    when available. Robust Theil-Sen fits prevent a single glitched reading or post-unplug voltage
    relaxation drop from dominating either estimate, while raw chart samples remain unchanged.

  • v2 staleness is anchored to layout, not pixels (live bench round 3, 2026-07-25). A region
    report against a superseded frame digest now dispatches when that frame's untrimmed region-id
    set matches the live one, resolved through a new ~10-generation digest lineage per device; a
    dashboard whose pixels re-render every 30 s no longer drops every tap that races a render.
    stale means the layout genuinely changed or the digest is too old to resolve.

  • Extraction races can no longer kill touch. A capture whose code-element mirrors hadn't
    posted by screenshot time extracts zero regions; that empty result no longer overwrites a
    populated sidecar for an unchanged composition, and an empty manifest rebuild for a page whose
    cached manifest had regions serves the cached manifest re-anchored (with a warning) instead of
    a structurally-valid 0-region manifest the device would hold until the next good redraw.

  • Anti-aliasing jitter no longer mints frames. The composition diff gained a per-channel
    tolerance (10/255, below the 16-level gray quantization): chart canvases and browser text
    jitter between captures of visually identical content inflated the changed area past the patch
    budget, forcing a full-frame mint and a full e-ink flash on every re-render. Sub-tolerance
    re-renders now hold the digest and stage nothing; real changes ride patches as designed; a
    divert that still fails logs its reason at warning level.

  • v2 region reports now dispatch (live bench round 2, 2026-07-25). Sidecar action specs from
    code-element markup are raw JSON strings; the manifest builder and the region-id resolver
    classified and dispatched the unparsed string, so every served manifest carried
    action.type: '{"action"' garbage and every /tap region report failed. Specs now pass
    through the same coerce_action normalisation the v1 dispatch path uses, in the one helper
    both the builder and resolver share — HA actions classify as tier 1 / ha and dispatch.
    /tap region reports also speak the firmware's wire vocabulary: ok on success, stale /
    deduped / ha_failed unchanged, and specific diagnostics (no_action_for_region,
    action_error, provenance_blocked, resolver_exception) instead of a mute error.

  • /frame/data answers 200 for every known digest. A frame with no slots and nothing staged
    returns an empty values document instead of 404, so a device can't latch data-off mid-linger
    and miss the patch a tap stages a second later; 404 now means an unknown digest only.

  • Manifest region trim is priority-ordered and audited. Over-budget frames keep navigation,
    then sliders, then taps, then swipes (document order within each class), and the dropped
    region ids are logged by name instead of tail sections silently going dead.

  • Protocol v2 manifest delivery survives re-renders (live bench, 2026-07-25). Every /frame
    200 for a proto-2 device now carries the manifest block: non-interactive frames get a valid
    empty manifest instead of silence (a manifest-less 200 reads as "v1 server" and latched the
    device out of region dispatch), a lost sidecar re-anchors the last built manifest for the same
    composition, and pixel-only re-renders keep the manifest digest stable so the device re-anchors
    without a re-fetch. /frame/manifest and /frame/data also keep answering for a
    just-superseded frame digest for a ~60 s grace window, so a device mid-linger on the old digest
    is not orphaned by a re-render (its artifacts and sidecars are prune-protected for the window).

  • /status value/patch envelopes now gate on the sticky capability. A beat that omits the
    overlay advert, or a pure-v2 firmware that only sends proto, no longer loses
    overlay_values / overlay_patches; patch reconciles and push diverts likewise accept
    proto >= 2 as patch-capable alongside overlay schema 2.

Added

  • Protocol v2 server surfaces (device-owned touch). Devices advertising proto: {v: 2}
    (sticky, persisted) get: interaction manifests (GET /frame/manifest?digest=, plus a pointer
    on /frame responses) with stable region ids, tier/type classification, feedback modes, and
    text regions; region-id action reports on POST /tap validated by re-minting ids from the
    frame's own sidecar; a Server-Sent Events channel (GET /stream) carrying values / patches /
    sync envelopes with keepalives; and state bundles (GET /bundle) projecting the warmed deck
    cache into digest-addressed frame states with a navigation links table. v1 responses stay
    byte-identical for devices that don't advertise the capability. Contract:
    docs/protocol-v2-touch.md.

Removed

  • Schema-1 overlay specs (protocol v2 cleanup, see docs/protocol-v2-touch.md). The
    GET /frame/overlay/<digest> endpoint and the build_spec document builder are gone: v1
    firmware probing the endpoint gets a 404, which its contract has always defined as
    feature-off, so it degrades to dispatch-without-echo until the protocol-v2 interaction
    manifest ships. The atlas store, values document, patch documents, touch dispatch, and the
    overlay capability handshake are unchanged; non-touch rendering is byte-identical.

Added

  • Frame patches now actually stage under real dithering, and periodic small changes ride them
    too.
    The patch diff moved to composition space (before per-renderer dithering): the .bin
    family's default error-diffusion dither made a one-tile change perturb the packed bytes of
    nearly the whole frame, so the wire-space diff always blew the budget and every reconcile fell
    back to a full frame — the schema-2 path never engaged on hardware. Composition rects now map
    through the same transform chain as tap targets and the blob is cut from the new artifact.
    On top of that, scheduled and push-triggered re-renders whose visual diff is small (a header
    clock tick) are delivered as patches on the current digest for schema-2 REST devices showing
    the same page: no full e-ink flash per clock tick, and stable digests mean a tap fired around
    a render can no longer be dropped as stale. Explicit repaint intents (resend, force publish)
    and big diffs still mint a new digest.

  • Overlay capability survives restarts. The advertised overlay schema persists in the
    device-facts store and re-seeds the status cache at startup, so a patch-capable panel isn't
    demoted to full-repaint reconciles between a server restart and its next heartbeat.

Fixed

  • Overlay values seq is now milliseconds. Second-granularity seqs made two value changes
    inside one second dedup to a single repaint under the firmware's newest-wins rule.

Added

  • Post-action frame patches (overlay schema 2). After a touch action fires a Home Assistant
    service call, the server re-renders the page headless, diffs the wire framebuffer against the
    frame on glass, and stages only the changed rects as a patch document (patches on
    GET /frame/data, overlay_patches on /status, blob via GET /frame/patch/<digest>).
    Capable firmware partial-refreshes those rects: state text catches up within a couple of
    seconds, with no full download, no full e-ink flash, and the digitizer live throughout.
    Documents are anchored to the served frame digest with a strictly increasing seq and are
    dropped the moment a newer frame lands, so a patch can never revert a pending push. Caps:
    12 rects / 256 KB per document; past that the server falls back to a normal full frame.

  • Overlay value slots: attribute paths, value maps, code-element support. Slot keys accept
    an attribute path (ha:light.desk:attributes.brightness), a slot can declare
    data-overlay-map='{"on":"1","off":"0"}' to render non-numeric states with the numeric glyph
    atlas, and slots inside code-element sandboxes are now collected through the same mirror
    mechanism as their touch regions.

Changed

  • The post-HA repaint no longer runs inside the touch wake. The synchronous re-push (full
    render + download + flash while touch was locked, roughly one action per 10 s) is replaced by
    a debounced background reconcile that re-renders whatever the device is showing: rotation
    step, deck page, or the last directly-pushed page (previously, devices without a rotation
    never repainted at all and kept 304ing until the next schedule). Patch-capable devices
    reconcile ~0.4 s after the last tap of a burst; everything else gets one coalesced full push
    ~3 s after (app.touch_patch_debounce_s / app.touch_repush_debounce_s).

Added

  • Pages own their update cadence (discussion #140). Every dashboard gains an "Updates"
    setting on the Dashboards list (only when pushed / every minute / 5 min / 15 min / hourly /
    daily). The scheduler re-renders the page on that cadence and delivers only to panels
    currently showing it (resolved from deck position, rotation position including manual holds,
    or a device bound to exactly one page); nobody showing it means no render at all. Freshness is
    now a property of the page's content, not a side effect of rotation dwell: a clock page set to
    "every minute" stays live through a 15-minute rotation step. Delivery respects quiet hours,
    and unchanged renders still answer battery panels' polls with 304. Default is "only when
    pushed" (no behaviour change for existing pages).

Fixed

  • Deck home-return now respects quiet hours on its promote fast path. The timer-driven
    return-to-home could repaint a panel inside its quiet window when a pre-warmed frame was
    available (the push fallback already gated itself); the return now defers until the window
    ends on both paths. Hand navigation and the Push button remain quiet-exempt (user-initiated).

Added

  • Deck editor: pick pages, done ("dense rail + inspector"). Decks get a dedicated
    create/edit surface (Decks -> New deck / Edit): a flip-order rail of page cards with
    thumbnails, click-to-append page library with suggested clusters, an inspector for the
    selected card (home toggle, return-after slider, per-page refresh override, reposition /
    remove), inline settings bar (name, device chips, cadence, Advanced with entry page + a
    live-derived navigation-graph view), and a one-line behavior summary. Navigation derives
    automatically from the flip order; no graph authoring needed. Fully submittable without
    JavaScript (membership checkboxes + numeric order fallback).

  • Deck home card with idle return. Mark a member page as home and set "return here after"
    (slider, 0-120 min): the deck returns to it after that long without a button press or tap,
    enforced server-side for server-navigated panels and shipped in the sync manifest (home
    block) so SD-cache firmware enforces it offline. Push sends the home page to the panel first;
    a fresh device's entry page defaults to home.

  • Deck sync manifests: swipe triggers and capacity awareness. Link tables now carry swipe
    triggers (authored directions mirrored from the graph; paging defaults where silent: swipe
    left = next, swipe right = back), and when a device's advertised SD capacity can't fit the
    whole deck, overflow pages are marked cache: false with ring-from-home priority instead of
    letting the card overfill mid-sync.

Added

  • Rotations re-render at every dwell boundary, including onto themselves (discussion #140).
    Previously a rotation only rendered on step transitions, so a step's widget data froze for
    its whole dwell (a device poll fetches the already-rendered frame; it never triggers a render),
    and a single-step rotation rendered exactly once, ever. A rotation now fires whenever a new
    dwell window begins, even when the step is unchanged, so a one-page rotation with a 5-minute
    dwell simply means "keep this page fresh every 5 minutes". The min-hold flap guard still
    applies to step changes but not to self-fires. Alongside this, rotation fires now exclude
    panels sitting inside a manual button/touch hold (previously any fire could yank a paged-away
    panel back mid-hold; now the rejoin pass restores them only when the hold lapses).
    (Supersedes the refresh_minutes setting that briefly shipped in v0.190.0; rotations saved
    with it still load, the field is ignored and dropped on next save.)

Fixed

  • Devices paged away by button/touch now rejoin their rotation (discussion #140). The
    scheduler only pushed on step transitions, so on a rotation dominated by a long-dwell step a
    panel that was manually paged away (physical button / touch) stayed on the manual page
    indefinitely: the manual hold (override_until) was recorded but nothing acted when it lapsed.
    A per-tick rejoin pass now pushes the rotation's current page, device-targeted, to any device
    whose manual hold has lapsed, then clears the hold; devices still inside their hold are
    untouched, and a device already on the current step just has its hold cleared. Hold length
    remains "rest of the day" by default, or app.button_hold_seconds when set.

Added

  • Decks navigate without a hand-built graph. Where a deck page's graph is silent, sync
    manifests now synthesize default links (left/right to prev/next in deck order, wrapping,
    plus left-half/right-half tap zones on touch panels when the page has no zones or markup touch
    regions of its own), and server-side button handling applies the same prev/next default for
    deck-bound devices, so a graph-less deck navigates identically on-device and via the server
    (previously right fell through to the rotation map and read as "refreshed but didn't
    navigate"). Explicit graph links always win. MCP create_deck with a bare page set now derives
    the graph from the pages' page:<id> tap/swipe links automatically (bridge 0.8.2 documents it).

  • Push button on the Decks page. One click warms every page for every bound device, sends the
    entry page to the panel(s), and seeds the nav position, so a new deck goes live immediately
    instead of waiting for the scheduler's warm tick. Deck cache hygiene is documented in
    client-protocol.md: digests are content-addressed, so only pages with volatile content
    (clocks, "last updated") churn the deck version.

Fixed

  • Completed firmware updates stayed labelled as queued. The Firmware page now distinguishes
    retained canary/fleet rollout membership from a release that is still newer than the device.
    Once a device reports the imported version it reads as up to date, while pending rows name the
    actual imported release instead of a potentially newer online Available version.

  • Deck heartbeat reports could revert a freshly pushed dashboard. v0.187.1's report promotion
    promoted whatever page the panel said was on glass, so a heartbeat arriving between a push and
    the device's next fetch clobbered the pending frame with the older deck frame, and the new
    dashboard (and its touch zones) never landed. Promotions from reports and from touch
    reconciliation now carry a recency guard: what's on glass only wins the live slot when it isn't
    older than what's pending there. A touch on stale glass still dispatches against the frame the
    finger actually touched; it just can't revert the pending push.

  • Buttons and touches were permanently deduped after a device power cycle. The firmware's
    wake-event counter is RTC-backed and restarts at 0 on any power cycle (battery pull, crash,
    reflash), usually without re-pairing since the token survives in NVS; the server's dedup rule
    treated anything <= the persisted high-water mark as a retry, so a restarted counter had every
    subsequent button and touch silently swallowed. Dedup is now equality-only (a genuine retry
    resends the same id; a lower id is a restart or an offline-queue replay and dispatches), and the
    re-pair paths (/register on an existing id, /discover MAC claim) additionally clear the
    dedup state outright.

  • Touches were dropped as stale after deck local navigation. When firmware paints a deck page
    from its SD cache, the panel shows a frame the server never served via /frame, so the touch
    stale-check rejected every subsequent stroke, and a routine conditional poll could even repaint
    the panel backwards to the pre-nav frame. A digest that matches a deck-cached render is now
    reconciled instead of dropped: the frame is promoted into the live slot (ETag polling 304s, nav
    position recorded) and the stroke hit-tests against its composition. deck_page_id reports do
    the same promotion, and report ingestion now runs before button/touch dispatch on /frame and
    /status so same-wake events resolve from the page actually on glass. Digests matching nothing
    are still dropped as stale.

Added

  • Per-device overlay target budgets (firmware v1.9). The overlay capability now carries the
    device's own tap-echo buffer size (overlay: {schema, max_targets}, 32 on current E1003
    firmware); the server trims target lists to the advertised value, treating absence as the v1.8
    baseline of 8. When a frame has more touch regions than the budget, navigation targets
    (page: / step: / rotate_*) win the echo slots ahead of miscellaneous actions, and
    survivors always emit in document order. Spec size is guarded against the firmware's 8 KB parse
    buffer. The MCP device list now reports overlay: {max_targets} so agents can design within
    the real per-panel budget.

  • MCP surfaces the deck-cache and overlay capabilities. /api/mcp/devices entries now carry
    the hardware kind plus firmware capability flags from live heartbeats: overlay: true
    (instant tap echo + live value slots apply on that panel) and deck_cache: {capacity_bytes}
    (radio-off deck navigation). render_report extracts overlay_slots alongside tap_regions
    (also in ?view=touch and ?fields=), so an agent can verify a data-overlay-key annotation
    survived the render. The tesserae-mcp bridge (0.8.1) documents the live-value-slot vocabulary
    and its server-enforced guardrails (8 slots, 2 font buckets, numeric charset, 47-char values,
    ha: keys only, widget-markup-only extraction) in the handshake instructions.

  • Overlay value slots + glyph atlases (hybrid render mode, schema 1 slice 2). Widgets can mark
    an element data-overlay-key="ha:<entity_id>" (optional data-overlay-suffix) and capable touch
    firmware repaints just that slot with live values during a wake, no full re-render. The slot's
    box, alignment, font size, and weight are extracted in the same Playwright pass as touch regions
    (sidecar v3); glyph atlases are rasterized through the same browser + Inter faces as the
    composition so blitted text is pixel-identical, packed 4bpp to the firmware's strip contract, and
    served content-addressed at /frame/overlay/atlas/<digest>. Live values come from
    GET /frame/data?digest= (pre-formatted strings via the ha_core plugin) and piggyback as
    overlay_values on /status responses for capability-advertising devices. Firmware caps
    honoured server-side (8 slots, 2 atlases by largest group, 32 glyphs, 47-char values); every
    failure path degrades the spec to rect-only. Contract updated in docs/dev/client-protocol.md.

  • Overlay specs for touch boards (hybrid render mode, schema 1). Firmware with fast partial
    refresh (reTerminal E1003 first) advertises overlay: {schema: 1} and fetches
    GET /api/v1/device/<id>/frame/overlay/<digest>: a rect-only draw list of tap-echo targets
    derived from the frame's touch-region sidecar, transformed server-side into wire-framebuffer
    pixel space (rotation, flip, scaling, underscan all applied at spec-build time, so the firmware
    uses coordinates verbatim). A tap inside a target inverts and partial-refreshes that rect locally
    in a few hundred milliseconds while the stroke still dispatches to the server as normal. Works
    for both live frames and deck-cached frames; capability is sticky per device. Value slots and
    glyph atlases are the next schema slice. Contract in docs/dev/client-protocol.md ("Overlay
    specs").

  • XIAO ePaper 7.5" black/white/red variant. bwr_3 is now a canonical packer gamut: the
    esp32_bin renderer packs tri-colour panels to the native 2-bpp layout (96000 bytes at 800x480,
    MSB-first, 0b00 black / 0b01 white / 0b10 red; the reserved 0b11 is never emitted), sharing the
    BWRY 2-bpp path. New xiao_epaper_75_bwr hardware SKU on the same esp32_bw_client protocol as
    the mono board, so reflashing a unit between mono and BWR firmware migrates its registration
    automatically (same-protocol kind heal) while keeping the device row, token, and history.

  • Deck cache sync for devices (on-device SD frame cache). Firmware with local storage can now
    cache a bound deck's pre-rendered frames and navigate button/touch links on-device (wake, read
    card, paint; no WiFi round trip). New device-facing surface: a deck_cache capability advertised
    in heartbeat bodies (current-state per beat, withdrawn the moment a card disappears), a
    GET /api/v1/device/<id>/deck manifest (page frame digests, byte sizes, TTLs, and the link graph;
    cold pages are warmed on demand), digest-addressed frame fetch at
    GET /api/v1/device/<id>/deck/frame/<digest> with immutable cache headers, a deck.version
    envelope on /status responses so firmware knows when to re-sync, and deck_page_id reporting on
    /status bodies and /frame query params so locally-navigated pages keep the server's nav
    position truthful. Devices that never advertise the capability see byte-identical responses
    everywhere. Contract documented in docs/dev/client-protocol.md ("Deck cache sync").

  • 2-bit grayscale renderer + reTerminal E1001 grayscale variant. New esp32_gray2_bin renderer
    packs compositions to 4-level grayscale at 2 bpp (96000 bytes for 800x480; MSB-first, 0b00 black,
    0b11 white, linear) for UC8179-class mono panels driven in their 4-gray waveform mode, and a
    seeed_reterminal_e1001_gray hardware SKU pairs it with the grayscale firmware build. Same
    esp32_bw_client protocol as the mono SKU, so the registration variant picker offers both and an
    already-registered E1001 migrates automatically when its firmware re-declares the gray kind.

  • Stale device kind auto-heals on re-pair (#121). A device that first registered under a generic
    protocol kind (e.g. esp32_client) and later comes back declaring its hardware-catalog SKU
    (e.g. seeed_reterminal_e1004) is now moved to the declared kind instead of staying pinned to the
    one it first paired as. Applies on /register re-pair and on the /discover MAC-claim path a
    re-flashed device actually hits; restricted to kinds sharing the same wire protocol, so a heal can
    only refine which board, never move a device across protocols. Fixes devices being silently exempt
    from per-kind OTA rollouts because releases are keyed by the SKU kind while the instance sat under
    the generic one. The device's cached render is invalidated on a move so /frame serves 204 until
    the next push repaints at the new kind's geometry.

  • OTA rollout UI (Settings → Firmware) (#121). A guard-railed admin page over the same per-kind
    rollout state the CLI writes (never a parallel store). Per device kind it shows the current release
    and its verified manifest (fw_version, key_id, sha256, size, image host), and the rollout controls:
    import + verify a descriptor-<kind>.json (rejected with the verifier's reason if the signature or
    key doesn't check out, or the kind is unknown), set a canary from the kind's OTA-capable devices,
    promote to the whole kind (disabled until a canary reports confirmed on that version, with a
    confirm dialog stating how many devices will be offered it), and pause/withdraw. A fleet view lists
    each device's firmware version and OTA phase chip, floating rolled_back / failed to the top;
    devices that never advertised OTA support show "USB update only". Every action is event-logged. The
    page never fetches the image bytes; it shows the URL and the devices fetch it themselves.
    When online mode is on, each kind also shows whether a newer firmware has been published
    (api.tesserae.ink's per-kind update check, cached hourly) with an "available" badge, and a
    one-click "Import from release" that fetches the release's signed descriptor (host-allowlisted),
    verifies it against your trust anchor, and sets it as the release, no manual download. The check
    now sends the reported firmware version (?current=) so api.tesserae.ink can aggregate version
    distribution. When online mode is off the check never runs and the page discloses that turning it
    on pings api.tesserae.ink with the kind ids and reported firmware versions; rollout itself stays
    fully offline.

  • OTA state reporting is ingested server-side (#121). A device that speaks OTA reports where it is
    in the update lifecycle on the ota object of its heartbeat (phase / reason / target_fw /
    attempt_id / detail, per the contract's State reporting section). The server now records the
    latest report on the device's live status, shows it as a chip on the Devices card (green
    confirmed, red failed / rolled_back, amber rejected, neutral in-progress), and appends an
    event-log row on each lifecycle transition (a terminal report re-sent every heartbeat logs once). A
    capability-only or idle beat leaves the last outcome standing. The report is advisory; the
    device's own first-boot checks remain the acceptance gate.

Fixed

  • OTA release path crashed on the Docker image, and shipped no trusted keys (#121). Two packaging
    gaps blocked the first canary. app/ota/release.py imported packaging.version at module scope,
    but packaging was never declared and is absent from the slim image, so the release CLI and the
    /status release-delivery path (which imports is_newer) both raised ModuleNotFoundError. The
    version comparison now uses a small internal plain-SemVer helper (app/semver.py) with no
    third-party dependency. Separately, the Dockerfile did not copy ota/, so load_trusted_keys()
    found an empty registry at its default ota/keys dir; the image now includes it.

  • Resend from History never reached devices served by a per-device renderer clone (#119). A
    resend replayed the stored composition as an unbound fan-out, and unbound pushes deliberately skip
    clone renderers (<base>__<device>, the #83 guard), so for a bound device the resend published
    nothing: the device's latest-frame entry kept pointing at the newer frame and its REST /frame
    poll answered 304 against the resent frame's differing ETag. The push history row already snapshots
    the delivery targets, so a resend now replays those exact targets (and the matching panel dims)
    through the fan-out.

  • Canvas editor: HA entity filter, icon picker, and per-entity overrides were dead (#130). The
    canvas config drawer injects a widget's options form after page load, but the canvas editor template
    is standalone and never loaded the icon-picker / entity-overrides modules, and the multi-select
    filter wiring lived only in the grid editor. So for the Home Assistant "Entities" widget the filter
    box did nothing, the icon picker didn't open, and the per-entity label / icon / number-format
    overrides never rendered. The filter is now a shared component wired in both editors, the two modules
    load on the canvas page, and the drawer initializes all three after injecting the form.

  • Canvas preview failed behind a non-default external port (#129). When the server was reached on a
    port that differs from its internal bind (a reverse proxy, k8s Service, or Docker port map sending
    external 4567 to the container's 8765), the canvas preview and other render routes built the internal
    loopback fetch from the browser's port, so the headless renderer hit 127.0.0.1:<external> where
    nothing listens and the render was refused. The tesserae CLI now records its real bind port in
    TESSERAE_BIND_PORT (previously set only by the Home Assistant add-on), so the renderer always
    fetches /compose on the port Flask actually binds.

  • Status bar showed the wrong device's battery (#125). Four gaps fed the same symptom, a status
    bar falling back to the lowest battery / signal across all devices (and the wrong temperature /
    humidity). (1) Per-device render detection only scanned grid cells, so a canvas dashboard pushed once
    per panel instead of per device; it now scans canvas elements too. (2) Even when a canvas push fanned
    out per device, the canvas render path dropped the target device before the widget fetch, so every
    panel still resolved the aggregate; the target now threads through to the fetch. (3) Editor previews
    (the dashboards-list hover thumbnail and the live compose iframe) carry no target device, so both
    grid and canvas previews showed the aggregate; a preview now defaults to the page's first bound
    device and shows a real one. (4) The per-device fan-out decision read the plugin registry off
    current_app, which is absent on the scheduler and rotation push threads, so scheduled refreshes
    silently dropped the fan-out and a grid panel kept showing the aggregate even when a manual Send was
    correct; the push manager now holds a direct registry accessor.

Added

  • fetch_latest button action. Re-downloads and repaints the latest frame
    already rendered for the device without running the composer, publishing a
    new artefact, moving the rotation, or setting a manual override. The action
    bypasses a matching If-None-Match for its /frame response, so its meaning
    does not depend on a particular firmware clearing its cached ETag first.
  • MCP: rotations, schedules, and decks. The agent MCP surface (/api/mcp) and the
    tesserae-mcp bridge now expose list / create / delete for rotations and schedules, and list /
    create / delete / suggest for decks, so an agent that builds pages can also wire how they cycle
    (rotations), when they push (schedules), and how they group for instant navigation (decks).
    suggest_decks derives a ready-made deck from the page:<id> tap / swipe links already on page
    elements, so the agent can offer a deck once it has wired inter-page navigation.
  • Decks page reshaped around the canvas. The Decks page is now a management surface, not a graph
    editor: you author navigation in the canvas editor (tap / swipe "go to page" on tiles), and the
    Decks page suggests a deck from those links, then owns the deck-level concerns, refresh cadence
    (deck default + per-page), devices, entry, enable, delete. A Sync from links button re-derives
    a deck's graph (and touch zones) from the current page links, and the raw JSON graph editor is
    demoted to an "Advanced" fold for manual tweaks.
  • Per-page refresh cadence in decks. A deck page can set its own
    refresh_interval_minutes (in the graph editor or via MCP), overriding the deck's default, so a
    volatile tile can re-warm every few minutes while a photo page in the same deck refreshes rarely.
    0 warms the page only on first navigation; unset inherits the deck cadence.
  • Decks: pre-rendered, navigable page groups. A new Decks page (next to Rotations) groups pages
    into a small linked graph that Tesserae keeps pre-rendered per bound device, so a button press or
    touch that moves between them serves an already-rendered frame instead of rendering on the fly. Each
    page links to others by a physical button name or a touch zone; the scheduler re-warms a deck's
    pages in the background on the cadence you set, so their data stays current. Removes the on-the-fly
    render latency from navigation (the download and e-ink repaint still happen). Build a deck under
    Decks: name it, bind devices, set the refresh cadence, and define the page graph. Decks can also be
    suggested automatically: when pages link to each other via tap / swipe "go to page" actions set
    in the canvas editor, the Decks page offers a one-click deck for each cluster, with the graph and
    touch zones derived from those links.
  • OTA per-kind rollout: manual promote + canary (#121). Beyond staging a build for a single
    device, an operator can now set a signed build as a device kind's release and roll it out
    deliberately: python -m app.ota.release set (offered first to the canary devices you list),
    promote (to every device of the kind), pause, clear, list. On /status the server offers a
    device its kind's release when the device is eligible (a canary, or the release is promoted) and the
    release firmware is newer than the version the device reports; a per-device staged descriptor still
    wins. The device-side apply/verify/rollback firmware is tracked separately, so nothing reaches a
    device until that ships.
  • OTA production signing (Cloudflare Worker) and trusted-key registry (#121). A Cloudflare Worker
    (packages/ota-signer/) signs firmware descriptors with an Ed25519 key held only in a Worker
    secret and serves the images from R2, co-locating signing with storage; its output is byte
    identical to the Python signer. Published public keys live in ota/keys/<key_id>.pub, and
    python -m app.ota.stage now verifies a descriptor's signature against the key matching its
    key_id before staging, refusing a mis-signed one (--insecure-skip-verify to override). Keys are
    keyed by key_id so they rotate without a re-flash. The staging gate is server-side; firmware
    embeds its own key set as the real trust anchor.
  • OTA capability handshake and /status delivery (Phase 2, #121). A device advertises OTA
    support with an ota: {schema: N} object in its register/status body; the server hands back a
    staged, signed descriptor on the always-200 /status response only when the device advertised a
    compatible schema and the descriptor targets its kind. Descriptors are staged per device with
    python -m app.ota.stage (pipeline: sign then stage), held in data/core/ota_pending.json,
    and picked up on the next heartbeat. /frame is untouched, so the image channel stays byte-clean
    for every device kind. The production key, R2 image hosting, and OTA state reporting are later
    slices.
  • Button wake window for ESP32 devices (#123). A per-device button_wake_s config field (0-60
    seconds, default 0) that keeps the device awake for a moment after a button press changes the page,
    so scrolling several pages doesn't pay a fresh wake and Wi-Fi cycle per press. Set it under
    Settings → Devices. The value is delivered on the /frame response a button wake already fetches
    (and via the config block on register/status), so firmware reads it without an extra request; 0
    keeps the current behaviour of sleeping immediately, and it applies only to button wakes, not
    scheduled refreshes or rotation. Firmware support lands separately.
  • OTA update contract (Phase 1). The signing and verification half of the over-the-air
    firmware update flow: an Ed25519-signed {payload, signature} descriptor binds the target device
    kind, firmware version, image URL, size, and SHA-256. app/ota/ carries the signer (with a
    python -m app.ota.sign CLI), the reference verifier (signature, then manifest shape, then target,
    then image digest, each failure a stable reason code), and the wire contract at
    docs/ota/contract.md. tests/fixtures/ota/ publishes a test-only key and four signed fixtures
    (valid, wrong key, truncated, digest mismatch) so device firmware can self-test the verifier before
    the live pipeline exists. Descriptor delivery on /status behind a capability flag, and image
    hosting, are separate follow-up slices.

Fixed

  • Calendar discovery: adding one collection no longer hides the rest (#124). After a CalDAV
    discovery, adding a discovered calendar redirected to a bare page that dropped the other found
    collections. The add now re-renders the discovery list (the added one marked "already added", the
    rest still one-click addable), so a server with several calendars can be added in a few clicks.