Added
- A
/discoverclaim says when the server kept a different device id. The MAC is the
identity on that path, so announcing a newdevice_idfor an already-registered MAC hands
back the stored id and its token rather than renaming anything. That's what lets a re-flashed
board re-acquire, but the response never said so, and a client that missed the echoed id kept
talking about a device the server doesn't have. The claim response now carries
device_id_changed(plusannounced_device_idwhen it did), sends
X-Tesserae-Device-Id/X-Tesserae-Device-Id-Changedheaders so a client can branch without
parsing JSON, and logs the mismatch at WARNING. Still a200: the device is paired and its
token works. Both headers are sent on every claim, so an absent header means an older server
rather than "unchanged". (#239)
[0.317.0], 2026-08-18
Added
- Companion can securely register a physically nearby display during
Bluetooth setup. Authenticated app sessions withdevice_setup:writemay
mint a short-lived, single-use firmware registration code through
POST /api/app/v1/device-pairings. Firmware still redeems that code through
the existing device registration endpoint, so the app never receives a
display token. Each Companion session keeps at most one unredeemed code;
requesting another invalidates the previous one. Existing Companion sessions
require an explicit operator grant; new pairings include the setup permission.
[0.316.0], 2026-08-18
Added
- A dashboard's settings open in the composer, in the same slide-out the
source config uses, so the values it asks for can be changed without leaving
the canvas. The fields are the same ones the standalone settings page renders,
and saving re-reads the dashboard from the server rather than trusting the
editor's in-memory copy, so the rewritten element options show up immediately
instead of arriving as an "changed externally" prompt.
Fixed
- The Dashboards list stays aligned when a dashboard has settings. The row's
actions are anchored to the right, so a button that only some rows carried
shifted Edit and Push sideways on those rows. Every row now reserves the slot
and only the rows with settings fill it.
[0.315.1], 2026-08-18
Fixed
- Agents are told about a dashboard's settings surface. The MCP handshake
instructions now cover declaringinputsand thatpatch_canvasaccepts them
on their own, so the one-call path for declaring settings after the elements
are placed is discoverable. The bridge serves per-tool descriptions from its
own package, so this had to reach agents through the server-fetched
instructions rather than a tool description.
[0.315.0], 2026-08-18
Added
- A freeform dashboard can carry its own settings page. A canvas declares
the values it asks for (canvas.inputs), and/pages/canvas/c/<id>/configure
renders one field per declared setting, pre-filled from what the dashboard is
currently rendering. An agent building a dashboard over MCP declares these as
it goes, so whoever inherits it changes the API endpoint or the postcode on a
settings page instead of hunting through per-element option drawers. A
dashboard that never declared any can derive them: Suggest settings runs the
Share flow's own analysis, so whatever would be redacted and asked of an
installer becomes a setting here. The declaration is the same shape the
template format already used, so a dashboard configured locally asks an
installer exactly what its author was asked.
Fixed
- A sensitive option that has to stay readable no longer renders as a
password box. Thesecretflag on a widget option was answering two
questions at once: keep this out of the render context and off the
marketplace, and hide it from the person editing it. Those diverge on a URL,
which is install-specific and can carry a key in its query string yet cannot
be maintained when masked, so masking moves to its ownmaskkey that
defaults tosecret. The REST / JSON service's URL opts out and is editable
again (#237).
[0.314.0], 2026-08-18
Added
- Apple Health seven-day summaries can now be published through Companion.
Servers advertise the opt-inhealth.summarysource, strictly validate its
bounded Activity, Sleep, and Workout sections, retain only the latest
publisher-isolated snapshot until expiry, and emit source-wide semantic
refresh events without putting raw Health values in logs, API errors, or
backups.
Fixed
- Prioritised multi-select lists can actually be reordered. Checked rows
now expose a drag handle that works with a mouse, touch, or pen, plus arrow,
Home, and End keys for precise accessible movement. The new DOM order is
submitted immediately, so priority-based widgets such as Album Art evaluate
Home Assistant entities in the order shown by the editor. Reordering keeps
the handle focused across the move, so arrow keys step an entry more than
once and ticking a row doesn't drop keyboard focus to the page.
[0.313.0], 2026-08-17
Added
-
Custom request headers on webpage renders, so a dashboard behind a bearer
token or an API key can be put on a panel without making it public (#234).
Available on the Webpage widget, on Send → Webpage, and on
POST /api/app/v1/webpages. The shape is a JSON object, matching the
headersoptionrest_servicealready uses rather than inventing a second
convention:{"Authorization": "Bearer …", "X-API-Key": "…"}User-Agentis accepted and becomes the browser identity for that capture
rather than staying a header, so a site that checksnavigator.userAgent
client-side sees the same value that went on the wire. That covers services
which reject unknown clients, though as noted in #178 it will not get past
protections that also inspect TLS fingerprints.Headers are scoped to the target URL's own origin. They are attached by
Chromium's request interceptor, not set on the browser context, because a
context header goes to every origin the page touches: on a dashboard render
that is every other widget's upstream. Origin scoping means a credential
cannot reach a third-party subresource, another cell's service, or wherever a
redirect points next, and it makes that a property of the mechanism rather
than a rule to remember. The SSRF block decision still runs first, so a host
the guard refuses is never handed headers on the way out.Values are never logged, never written to History, and never included in
error messages; the log line records how many headers and which names. On the
Send page the field is not remembered between pushes. Transport and
browser-managed headers (Host,Content-Length,Connection,
Transfer-Encoding,Cookie,Proxy-*,Sec-*, and others) are refused,
as is any value containing a control character, since a newline would be
header injection.One limitation worth knowing: headers cannot reach the live preview in your
browser, only the server-side render. A webpage cell may show a 401 in the
editor and render correctly on the panel.
Fixed
- Secret cell options are no longer written into the composed page.
compose.htmlserialises every cell's options into adata-options
attribute for client-side widgets to read, without regard for the manifest's
secretflag, so a credential stored on a cell was readable in the composed
DOM. Reachable only over loopback, and template export already redacted these
before sharing, so nothing could leave an install; but it put a token
somewhere it had no reason to be, and anything reading page source (render
reports, diagnostics) would capture it. Secret-flagged options are now
dropped before the payload reaches the browser. The server-side data fetch
runs first and still sees the full option set, sorest_serviceis
unaffected.
[0.312.1], 2026-08-17
Changed
- App-only Docker upgrades download much smaller changed layers. System
packages and Python runtime dependencies are installed before the frequently
changing Tesserae source, and tagged builds can fall back to themainbuild
cache, so unchanged large layers can be reused when the cache is available.
[0.312.0], 2026-08-17
Added
-
Edge handling now reaches CircuitPython clients. The
circuitpython_pngandcircuitpython_bmprenderers quantise on the
server but read only fit, contrast and dither mode, so a palette
profile's "Protect the panel's own colours", "Preserve line-art edges"
and "Smoothing radius" were resolved, handed over, and dropped. All
three apply now, using the same helpers and the same meaning as the
.binpackers. Each is a no-op at its default, so a display that has
never opened that block gets a byte-identical file.Tolerance behaves differently here and the difference is worth knowing.
This path quantises against the gamut's nominal palette, so panel
black is#000000rather than a measured#1F2226, and a photographic
near-black sky sits further from it than it would after the calibrated
pre-pass. Where the.binpath wants single digits on photo content,
this one wants 32 to 48 to catch the same pixels.The calibrated palette itself is still not applied on this path: the
wire palette is a per-gamut contract with the client, and changing it
would repaint every existing CircuitPython panel. That stays a
follow-up, and wants to be opt-in per display when it lands.
Fixed
-
Edge-handling settings were discarded on every edit after the first.
The tone editor forks a bundled preset on first tweak and edits the fork
in place afterwards. The fork path wrote the submitted edge settings; the
edit-in-place path wrote the base profile's, so "Protect the panel's own
colours", "Preserve line-art edges" and "Smoothing radius" took whatever
value they were forked with and then silently ignored every later change.
The only way to move one was to reset the display back to the bundled
preset and fork again. Tone and dither were unaffected, which is why the
editor looked like it was saving. -
The tone editor showed two identical Save buttons. The inline button
and the sticky save bar are meant to be alternative affordances, but the
bar is sticky rather than fixed, so its natural position is a few pixels
under the inline button and both were on screen together the moment a
slider moved. The inline button now hands over to the bar while the form
is dirty, so exactly one Save is visible at a time.
[0.311.0], 2026-08-17
Added
-
A per-display timeline of the next visible updates, at
GET /api/app/v1/devices/{device_id}/upcomingbehind the
device_timelinecapability. It answers what is expected to change or
repaint one panel next, when, and why: Lineup advances, Keep Fresh
re-renders, and Home Return, each with the dashboard it lands on and
whether the timing is scheduled, conditional, or an estimate.The projection replays the running engine's own gates rather than
walking the stored records, because everything that decides whether an
update actually reaches the glass lives in the engine: the dwell grid
and its manual overrides, minimum hold measured window to window,
per-record cooldowns, daily backfill suppression, Smart Sync, quiet
hours, and which panel an unbound record resolves to. Anything landing
inside a display's quiet hours is left out, since it repaints nothing,
and records due in the same tick are coalesced to the one the panel is
left showing.Dashboard and widget refresh timings are deliberately not included.
Refresh is becoming event-driven, so covering only its timed half would
make the timeline quietly incomplete; thecausevalues are reserved so
adding them later is additive. Manual sends, webhooks, and Home
Assistant events are not predicted at all. -
current_frame_aton the same response, so a countdown has a start
as well as an end. For a REST display it is when the server handed over
the frame the panel now holds, taken from the delivery-side snapshot
already persisted next to the served digest, so it survives a restart;
for other transports it is when the current frame was published. It is
never a claim that the panel finished repainting, and is null when no
baseline can be established rather than guessed at.
[0.309.1], 2026-08-16
Fixed
- Portrait pictures arrived sideways on mono and greyscale ESP32
panels.esp32_bw_bin,esp32_gray_binandesp32_gray2_bindecided
the quarter turn by comparing the source image's aspect with the
firmware's row stride, so any picture taller than it is wide was turned
90° on a landscape panel, whether or not the panel wanted a turn. A turn
belongs to the panel: the upload is now fitted to the display's
composition first, and rotated only when the composition and the
hardware stride actually disagree, which is whatesp32_binand the
CircuitPython path already did. Dashboards were unaffected, they arrive
composition-sized.
[0.309.0], 2026-08-16
Fixed
- Phone photos landed on their side on Pi panels. A camera stores a
landscape pixel buffer plus an EXIF orientation tag rather than rotating
the pixels. The.binrenderers apply that tag inside their own fit,
butpi_pngfits client-side and never called it, so the tag was
dropped on re-encode and the panel painted the photo turned a quarter.
Orientation is now settled once when the image is taken in, so every
renderer sees upright pixels and the History thumbnail matches what the
panel paints.
Added
- A rotation control for pushed images, on the Send page and as
rotateonPOST /api/v1/push/image.autoturns the image a quarter
when its aspect is the opposite of the panel's, so a portrait photo
fills a landscape panel instead of arriving as a letterboxed strip;
90/180/270turn it clockwise regardless. Unset keeps the
orientation as shot, since an image carrying text is worse rotated than
letterboxed and nothing here can tell the two apart. A panel that is
mounted the other way around still wants the display's own Rotation
setting, which dashboards follow too.
[0.308.1], 2026-08-16
Fixed
- Uneven gaps between the cards on the Stats page. Three different
things were spacing the page: the tiles and sponsor card carried their
own bottom margins, adjacent section cards get 16px from a global rule,
and that rule stops applying the moment a wrapper (the two-column grid)
sits between two cards, which left some seams at zero. The page now
stacks with a single 16px flex gap and resets the parts that were
contributing their own, including the adjacent-card rule that outranks a
plain reset.
[0.308.0], 2026-08-16
Added
- One sponsorship ask, on the Stats page, after a milestone. A card
appears once an install has been running a year or has painted 10,000
frames, on the page someone opened to look at what Tesserae has done for
them. Fleet size is deliberately not a trigger: panel count can't tell a
hobbyist with secondhand hardware from a business. Dismissal is
permanent and silent, with no "remind me later", and the flag lives in
settings rather than the stats store so deleting the counters can't
bring the card back. Nothing is gated, delayed, or degraded either way,
the footer sponsor link is unchanged, and none of this is ever rendered
to a panel.
[0.307.0], 2026-08-16
Added
- A Stats page, counted and kept on your own server. The event log is
capped and rolls over within days, so until now nothing could answer how
much work an install actually does./statsnow shows pushes per day
grouped by what asked for them, frames painted per display, check-ins,
render times, outcomes, and a snapshot of what the install is made of.
Counters are aggregated as events happen intodata/core/stats.db: one
row per date, metric, and dimension, with no URLs, dashboard titles,
push contents, or clock times finer than the day. Nothing reads that
file except the page and the export button, and the page carries the
controls to prove it: export the whole store as JSON, pause collecting,
or delete every counter. Documented under Privacy.
[0.306.0], 2026-08-16
Added
- Flat backgrounds can keep the panel's own colours instead of
dithering (#227). A pixel that already sits on one of the panel's
colours generates no dither error of its own, but it still receives its
neighbours', so a background beside saturated content gets pushed off
its colour and speckles. The palette profile's edge section gains
"Protect the panel's own colours", a tolerance in sRGB distance: above
0, those pixels keep their colour, and on the error-diffusion dithers
they stop passing error onward so a protected region can't collect
error on one side and dump it out the other. The mask is built on the
tone-mapped source, since exposure, the S-curve and the calibrated
pre-pass all move the pixels it means to protect. Wired for the colour
packers and for black-and-white panels, left out of the greyscale ramp
where nearly every pixel is close to some level and the same guard
would flatten photographs. Off by default, and off renders exactly as
before.
[0.305.0], 2026-08-16
Fixed
- Saving an album from the Gallery kept only what that form can edit. The
album form builds a whole record from its own fields, so an explicit frame
order, a disabled album, or an interval that isn't a round number of minutes
was dropped the next time someone renamed the album or changed its displays.
All three come from surfaces the form knows nothing about (the MCP tools
today, the Companion API next), and none of them are the form's to discard.
A save now carries them across, leaves the interval alone unless the minutes
field actually changed, and says an album is disabled instead of reporting a
success about displays it isn't playing on.
[0.304.0], 2026-08-16
Added
- Push a single image from a script (#231).
POST /api/v1/push/image
sends one image straight to named displays, using the same webhook token
asPOST /api/v1/push. Until now the only scriptable image push was the
Companion API, which is a paired-client contract and isn't in the public
OpenAPI spec, so the obvious place to look didn't have it. Multipart
upload, requireddevice_ids, optionalfitand Historylabel,
per-device quiet hours honoured (with anoverride_quiet_hoursopt-out for
images that are worth waking a panel for), and a response that names each
display undersent,quietorfailedrather than leaving a partial
result to be inferred from a count. Documented in the spec and in the
OpenAPI guide with a curl recipe.
Fixed
- The webhook token's location in the docs was wrong. The API spec and the
OpenAPI guide sent people to Settings → Server → App; the token has always
lived under Settings → System → Webhook.
[0.303.0], 2026-08-15
Changed
- A display plays one offline album at a time, and says so. Binding a
display that already plays another album used to save cleanly and then
resolve to whichever album was found first, so the other silently never
synced. The album form now refuses the save, names the album holding each
display, and offers a "take over" tick to move them deliberately. A
displaced album keeps its remaining displays rather than being deleted. - A display that reports a frame cache says how much it holds. Its
advertised capacity and frame cap now ride along with the capability state
in the Companion API, so a client can tell whether a folder fits without
guessing from the model name. Absent when the display didn't report them.
[0.302.2], 2026-08-15
Fixed
- Deleting a device takes its own History, not the dashboard's (#229). A
push records the dashboard it came from and the displays it reached; the
wipe only looked at the dashboard. Deleting one display off a shared
dashboard left its pushes behind, and deleting the last display then removed
every row for that dashboard, including the first display's. Rows delivered
only to the deleted display are now removed with it, rows shared with
another display survive with the dead one dropped from their delivery chips,
and the delete dialog counts the two separately. - A dashboard bound to the same display twice is listed once (#229). A
repeated id in a dashboard's binding listed it twice under that display on
the Dashboards page, read as "Push to 2 linked displays", and pushed the
same frame to the same panel twice. Bindings now collapse repeats when a
dashboard is loaded or saved, so existing dashboards heal on their own.
[0.302.1], 2026-08-15
Fixed
- Community templates, install and uninstall work again under the Home
Assistant App. The merged Browse catalog (0.297.0) prefixed its request
URLs with the ingress path a second time, because the ones it reads from the
page already carry it. Home Assistant answered the doubled path with a 404,
which the page reported as "Template catalog unreachable right now"; install
and uninstall failed the same way. Requests now go through one idempotent
prefix helper, and a test pins both halves of the contract.
[0.302.0], 2026-08-15
Changed
- Browse catalog rows lead with the screenshot. Rows are roomier by
default: a 300×180 preview instead of 152×95, a larger title, and three lines
of description rather than two. A Cozy / Compact control in the toolbar
switches back to a scanning density, and the choice is remembered per browser. - A row's screenshot enlarges in place. Clicking a row thumbnail opens the
full-size preview directly, the same lightbox the detail sheet uses, instead
of only being reachable by opening the sheet first.
[0.301.1], 2026-08-15
Fixed
- Deleting a device no longer strands the dashboards it shared (#229).
A deleted device's id stayed on every dashboard it was bound to alongside
another device, and the delete flow decided what a device owned by counting
ids rather than live devices. A dashboard bound to two panels therefore kept
a dead id after the first was deleted, which made it look permanently shared:
deleting the second panel never offered to remove it, and no later delete
could either. Ownership is now judged on devices that still exist, so the last
real device to go takes the dashboard with it and dashboards already carrying
a dead id heal on the next delete. Wiping a device now also drops it from
dashboards it shared, and the Dashboards list counts live bindings, so a row
can no longer read "unbound" and "Push to 2" at once. Panel view resolves its
gamut from the first live binding for the same reason.
[0.301.0], 2026-08-14
Fixed
- Touch controls are drawn in the render unless the panel draws them
itself (#228). The button, switch, slider and stepper elements reserved a
blank rect in every composition, because a touch panel's firmware draws the
control into that rect itself. On every other target, a display-only panel, a
preview, a dashboard with no device bound, nothing ever filled it, so a
control placed in the editor rendered as an invisible hole. The reserve is
now kept only for panels advertising protocol v2; everywhere else the server
paints the control. The editor and the renderer share one painter, so the
preview and the composition can't drift, andrender_reportgained
touch_primitives, naming the bound devices that draw each control
on-device.
[0.300.4], 2026-08-14
Changed
- Companion responses are validated against contract 0.12. The vendored
copy of the app contract was several versions behind, so Lineups, the
session endpoint and the whole Gallery surface were checked against local
transcriptions of shapes rather than the published ones. The refreshed copy
carries every value we had been serving ahead of it, so the local errata
list is now empty, and the Gallery, Lineup, session and capability-support
fixtures are exercised directly.
[0.300.3], 2026-08-14
Fixed
- BMP and GIF images are visible over the Companion API (#117). The
Gallery accepts both and the Web UI serves them unchanged, but the client
contract's media types cover neither, so a folder of pre-dithered BMPs read
as empty from the phone. Those images are now served as a full-size PNG
rendition, generated on first request and cached beside the thumbnails. The
stored file is never rewritten, and the reported filename, size and cache
validator describe what is actually served.
[0.300.2], 2026-08-14
Fixed
- A body that isn't JSON says so.
/api/v1/device/discoverand
/registerfell back to an empty object when the body failed to parse, so
the rejection blamed the first field they couldn't find (device_id is required) rather than the parse. A client that formats a Python repr
(single quotes, bareNone) instead of serialising JSON now getsbody is not valid JSON. An empty body still names the missing field.
[0.300.1], 2026-08-14
Fixed
- A placeholder MAC can no longer claim another device's token (#226).
/discovermatches the announced MAC against registered instances, and it
took any non-empty string at face value. A client that formatted its own
null into the body sent"None", so two such clients resolved to one
instance and the second was handed the first one's access token; an
all-zero or broadcast MAC from a radio that wasn't up yet did the same.
null,"None",00:00:00:00:00:00,ff:ff:ff:ff:ff:ffand similar
stand-ins now read as no MAC at all:/discoverrejects them with400,
/registerpairs but stores no MAC, and an instance that already carries
one from an earlier install stops being claimable. No migration; the
stored value stays where it is and simply stops matching.
[0.300.0], 2026-08-14
Added
- Companion API can browse and add to the photo Gallery (#225). Six
routes under/api/app/v1/gallery/list folders, create an internal
folder, read a folder's images, upload one image, and serve a thumbnail or
the stored content. Folders and images are addressed by opaque server-minted
ids rather than filenames or host paths, external folders are read-only, and
gallery:read+gallery:writeride along with the pairing role.
Uploads are normalised on the way in: all EXIF is dropped (location
included), source orientation is baked into the pixels, and the ICC profile
is preserved. Sending a Gallery photo still goes through the existing
POST /imagesflow, so there is one send path rather than two. - Displays report which protocol capabilities they support. Each device in
the Companion API's list carries acapability_supportmap with a
supported/unsupported/unknownstate per capability, computed from
the display's own heartbeats. A display that has not checked in recently
enough to tell readsunknownrather than being reported as incapable.
Fixed
- The offline photo album form no longer offers displays that can't play
one. It listed every registered display whose kind resolved, so an album
could be bound to a panel with no frame cache, save cleanly, and then never
receive anything. Displays that reported no frame cache are now disabled
with the reason shown, ones that have not checked in yet are marked as
unconfirmed but still bindable, and a submitted target that reported no
frame cache is dropped rather than saved.
[0.299.0], 2026-08-14
Fixed
/api/v1/device/discoverrejects an announce with no MAC (#226). The
claim path matches the firmware's MAC against a registered instance, so a
MAC-less announce landed in the Discovered strip, registered cleanly, and
then polled forever onregistered: falsewith nothing in the response
saying why. It now returns400naming the missing field and pointing at
the pairing-code path for clients that have no MAC to report.
Changed
- Device cards show the device id and the MAC. Connection details carry
both, and a REST device with no stored MAC says so rather than omitting the
row, so a stuck pairing or a pair of clients announcing the same MAC is
visible from the card.
[0.298.0], 2026-08-14
Changed
- Browse catalog rows carry a larger preview. The row thumbnail goes from
104x65 to 152x95 and the description runs to two clamped lines, so a
screenshot is legible in the list rather than only in the sheet. The sheet's
own preview grows to 264px and now opens in a lightbox on click, with the
thumbnail strip choosing which shot opens, arrow keys stepping through an
entry's screenshots, and Escape closing the lightbox before the sheet.
[0.297.0], 2026-08-14
Changed
- Browse community widgets and Community templates are one Browse catalog
page. Widgets, themes and templates now share a single list of dense rows
with a persistent filter rail (type with live counts, status, category,
panel fit), a ranked search, a sort menu, and a detail sheet holding the
long description, the plugin folders and the install detail that used to
make every card tall. Selecting Templates switches the list into the
resolution-grouped sections the standalone templates page was built around;
/plugins/templates/redirects into it. Install and uninstall post in
place, so the list keeps its filters, sort position and scroll, and the
topbar "Restart required" button lights up as soon as the first change
lands.
[0.296.1], 2026-08-13
Fixed
- Release notes cover the whole span a release ships. Every version bump
writes its own CHANGELOG section, and the release workflow extracted only
the section matching the tag, stopping at the next## [. A release cut
after fifty bumps therefore published the last bump's entry and dropped
everything else, which is what v0.296.0 did on first publish. The workflow
now reads down to the previous release tag's section, keeping the
intermediate version headers, and falls back to the single-section
behaviour when there's no earlier tag to stop at.