Added
-
OpenDisplay device kind. A new
opendisplaydevice kind for OpenDisplay BLE e-paper tags,
driven by the separatetesserae-opendisplaybridge: Tesserae renders a full-colour PNG (via the
pi_pngrenderer) and the bridge polls the frame over REST and pushes it to the tag over Bluetooth
LE, where the OpenDisplay SDK dithers for the tag's panel. REST-polled, honourssleep_interval_s.
The panel size is set per tag at registration, so one kind covers every OpenDisplay panel. -
OpenDisplay via Home Assistant. A second OpenDisplay path for people already running Home
Assistant with the OpenDisplay integration: the newopendisplay_hadevice kind writes each
rendered frame into HA's media folder and calls theopendisplay.upload_imageaction, so HA owns
the Bluetooth and no separate bridge or BLE hardware runs on the Tesserae host. Set the tag's HA
device id on the device; each tag targets its own, so it scales to many tags. The frame uses a
stable per-device filename overwritten in place (no media-folder growth), and orphaned files are
swept on startup and when a device is removed. Best when Tesserae runs as the Home Assistant
add-on with a shared/media; requires the Home Assistant Core plugin. -
Touch interactions guide (#49). A new docs page covers enabling touch, attaching tap / swipe /
slider / hotspot / code-element actions in the editor, verifying with the touch monitor, and
implementing touch on any client (the transport is device-agnostic: ESP-IDF, CircuitPython, a Pi,
or any HTTP client, not just the reTerminal firmware). -
"Fade old" toggle on the touch monitor (#49). Dims older touch marks by recency so the latest
activity stands out against the history.
Added
- Set the log level on self-hosted installs (#122). A
--log-levelflag and aTESSERAE_LOG_LEVEL
environment variable set the root log level (trace / debug / info / warning / error) for Docker, LXC,
and bare installs, which previously had no way to change it from the hardcoded INFO. The flag wins
over the env var; the Home Assistant add-on keeps using its own Log level option. - OpenDisplay-via-HA tags report battery, signal, and firmware. These tags never heartbeat
Tesserae directly (Home Assistant owns the Bluetooth link), so a background poller pulls each tag's
battery / signal-strength / temperature / firmware from its HA entities (matched by device class,
via/api/template) and reshapes them into a normal heartbeat. It flows through the same pipeline
every other device uses, so the device card's battery, signal, and firmware tiles, the battery-drain
history, and the event log all populate with no tag-side reporting. Polls every 15 minutes by default
(app.opendisplay_telemetry_interval_s). - Pick the OpenDisplay tag from a dropdown instead of pasting an id. Both the Add-device form's
OpenDisplay tab and the device card now list the OpenDisplay devices Home Assistant knows about
(queried over HA's/api/templateendpoint, no WebSocket), so you select the tag by name and
Tesserae stores the correct device id. When the tag's model string carries a resolution (e.g.
296x128), it also fills the panel size. Falls back to manual entry when Home Assistant isn't
reachable. The underlyingha_device_pickerconfig field type is reusable by any HA-targeting
device kind.
Changed
- OpenDisplay is a transport choice when adding a device. The Add-device card's transport control
gains an "OpenDisplay" option alongside REST and MQTT. It detects whether Tesserae is the Home
Assistant add-on: if so it offers a short form to add the tag (pushed viaopendisplay.upload_image),
otherwise it points at the standalone bridge and the REST pairing tab. The "Issue pairing code"
button also aligns to the note field and stacks full-width on narrow viewports instead of
overflowing the card. - The canvas and grid editors share one touch Interaction picker (#49). The canvas editor had its
own copy of the Interaction UI (on-tap / swipe / slider / code-element actions); it now uses the
sametouch_interaction.jsmodule the grid editor does, so the two can't drift and an editor-side
fix (e.g. decoding a Home Assistant action back into the form) only has to be made once.
Fixed
- Physical buttons and touch swipes advance rotations built in the UI (#122). The Rotations page
saves a rotation with an emptydevice_ids(meaning "drive whichever devices the step pages are
bound to"), but the button / touch handler only matched rotations that named the device explicitly.
So no rotation created in the UI ever advanced on aleft/rightpress or a swipe: the device just
re-served its current frame, and the press showed in History as a red "failed" row. The handler now
honours the same page-binding fall-through the scheduler uses, and benign button outcomes (no-op,
duplicate, unmapped, dispatched) are labelled as such in History instead of "failed". - The "Issue pairing code" button lines up with the note field. The button sat ~12px below the
input because the field wrapper's block padding extended the flex row's baseline; the pairing-row
field now has that padding zeroed so the button's bottom meets the input's. - OpenDisplay-via-HA no longer times out on slow BLE pushes or blocks the push pipeline. A BLE
e-paper transfer can take tens of seconds; the 10s HTTP timeout fired mid-push and masked Home
Assistant's own error, and because push listeners run synchronously it would also have stalled the
pipeline. The upload now runs on a single background worker (BLE is serial anyway, so pushes
coalesce to the newest frame) with a 120s timeout, so a push returns immediately and HA gets time
to finish or report the real failure. - OpenDisplay-via-HA surfaces the reason
upload_imagefailed. When Home Assistant returns a 500
foropendisplay.upload_image, the log now includes HA's response body (the integration's actual
error) instead of just "HTTP Error 500", so a failed BLE push is diagnosable without digging
through HA's own logs. The HA device id help text and docs now spell out that it's HA's internal
device id (a long hex string), not the tag's own name/serial, which was an easy mismatch to make. - Resending a frame from History now re-paints REST clients (#119). A resend force-republishes
over MQTT, but a REST/HTTP-polled client comparing the content-addressed ETag would get a 304 and
skip the re-paint when the resent frame was byte-identical to what it already showed. Resend now
flags the frame so/frameserves one 200 to force the re-fetch, then clears the flag so the next
unchanged poll is 304 again (the deep-sleep battery path is unaffected). - OpenDisplay-via-HA can write frames to the media folder. The container drops to an unprivileged
user, but Home Assistant mounts/mediaroot-owned, so the first frame write hit
PermissionErrorand no frame reached the tag. The entrypoint now creates and chowns the
/media/tesseraesubdirectory while still root (HA core keeps read access), and the publisher logs
one actionable warning instead of a traceback per render if the folder still isn't writable. - OpenDisplay devices no longer read as MQTT. Both OpenDisplay kinds carried a
status_topicin
their manifest, which tagged them as MQTT devices and showed dormant MQTT topic rows. Neither uses
a broker: the OpenDisplay-via-HA kind now declares a newpushtransport (delivered through Home
Assistant'sopendisplay.upload_image, badged "HA"), and the bridge kind declaresrest(the
bridge polls the frame endpoint). Existing instances are normalised on load, so a device added
before this fix corrects itself without re-adding. Both device cards also gain an OpenDisplay setup
note that detects whether Tesserae is the HA add-on and links to the integration or the bridge
accordingly. - Code-element touch regions built asynchronously are no longer lost (#49). A code element that
builds its tappable DOM in JS (fetched data, a chart, any delayed render) had itsdata-on-tap
nodes scanned once, ~32ms after load, before they existed, so the region never reached the sidecar
and every tap on it resolved tono_target. The sandbox collector now re-scans on DOM changes and
reports the full set each time (the host rebuilds the mirror regions from it), with a short quiet
settle bounded by the existing timeout, so a late-appearing annotated node still registers. - Swipe zones are more forgiving (#49). A swipe was hit-tested only on its start point, so a
stroke that began a hair outside a small zone and moved into it registered asno_targeteven
though its tail crossed the zone. Swipe hit-testing now falls back to the stroke's end point when
the start didn't land on a region declaring that direction, so a swipe onto a zone fires reliably.
Taps are unchanged (still strict start-point), and sliders (a press-on interaction) aren't affected. - Home Assistant touch actions no longer fail with HTTP 400 (#49). The touch dispatcher called HA
throughcall_service_with_response, which always appends?return_response— and HA rejects that
with 400 for any service that doesn't support returning a payload (most actuators, e.g.
light.turn_on). A tap that resolved and dispatched correctly still came backha_failed. Touch/
button HA actions now use a plaincall_service(noreturn_response); the response variant stays
for the read-style services that need it (todo.get_itemset al). - Touch actions show correctly in the canvas editor (#49). Touch actions are now stored in their
canonical{action,domain,service,data}form at write time (a model validator, so it applies to
MCP writes, editor saves, and self-heals legacy dashboards on load). Previously an agent-written
flat HA action ({service,entity_id,brightness_pct}) dispatched fine but was stored raw, and the
editor's Interaction panel — which only decodes the canonical shape — showed it as blank/Custom. - Touch actions written in the flat Home Assistant shape now dispatch fully (#49). An action like
{"service":"light.turn_on","entity_id":["light.hall"],"brightness_pct":50}validated as fine but
silently droppedbrightness_pct: onlyentity_idwas folded into the call. Top-level service-data
keys are now hoisted intodata(an explicitdatablock still wins), and a comma-joined
entity_idstring normalises to the list HA expects, so the flat form dispatches the same as the
nested canonical one. - Structured swipe actions can be authored (#49).
on_swipewas typed as a direction→string map,
so an inline HA object ({"left":{"action":"ha",...}}) was rejected at write time and the
interaction never stored. A swipe direction now accepts the same string-or-structured forms as
on_tap. A swipe object with noup/down/left/rightkey (which can't fire) is flagged in
render_report'stap_invalidwith a fix hint instead of being silently dropped.
Added
-
Touch authoring ergonomics for MCP agents (#49). Four additions from agent-session feedback:
adescribe_actionsMCP tool /GET /api/mcp/actions/describereturns the authoritative
touch-action vocabulary (element fields, string grammar, the HA object form and every input
variation, slider{value}, provenance, how to verify) so it doesn't have to be reverse-engineered;
render_reporttakes?view=touchor?fields=…to trim the response so verifying a large board
doesn't blow the output cap; a bulk element endpoint (POST /pages/<id>/elements/bulk, all-or-
nothing) builds a big primitive board in a few chunked saves instead of one giantset_canvas; and
the touch monitor gains a dashboard picker that previews any dashboard's touch regions at the device
panel size without pushing it first. Bridge published as 0.7.0 with the matching tools. -
Self-hosted CalDAV calendars + todo lists (calendar_core). Calendar feeds can now carry basic
or digest credentials, so a private server (Baikal, Radicale, Nextcloud) that gates its.ics
export behind auth works, including a LAN-only server the panel can't reach directly (fetches run
server-side). A Discover panel takes your CalDAV calendar-home URL and enumerates the calendars
and todo lists it finds via one PROPFIND, so you add each in a click instead of hand-building
?exportURLs. calendar_core also parsesVTODOcomponents now (load_todos), which the new
CalDAV Todo widget renders as a read-only checklist. Credentials are stored in the plugin's
feeds.json(plaintext in the data dir, same posture as the Home Assistant token).
Fixed
-
Artifact GC no longer eats the live frame's touch regions (#49). The render prune (and the
History page's per-row delete) kept only digests still referenced by event rows, so once the event
cap evicted (or a History clear deleted) the push row for a frame still on a panel, its
touch-region sidecar and thumbnail were removed from disk. From then on every tap resolved
no_target, interactions stopped carrying actions, and the touch monitor had no regions to
overlay. The latest render for every device (artifact + composition digest) is now always kept.
Recovery on an affected install: push the dashboard once and the sidecar regenerates. -
MCP element writes reject unknown fields (422). Unknown element keys were silently ignored, so
an agent writingtapinstead ofon_tap(or any misspelled field) got a 200 while the
interaction evaporated. The element write paths (append / patch / whole-document) now return 422
naming the unknown keys and pointing aton_tap/on_swipe/on_slide, so the mistake
surfaces at write time instead of as a dead panel.
Added
-
Touch linger on by default for the reTerminal E1003 (#49).
touch_linger_snow defaults to
30, so after a touch wake the firmware stays up polling the digitizer directly and follow-up taps
dispatch in a few hundred ms instead of paying the ~2.7 s deep-sleep boot each time. Delivered
through the/statusresponseconfigblock like every other device setting; set it back to 0
in the device's settings to sleep immediately. Touch input itself stays opt-in. -
Speculative pre-render of likely next frames (#49). During a touch session the server now
prewarms the compositions a follow-up gesture is most likely to ask for (the rotation steps either
side of the current one) on a background thread, so the synchronous push a swipe or page tap
triggers skips its Playwright capture, the dominant share of post-touch latency. Entries are
consume-once with a 60 s TTL and keyed on the page's content token, so an edit mid-session or a
scheduled push minutes later can never be served a stale composition. -
Touch ETag stability locked by regression tests (#49). A touch whose action doesn't change
the canvas must leave the frame ETag untouched: renders are content-addressed, the packers are
deterministic, and the touch re-push path doesn't force-publish, so an unchanged canvas resolves
tono_changeand the device's next/framepoll 304s. On the E1003 every false-positive 200
costs a 1.3 MB download and a ~30 s panel repaint, so this chain is now pinned by tests at both
the REST layer (no-op touch → 304 against the prior ETag) and the push layer (unchanged re-push
keeps the digest). -
Touch monitor Clear now sticks (#49). Clear only wiped the on-screen marks, but the monitor
re-seeds from touch history on load, so the events reappeared on refresh. It now deletes this
device's recorded touch events server-side (scoped totouchevents for that device, so push and
button history is untouched), so the clear survives a reload. -
Home Assistant touch actions written in the natural shape now fire (#49). A structured HA
action only dispatched when written in the exact canonical form; the shapes an agent naturally
writes (noactionkey when aserviceis given,entity_idat the top level, a dotted
service:"light.turn_on", or the HA-nativetarget) silently no-op'd. These now normalise to the
canonical form and dispatch, and sliders accept$valueas well as{value}. Execution is
server-side through the ha_core connection (a POST to/api/services/...), not the read-only
ha_service data source. -
render_reportno longer green-lights dead touch actions (#49). It now returnstap_invalid,
regions whose declared action would not dispatch (box + gesture + reason), so verification reflects
what will actually fire instead of echoing a stored-but-undispatchable payload. A region appearing
intap_regionsonly means it was stored;tap_invalid == []is the real "this dashboard is
wired" signal. The panels touch-regions endpoint reports the same.
Added
-
Per-device touch monitor (#49). A touch-capable panel (the Seeed reTerminal E1003) gains a
Touch monitor page, linked from its device card, that draws the panel at its true aspect ratio
and plots recent touches on it: taps as dots, swipes as arrows, sliders with their value, each
colour-coded by outcome (fired / no target / blocked). The last render's touch regions overlay so
you can see whether a tap landed inside its target, and events stream in live over the Events SSE
feed. Seeds from the recenttouchhistory on load, so you get the last session's misses without
waiting for a fresh tap. -
Touch events on the Events page (#49). Touch strokes now log as their own
touchevent type
with a dedicated filter chip, instead of being folded into the push feed. Every touch is recorded,
including the misses (no_target,stale,blocked), which are the diagnostically useful ones,
with a scannable summary (gesture, coordinates, resolved action, region) and the full JSON payload
on expand. Handy for confirming a tap landed where you expected and fired the right action. -
Touch capability flag on devices (#49). Panels with a touch digitizer (the Seeed reTerminal
E1003) now reporttouch: truethrough the device APIs, the MCPlist_devices//devices
surface and the canvas editor's device list. Previously nothing in the device registry indicated
a panel was touch-capable, so an agent inspecting the devices couldn't tell the E1003's on-tap /
swipe / slider actions would actually fire on it. It's a hardware fact carried from the hardware
catalog entry, distinct from the per-devicetouch_enabledfirmware setting. -
Touch actions in the grid dashboard editor (#49). The touch authoring that shipped for the
canvas editor is now on grid dashboards too: each cell's Advanced pane gains a full
Interaction editor, on-tap and per-direction swipe actions (refresh / rotate / jump to step / go
to page / webhook / Home Assistant), and "Make this a slider" to map a drag to a 0-100{value}
for a webhook or HA call. The picker UI is a sharedtouch_interaction.jsmodule so the grid and
canvas editors stay consistent, backed by ungateddashboards.json+ha-actions.jsonendpoints
so it works without the canvas experiment. -
Home Assistant touch actions and sliders (phase 3 of #49). The Interaction picker gains
Home Assistant…: choose a service and entity (fetched live from your HA instance via the
shared ha_core connection) plus optional service data, and a tap on the element fires the call.
It runs synchronously and re-pushes the current page, so the frame the display gets back on that
same wake already shows the new state. Any element (or invisible touch region) can also become a
slider: the stroke's end point maps to 0-100 along the chosen axis (vertical fills upward, and
a plain tap sets the value at that point), and"{value}"placeholders in the action receive the
number, e.g.light.turn_onwith{"brightness_pct": "{value}"}for a one-stroke dimmer, or a
webhook URL like…/level/{value}. Sliders work on grid cells too. Structured HA actions are
honoured only from editor/MCP-authored config or a code element's named actions map, per the
touch provenance gate. -
Touch actions in the canvas editor (phase 2 of #49). Every canvas element gains an
Interaction section: pick an on-tap action (refresh, rotate, jump to step, go to page,
webhook) and per-direction swipe actions, with a hand badge on interactive elements and in the
Layers list. A new Touch region palette entry drops an invisible hotspot you can position
over anything, including a code element's rendered output. Code elements get a named Actions
card (mirroring Sources): define actions with pickers, reference them from markup as
data-on-tap="@name", in static HTML or JS-built DOM alike. A toolbar Show touch targets
toggle renders the canvas headless and overlays the extracted regions, flagging unresolved
@namereferences. The MCPrender_reportnow returnstap_regions+tap_danglingso agents
can verify their annotations. Dispatch gains a provenance gate: side-effecting actions (webhooks,
and Home Assistant actions when they land) only fire from user-authored config (editor/MCP fields,
code-element actions maps), never from raw widget markup; navigation actions work from anywhere. -
Touch input protocol (server side). Touch-capable devices can now report taps and swipes and
have them drive the dashboard (#49). A stroke arrives either astouch_*query params on the
existingGET /framepoll (deep-sleep clients get the action's repaint on the same wake, exactly
like button wakes) or via a newPOST /api/v1/device/<id>/tap; the device stays a thin client and
the server does all gesture classification (tap vs directional swipe) and hit-testing. Touch
regions are declared in markup, not drawn: any element in the composed page can carry
data-on-tap/data-on-swipeattributes (the existing button-action grammar:refresh,
page:<id>,rotate_next,webhook:<url>, …), including DOM generated inside a canvas code
element. Grid cells take a per-cellon_tapoverride plus a widget-manifeston_tapdefault.
Regions are extracted at render time from the exact DOM the frame captured (shadow roots
included) and stored beside the render, and a stroke is only ever dispatched against the frame
the finger actually touched (stale strokes degrade to a plain refresh). Touch events land in the
History page alongside button presses. The reTerminal E1003 (GT911 touch panel) gains
Touch input and Touch linger device settings delivered through the standard config
channel; firmware support lands separately in the unified device firmware. -
Manual history controls. The History page auto-evicts at a cap, but now you can also clear it
by hand: a "Clear history" control deletes everything or everything older than 7 / 30 / 90 days,
and a per-row checkbox with a floating bulk bar (like the Dashboards multi-select) deletes just the
ticked entries. Orphaned render artifacts are pruned after any bulk delete. (#116) -
"Update available" badge in the header. When a newer Tesserae release exists, the topbar shows
an accent-tinted update badge (with the version) linking to Settings. The check reads
api.tesserae.ink/version/latestfor the running version, entirely in the background off the
request thread (a cached, single-flight refresh with a 6h TTL), so no page render ever blocks on
it. It's gated by the online-features opt-out exactly like the heartbeat, so an opted-out install
makes no call and shows nothing, and the install id is scoped (a one-way derivation) before it's
sent. Compares against the latest stable release; an install already ahead of stable (a source /
edge build) simply shows no badge rather than a false nag. -
Choose how Home Assistant numbers are formatted. HA widgets rounded every numeric state to 2
decimals and trimmed trailing zeros, so a column mixing21.00and20.55rendered as21next
to20.55. A new widget-level Number format option fixes the decimal places using the same
pattern vocabulary as the canvas data element (0,0.0,0.00, …); blank keeps the old auto
behaviour. Applied across the HA family where it fits: Sensor and Entities also take a
per-entity override from a per-row format box in the names/icons editor, and History formats
its current / min / max labels. Purpose-built HA widgets (Battery, Climate, Energy, Media, Lights)
keep their fixed, unit-aware precision.ha_sensor0.7.0,ha_entities0.6.0,ha_history0.5.0.
(#111) -
Daily heartbeat now reports firmware versions per device kind. The opt-in heartbeat already
sent the set of configured device kinds but not what firmware they run, so the maintainer couldn't
see the firmware spread in the field. It now includesfw_by_kind({kind: [versions]}), sourced
from each device's latest status heartbeat, deduped and capped. Aggregate only, no per-device
identity; documented on the privacy page. -
Bundled fonts are now usable inside the code element. A code element can use any bundled font
by family name (font-family: "Fira Code","Press Start 2P", …). The sandbox has no network and
afont-src data:CSP, so a new/fonts/face/<id>.cssendpoint serves a self-contained@font-face
(woff2 embedded as adata:URL), and the sandbox inlines only the fonts a given element actually
names. Previously only Phosphor was available in code elements; the wholefonts_coreset (including
the new programming + pixel fonts) now works there. Bridge doc-shape updated so the agent knows.
Bridge 0.5.13. -
16 new fonts: a big programming + pixel set. Core fonts (
fonts_core0.5.0) gains a broad
monospace / coding set (Fira Code, Source Code Pro, Martian Mono, Red Hat Mono, Fragment Mono,
Spline Sans Mono, Overpass Mono, on top of the existing JetBrains Mono / IBM Plex Mono / Space
Mono) and a pixel / bitmap set (Press Start 2P, VT323, Silkscreen, Pixelify Sans, Handjet, Micro 5,
DotGothic16, Jersey 10, Jersey 25). All Latin-subset woff2, OFL/Apache, verified to load in-browser. -
Multi-select dashboards for bulk push / delete. The Dashboards page now has a checkbox per
dashboard and a floating action bar that appears once one is selected, with "Push selected" and
"Delete selected". Push fans each dashboard out to its own bound devices; delete removes them all
after a confirm. NewPOST /send/pagesandPOST /pages/bulk/deleteendpoints back it. -
All six Phosphor icon weights in the canvas code element. The sandbox previously only had bold;
it now has thin, light, regular, bold, fill, and duotone, each vendored as a self-contained CSS with
the font embedded as adata:URL and inlined only when its class (ph,ph-thin,ph-duotone, …)
appears in the code, so an element that uses one weight doesn't pay for the others. Verified all six
load and render under the sandbox CSP.
Changed
-
MCP agent binds the device early and builds visibly. The bridge instructions now open with a
"START HERE" step: pick the panel withlist_devices,create_canvas_pagesized to it, then
bind_devicesright away rather than only at push time, so the artboard and Send / schedule /
rotation target the real hardware from the start (previously the agent left a dashboard unbound
unless asked). The same step tells the agent to add an emptycodeelement and stream it in with
append_code(the open editor re-renders each chunk) and preview early, instead of composing the
whole thing silently and posting one bigset_canvasat the end, so the build stays responsive.
Bridge bumped to 0.5.14. -
The MCP agent defaults to the code element for dashboards. Guidance now tells the agent to build
a dashboard as acodeelement (HTML/CSS/JS fed by widget sources, with the vendored toolkit) for
anything beyond a trivial single-widget page, falling back to bare widget/data/shape elements only
when the page really is just one widget. Bridge bumped to 0.5.12. -
Hover preview on the Dashboards page. Hovering a dashboard row shows a scaled-down preview of
it. The preview is a cached PNG screenshot, rendered via the same headless path a push uses and
reused, keyed by a content token so it only re-renders when the dashboard actually changes. The
render happens in the background off the request thread (a single-flight queue): the first hover of
a new/changed dashboard falls back to a live/compose/<id>iframe while the image renders, and
subsequent hovers serve the cached PNG. It lazy-loads after a short hover-intent delay, covers grid
and freeform pages alike, and is disabled on touch / narrow screens. -
Bind a canvas to multiple devices. The canvas editor's device picker is now a multi-select
popover: a canvas can target any number of panels, and Send fans the one render out to each,
fitted/quantised to its own dims by the server. Matching the artboard to a specific panel's
resolution is now a separate, explicit per-device action (⤢), decoupled from binding so a
mixed-size fleet doesn't fight over one artboard. On the MCP side,push_to_devicealready fanned
out to a device list; a newbind_devicestool (POST /api/mcp/pages/<id>/devices) persists the
target set on the page so agent-built dashboards can be scheduled to the same panels. Bridge 0.5.11.
Changed
- MCP agent designs in full colour, not just the raw panel inks. The canvas agent's guidance
(bridge doc-shape + the/devicescolour capability) told it to "design within the panel palette
so colours don't quantise away", which pushed it to flatten layouts to the handful of Spectra 6 /
ACeP inks and threw away what dithering buys. The panel actually dithers the full-colour
composition (Floyd-Steinberg) down to its inks, so rich colours, gradients, and photos reproduce
as blended approximations. Guidance now says to design in full colour and reserve exact palette
hex only for fine detail (thin text / small icons) where dithering reads as speckle, and to honour
themonoflag for genuinely grayscale panels. Bridge bumped to 0.5.10.
Fixed
-
Grid dashboard editor: reload-on-change fields no longer pop a spurious "Leave site?" dialog.
Picking a target device, a cell's widget, a layout preset, or refitting the panel saves every form
then reloads the editor (the server reshapes the page). That reload left the page still flagged
dirty, so the browser's unsaved-changes guard fired: choosing "stay" cancelled the reload and left
the status stuck on "Saving…" (even though the change had already been saved), while "leave" let it
through and looked like leaving had saved. These paths now mark the page clean once the save
completes and before the reload, so they persist and refresh silently; genuinely unsaved text edits
still warn on navigate and discard on leave as before. (#115) -
Render pipeline no longer starves its own worker threads. The headless screenshot self-requests
/compose/<id>, which needs a free web-server thread to be served; a blocked render holds its
caller's thread for up to ~105s, and every open SSE stream (the event log, the canvas editor) pins
one for the life of the connection. With only 8 waitress threads, a couple of open editors plus a
render or two could leave nothing free to serve the inner/compose, so every render timed out
(Page.goto: Timeout 15000ms) and the task queue backed up. The default thread count is raised to
24 (overridable viaTESSERAE_THREADS), and the dashboards hover preview now renders in the
background rather than on the request thread, so a burst of hovers can't pile onto the pool. -
Heartbeat device count no longer reads "10+" on every install.
build_payloadcounted
registry.all(), which includes the 20+ built-in device kinds and hardware SKUs (catalogue
entries, not owned hardware) alongside the operator's actual device instances. So every install
reported the10+bucket regardless of how many panels it drives. The count, transport, and kind
set now key off instances only (kind_ofset). -
Install identifier no longer rotates on a transient read hiccup. The install-id loader treated
"file exists but couldn't be read right now" (a transient I/O / permission error, e.g. the
container-bootchownrace) the same as "corrupt", and self-healed by minting a fresh UUID that
overwrote the still-valid file, permanently changing the install's identity (which surfaced as
the ID appearing to change across updates). A present-but-unreadable file is now left untouched: the
boot uses an ephemeral ID and the next healthy boot recovers the real one. Genuinely corrupt files
still self-heal. -
Spectra 6 panels no longer pick up a 7th colour. The CircuitPython PNG/BMP path mapped a
spectra_6panel to the 7-entry palette that carries orange (that palette exists for the Pi-side
inky path, which reprojects to its own gamut). A CircuitPython client paints exactly what arrives,
so the extra ink showed up asDarkOrange(#FF8C00) in the output on a 6-colour panel. The gamut
now maps to the 6-colour Waveshare E6 palette (black/white/red/yellow/blue/green), and the unknown /
unset-gamut fallback matches. (#118) -
Gallery accepts BMP uploads. The Picture, Gallery widget filtered BMP out of both the file
picker and the server-side upload check, so a pre-dithered.bmpcouldn't be added..bmpis now
an allowed suffix; thumbnails and serving already handled it. (#117) -
Stable Home Assistant add-on now tracks releases again. The
release.ymlworkflow
published each GitHub Release with the built-inGITHUB_TOKEN, and GitHub does not let that
token trigger downstream workflows, sosync-addon.yml's stable-channel job never fired and the
add-on's stable version froze (it lagged at 0.65.0 while releases reached v0.100.0).release.yml
now explicitly dispatches the sync viaworkflow_dispatch(the documented exception that does fire
fromGITHUB_TOKEN), so every release bumps the stable add-on. Edge was unaffected.
Added
-
PicPak listed under supported hardware. The README's supported-hardware section gains a
Community table crediting the PicPak 4.2" BWRY
client, a community firmware by @varanu5. -
Canvas code elements can show remote images. The
codeelement sandbox now allows
img-srcfrom the web (https:/http:/data:/blob:), so a code element can paint
a Spotify album cover, an Unsplash photo, or any other remote artwork it pulls from a source,
the same external images ordinary widgets already render. Isolation is otherwise unchanged: this
is images only (a one-way GET);fetch/ XHR / WebSocket stay blocked bydefault-src 'none', and
the frame keeps its opaque origin (no same-origin or credentialed access to Tesserae). Bridge
doc-shape updated so the agent knows remote<img>works; bridge bumped to 0.5.9. -
Service plugins: a non-placeable
serviceplugin kind that feeds the canvas code element.
A new manifestkind: "service"exposes a whole external API as a data source for acode/data
element without appearing in the canvas picker (it has no placeable render). The MCP agent discovers
them viaGET /api/mcp/services(and thelist_servicesbridge tool), then sources one by key exactly
like a widget:POST /widgets/<key>/data(probe) andGET /widgets/<key>/optionswork unchanged. By
convention a service probed with empty options returns a self-describing map of the scopes it offers,
so the agent can explore the API before choosing what to pull. Four reference services ship: Open-Meteo
(no key: current / hourly / daily / air quality / marine), REST / JSON (any public https endpoint, with
a loopback/private-host SSRF guard), Home Assistant (reuses the ha_core connection: states / entity /
history / services / config / raw), and Spotify (marketplace, reuses spotify_core: now-playing / queue /
top / raw Web API GET). The plugin schema now allows non-placeable kinds to leavesupports.sizesempty.
Bridge bumped to 0.5.8. -
Stream a code element in live (MCP
append_code). A newPOST /pages/<id>/elements/<eid>/append
endpoint (andappend_codebridge tool) appends text to a code element'shtml/css/jsand
saves on each call. Since a save is what pushes the SSE update to an open canvas editor, an agent
can stream a code element in chunk by chunk and watch it build up, rather than the whole blob
appearing at the end. Returns the field's new length; bridge doc-shape documents the pattern
(add the empty code element, then append). Bridge bumped to 0.5.7. -
Code elements get a rich sandbox toolkit + editor polish. The
codeelement's sandbox now
has a vendored, self-hosted library toolkit, each conditionally inlined (loaded only when the
code references it, so unused libs cost nothing): Chart.js + chartjs-plugin-datalabels,
canvas-gauges, Day.js (+utc/timezone), qrcode, marked, chroma.js, SVG.js, and Phosphor icons
(<i class="ph-bold ph-heart">, font inlined as adata:URL). Isolation is unchanged (opaque
origin, no network); verified end to end that each renders through the real compose path. The
canvas editor's code popout now auto-formats on open (js-beautify) so one-line / MCP-authored
code is readable, with a Format button and line-wrapping; and the sidebars are now resizable
(drag handles, persisted) and scroll when content overflows. The MCP bridge doc-shape (0.5.6)
documents every sandbox library with usage, andset_canvas_backgroundnow tells agents the
fal.ai key lives on the AI-image (fal-image) widget.
Changed
-
A device can switch frame format (png <-> bmp) without re-registering. Re-declaring
formaton a laterPOST /api/v1/device/registeror/discover(MAC-match) now moves an
already-registered device to the matching renderer in place, instead of the declared format
being silently ignored on any existing device. On a real switch the device's cached frame is
invalidated, soGET /framereturns204until the next push repaints it in the new format,
rather than serving the stale old-format frame (previously only clearable by deleting
data/core/renders). Aformatthat's absent, unknown, or already active is a no-op. -
CircuitPython BMP frames are 2-8x smaller (sub-byte packing). The
circuitpython_bmp
renderer now packs the indexed frame at the smallest standard BMP bit depth that fits its
palette (a bespoke writer inapp.bmp_writer): 1 bpp for mono, 4 bpp for tri-colour / 4-grey /
Spectra 6 / 7-colour ACeP, versus Pillow's fixed 8-bit. Output stays uncompressed BI_RGB and
decodes on the sameadafruit_imageloadpath (its unpacker is generic over bit depth); the
full-colour rgb24/rgb16 passthrough still emits a 24-bit BMP. The palette compacts to the colours
actually present, so a frame that touches few colours packs even smaller.
Added
-
Chart.js in code elements; MCP data-source guidance. Chart.js (vendored, self-hosted) is
preloaded inside thecodeelement's sandbox aswindow.Chart(animations off), so author JS can
draw charts from widget data with a<canvas>andnew Chart(...). It's inlined into the
network-blocked opaque-origin sandbox, so the isolation is unchanged; it renders once at compose
time (verified end to end through the real render path). The MCP bridge doc-shape (0.5.5) gains a
"DATA SOURCES" section spelling out the model (a source is always a widget key + options;data
andbindtake one,codetakes many named ones asctx.data.<name>; probe first; shared fetch
is free) and documents the Chart.js availability. -
Code elements take data from any number of widgets; canvas editor gains collapsible
sidebars and a CodeMirror popout. Acodeelement now declares a list of namedsources
({key, options, name}), each injected asctx.data.<name>, so one element's JS can combine
weather + transit + calendar, etc. (a legacy singlesourcestill works, keyed by widget id).
Drivable from MCP with no new endpoint (thesourcesarray ridesadd_element/set_canvas;
bridge doc-shape updated, 0.5.4). In the canvas editor, the left and right sidebars now collapse
to reclaim canvas space (persisted per browser), and code is authored in a rich CodeMirror popout
(vendored, self-hosted; HTML/CSS/JS panes with syntax highlighting, line numbers, bracket matching)
opened from the element inspector, which also lists each source'sctx.data.<name>.<path>fields. -
Canvas
codeelement: HTML/CSS/JS fed by widget data. A new canvas element kind runs
author HTML + CSS + JavaScript, fed by a widget's live data primitive: the source widget's
fetched data is injected as the JS globalctx.data(plusctx.options/ctx.w/ctx.h) and the
JS builds the DOM from it. It renders in a sandboxed iframe with scripts enabled but an opaque
origin and CSPdefault-src 'none'— no network, no same-origin access — so the data is delivered
(never fetched from inside the frame) and the loopback render context stays sealed. Runs once at
render (e-ink is static). Authored in the canvas editor (source picker + HTML/CSS/JS panes with a
ctx.datafield hint) and drivable from MCP with no new endpoint:kindis a free field, so
add_element/set_canvascarry it, andprobe_widget_datasurfaces the field shape first.
Bridge doc-shape documents the kind (bumped to 0.5.3). -
AI-generated canvas backgrounds (fal.ai). A canvas dashboard can generate a full-bleed
background image from a text prompt; the data widgets composite crisply on top, so the data
never passes through the image model (the background is decorative, the numbers stay exact).
Newapp.fal_backgroundsservice calls fal.ai, stores the result as a local render asset, and
sets the canvas' existingbg_image. Exposed asPOST /api/mcp/pages/<id>/background(and
set_canvas_backgroundin thetesserae-mcpbridge) and as a "Generate background" control in
the canvas editor's appearance panel. Model + style presets mirror the fal-image widget; the
fal key is reused from an installed fal-image widget, elseapp.fal.api_key, elseFAL_KEY.
Generation is on-demand (set-and-forget). Needs a fal.ai key; no key = the feature 400s cleanly. -
MCP faithful-render screenshots for the catalog (Screenshot Contract). The
GET /api/mcp/widgets/<id>/render.pngfaithful render now accepts explicitw&h
(clamped) as an alternative tosize(lgstays 1200x800), reuses the same
Playwright render path, and tightens its error semantics to the contract: unknown
widget 404, unknown fragment or invalidoptions/optsJSON 400, render unavailable
503 (was 502), never a 200 with a blank / HTML body. A smallpython -m app.screenshots <id> --out <dir> --lg --extra <presets.json>CLI drives that same endpoint to write a
widget's whole catalog set (lg.pngplusextra-1..N.pngfrom a{name, options}
preset list). All additive; now/h= today's behaviour. -
CircuitPython clients can request an uncompressed BMP frame. New
circuitpython_bmprenderer emits the composition quantised to the panel's palette
as an uncompressed indexed BMP, alongside the existingcircuitpython_png. A client
declares"format": "bmp"on/discover(or/register) to bind it. The BMP needs
nozlib.decompresson the client:adafruit_imageloadreads it row by row, so peak
RAM is the framebuffer plus a small row buffer, where an indexed PNG's one-shot inflate
can't fit a contiguous decode buffer on Pico W class boards. PNG stays the default for
boards with headroom (it's a few times smaller on the wire). Thecircuitpython_generic
kind now lists both renderers and pins one per instance via arenderer_idrecorded at
registration; the shared pixel pipeline lives inapp.quantizerso both formats produce
identical pixels. Spec: client protocol. -
MCP: less friction authoring widgets via Studio. Three additions to the
/api/mcpsurface:GET /widgets/<id>/render.pngtakes afragmentparam so an
agent can faithfully render a single declared fragment (not just the whole card),
400ing an unknown fragment id;render.pngandPOST /widgets/<id>/datatake
fresh=trueto bypass caches (surfaced to widgets asctx["fresh"], and it skips
the render path's last-good fallback) so a just-editedserver.pyis instantly
verifiable; andDELETE /api/mcp/pages/<id>removes a canvas dashboard (throwaway QA
pages), exposed in thetesserae-mcpbridge asdelete_canvas_page. The reload acks
now carryreloaded(and a re-imported module count) so an agent can tell a real
in-process reload from a no-op. All additive; nofragment/fresh= today's behaviour.
Changed
- The
tesserae-mcpbridge now lives in this repo (packages/tesserae-mcp) as a
self-contained sub-project with its ownpyproject, so its tool list / doc-shape stay
in lockstep with the/api/mcpsurface it wraps and are tested in the same CI run. It
still ships a thin, stdlib-plus-mcpwheel published to PyPI astesserae-mcp, so an
agent-machine install stays light. Install becomespip install tesserae-mcp.
Docs
-
The
tesserae-studio-mcpbridge install now readspip install tesserae-studio-mcp
(it's on PyPI), matching thetesserae-mcpsection, with a from-source fallback. -
MCP client-config snippets for Codex CLI, Cursor, Windsurf, Cline, VS Code, and the
OpenAI Agents SDK, alongside the existing Claude config. -
Two new docs-site pages: Build widgets with Studio (the Tesserae Studio authoring
loop, linter rules, and the new-widget restart gate) and MCP servers: install & use
(installing and configuring bothtesserae-mcpfor dashboards andtesserae-studio-mcp
for widgets).
Added
-
Canvas: live data bindings for shapes. Data elements re-evaluate every render,
which is why numbers stay live; shapes (rect / ellipse / icon / line / text) were
static geometry. Any element can now carrybindentries that read a widget field
each render and map it through a transform to patch the element's props, so a shape
reflects data in lockstep with the data primitives on the same canvas, with no
polling or agent tick, and it survives push / rotation. Six transforms:position
(a marker that moves along a segment),length(a gauge that grows),pick(hop
between discrete states by index),color(threshold colouring),gradient(a
value interpolated smoothly along colour stops, quantised to the panel palette on
e-ink), andicon(a condition code to a Phosphor glyph). Bindings resolve through
the same shared fetch as data elements, so a shape bound to a widget already on the
canvas costs no extra request. Authored via the document (MCPset_canvas/
tesserae-mcp0.4.0); a binding that can't resolve is skipped, leaving the element's
authored props intact. -
MCP: push a widget you are authoring to a running instance. New endpoints let
an authoring client (Tesserae Studio) install a widget over the network with no
shared filesystem, so authoring works against a Tesserae on another machine or in
the Home Assistant App / Docker.POST /api/mcp/widgets/installaccepts a widget
tarball, validates it (kind widget, schema-valid, no bundled-id collision, tar-slip
and size guarded), writes it to a persistentauthored/dir, and reloads: an
in-process registry rebuild when safe (fast, no dropped connections) or a process
restart when the widget adds an admin page.DELETE /api/mcp/widgets/<id>,
POST /api/mcp/reload,GET /api/mcp/widgets?origin=authored, and a faithful
GET /api/mcp/widgets/<id>/render.pnground it out. Token-authed like the rest of
the MCP surface (loopback or bearer), behind themcpexperiment. -
MCP: faithful, editable, hardware-aware dashboard building. A batch of
agent-facing improvements from real building sessions:- Partial edits.
update_element,delete_element, andpatch_canvas
change one element or one document field without re-sending the whole canvas
(a big dashboard was expensive and error-prone to edit before). - Structured render report.
render_reportreturns a machine-readable
companion to the preview PNG: per element, the resolved box, the text that
rendered, overflow/clip flags, whether the data was live / sample / error, and
the computed colours, plus the board's resolved background and theme. The agent
can verify a render (catch clipping, confirm live data) without parsing pixels. - Probe tells live from placeholder.
probe_widget_datanow reports a
data_source(live | sample | error) and areason, and lists the bindable
field paths with sample values, so an agent stops mistaking a demo sample for
a real result and stops reverse-engineering field shapes. - Text measurement + layout helper.
measure_textreports how wide text
renders in a given font (so a box can fit its content and not clip);
arrangecomputes aligned grid / row / column boxes so an agent lays out by
intent instead of hand-computing every pixel. - Leaner option schemas.
get_widget_optionsomits huge choice lists (HA
entity pickers) by default, showing a count and achoicesendpoint; each
option carries a format hint for its type.get_widget_choicespages the rows. - Device colour capability.
list_devicesnow reports each panel's
color_mode, renderable palette (hex), and amonoflag, so an agent designs
within what the hardware can show. - Concurrent-edit guard. Writes accept a
base_rev(fromget_canvas); if
the page changed since, the write returns a conflict instead of clobbering a UI
edit, and acks now carryupdated_at/updated_by.
Requires the
tesserae-mcpbridge 0.3.0. - Partial edits.
-
Canvas editor: SVG primitive. Paste raw SVG as an element; it scales to fill
the box and renders in a sandboxed iframe. -
Data primitives: value formatting. A
formatoption renders datetimes and
numbers (e.g.HH:mm,MMM d,relative,0.0), so calendar/time fields are
presentable. Field paths also gain array indexing and a pluck syntax
(series.*.total/series[].total) to feed charts from arrays of objects. -
MCP: agent affordances. From feedback building dashboards with an agent:
set_canvasreturns a compact{ok,id,rev,elements}ack instead of echoing the
whole document (opt back in with?return=doc); aprobe_widget_datatool
returns a widget's live data so the agent can see real field names before
binding; anadd_elementtool appends one element per call (each a save, so an
open editor updates live as the agent builds); and the catalog omits per-widget
samples to stay small.
Fixed
-
Pushed widgets served their assets only after a restart. A widget installed
over the MCP push path went live for data on the in-process reload but its
client.js/ static assets kept 404ing until a full restart, so a freshly pushed
widget rendered blank. The per-plugin asset routes closed over the registry captured
at startup; they now readapp.config["PLUGIN_REGISTRY"]fresh per request (matching
the composer / condition routes), so a newly-pushed non-blueprint widget serves its
assets immediately on reload. Widgets that declare an adminblueprint()still take
one restart (Flask registers blueprints once at startup). -
MCP
render.pngreturned a login screenshot on password-protected instances.
The single-widget render endpoint screenshots/_test/renderover loopback with no
session; that path was not in the auth gate's loopback-exempt list, so a
password-protected instance redirected the render to/loginand captured the login
page instead of the widget./_test/renderis now loopback-exempt (loopback only,
same trust boundary as/compose), so the render is faithful with or without a
password. -
Grid layout editor: smooth edge dragging. Dragging a cell edge no longer
rebuilds the whole schematic board on every pointer move; it repositions just
the affected cells (coalesced per animation frame), so the drag tracks at full
frame rate. The live preview follows the drag in place instead of sitting still
until release, and settles authoritatively when the drag ends. -
Weather widgets: a written-out location now resolves. A location set as
plain text (e.g. by an MCP agent, or"South Morang"/"Paris, FR"/
"-37.65,145.09") is geocoded server-side instead of being ignored, so the
preview and the pushed frame both show weather for that place and echo its
name. Preview and push share one resolver, so they can no longer disagree. A
location that genuinely can't be resolved now surfaces an error for the place
you asked for rather than silently falling back to the sample city. -
Canvas: one fetch per widget. Several data primitives bound to the same
widget (temperature, humidity, wind off one weather source) now share a single
data fetch per render instead of each fetching independently. -
Canvas theme background. The editor and the pushed frame disagreed on an
unset background (one showed black), and neither loaded user / community theme
CSS the grid pages resolve; both now agree and fall back to the theme paper
colour. -
Canvas editor: external swaps remount cleanly. When the open document is
replaced by an external save, the editor now forces a full remount so stale
element nodes can't linger. -
Canvas editor: layer names. Decoration, data, and custom-HTML/SVG elements
showed as "Empty" in the Layers panel; they now carry meaningful names. -
Canvas editor: icon key format. The icon primitive now accepts both
star
andph-star(matching the format widget icon fields use). -
Canvas editor: data primitives. A new element that binds any widget's data
field (by dotted path) to a scalable text, number, or graph (line / bar /
sparkline via Chart.js). Pick a source widget, configure it, choose a field
from the introspected data, and style it. Renders identically in the editor
and the pushed frame. -
Canvas editor: custom HTML element. Author a "mini widget" from HTML + CSS,
rendered in a sandboxed iframe (no scripts, no network). Set it in the editor
or over MCP. -
Canvas editor: partially off-canvas elements. Elements may sit past the
panel edge (they clip cleanly at render), keeping a sliver on-canvas so they
stay grabbable. -
Canvas editor: Shift to keep aspect ratio. Hold Shift while dragging a
corner handle to resize proportionally. -
Live canvas updates in the editor. The freeform canvas editor now reflects
external saves without a reload, so you can watch an MCP agent build a
dashboard in real time. It follows changes silently while you're not editing;
if you have unsaved local edits it shows a non-destructive "changed externally"
reload prompt instead of clobbering them. Backed by a per-canvas Server-Sent
Events stream and a content rev that distinguishes your own saves from
external ones. -
MCP server (build dashboards with AI). An optional Model Context Protocol
server lets an agent (Claude Desktop / Claude Code, etc.) build freeform canvas
dashboards: it lists your widgets and devices, lays out a canvas, renders a
preview to check its work, and pushes to a panel. Enable it under Settings →
System → MCP (off by default), then run the
tesserae-mcpbridge on your agent's
machine. The/api/mcpsurface is reachable from loopback without a token, or
with a generated token for a remote agent; writes only touch canvas dashboards,
pushing is always explicit, and agent-created pages are flagged in the
Dashboards list. The stdio bridge is a separate package (installed where the
agent runs), so Tesserae core stays free of themcpdependency. See the docs. -
Dark mode in the canvas editor. The freeform canvas editor, a standalone
page that doesn't extend the admin shell, now follows the shell's persisted
light/dark choice (applied before paint, so no flash) and carries its own
toolbar toggle. Its overlays (the experimental disclaimer and small-screen
block-out) go dark with it. -
Canvas editor guardrails. The freeform canvas editor gains an explicit
Save button (also Cmd/Ctrl+S) and a back-to-dashboards link in its toolbar,
a first-open notice that the editor is experimental with a link to file bug
reports, and a small-screen block-out that explains phone editing is not
supported and offers a way back.
Changed
- Removed the usage survey from Settings → About. The aggregate heartbeat
now covers the "how is Tesserae used" question the survey stood in for.
Fixed
-
Dark-mode gaps across the admin UI. Swept hardcoded light colours onto
the shared token palette so they adapt in dark mode: the Devices card palette
(previously light in both themes), Settings chips / segmented controls /
hint and warning bands, and page ledes. The always-dark save bar keeps light
text in both themes. -
Create-dashboard button sat low. The button on the Dashboards create row
now centres with the name field and layout picker instead of hugging the
bottom edge. -
First-run consent step could be skipped. The online-features opt-in ran
after the dashboard step, whose Edit and freeform actions hand off to the
editor and let someone leave the wizard before the consent screen was ever
shown. It now runs before the dashboard step, which becomes the final Finish. -
Online features toggle showed a raw checkbox beside the styled switch.
The form now carriesfield--switch, which scopes the checkbox-hide CSS, so
only the switch renders.
Added
-
Online features (opt-in, asked at first-run). Tesserae can report an
anonymous, aggregate install count toapi.tesserae.inkwhen you add a
widget from the marketplace, and show the count on the Browse cards. Each
request may include your install's random ID, the widget id, and the
running version; a coarse country is derived from the request IP and
the IP is then discarded. No account, no personal data, no IP addresses
or User-Agent strings are stored. It is off by default: the first-run
wizard asks once, and a single switch at Settings → System → Online
features governs it and the app + device-firmware update checks.
Leaving it off means the app never contactsapi.tesserae.ink. This
replaces the separate firmware-update opt-in. Never enabled in CI /
Codespaces / dev containers. Install pings surface on the/events
timeline under the telemetry filter. -
Daily heartbeat. With Online features on, Tesserae sends a once-a-day
best-effort heartbeat toapi.tesserae.inkcarrying only low-cardinality,
aggregate facts about the install (version, OS family, CPU arch, Python
minor, deployment kind, transport, a bucketed device count, the set of
registered device kinds, and a Home Assistant boolean). The server stores
only the day, not a timestamp, and dedupes to one per install per day, so
the cadence can't become an activity trace. No personal data, no exact
counts, no IP stored. Also gated by Settings → System → Online features;
the payload is documented on the privacy page.
Fixed
-
Reordered multi-select entries survive a save. The editor's
multi-select picker (Home Assistant entities / sensors) rendered its
options in the plugin's defaultchoices()order and only toggled
which were checked, so a drag-reordered selection was never redrawn in
the saved order. Saving any cell rewrites every cell's form, so an
unrelated change like switching the dashboard theme re-submitted the
entities in the default order and wiped the arrangement. The picker
now renders checked options first, in the saved order, then the rest,
so the order round-trips. (#94) -
Unbound dashboard no longer leaks onto a bound device's panel.
Sending a dashboard bound to no device fanned out to every renderer,
including the per-device clone renderers of devices bound to other
dashboards. That overwrote the bound device's latest-render entry, so
the device painted the unbound dashboard on its next
/api/v1/device/<id>/framepoll. Hard to catch because it only
showed if the client happened to poll in the small window after the
unbound push. Unbound / virtual-panel pushes now skip per-device
clone renderers; only base renderers fan out (legacy single-head /
retained MQTT topic still works). Bound devices only ever receive
frames from pushes that target them (#83).