github dmellok/tesserae v0.436.1

3 hours ago

Added

  • Mosaic looks for code elements. Mosaic
    is an open design system for e-ink dashboards, now vendored and optional. A code element that
    names a data-look, uses Mosaic's classes or calls Mosaic. gets its core, that look, its
    script, the look's fonts and its icons. Five looks: bauhaus, almanac, signal, pixel and
    os7 (classic Macintosh System 7). See docs/mosaic.md.

  • Device log upload and failure reporting. Native firmware that
    advertises logs: {schema: 1} on its status can now send its serial log
    to the server. The device page gains a Logs section (only for such panels)
    to collect logs on the next 1, 5 or 20 wakes, view an upload inline or
    download it, and see the last detected failure. The status response asks
    for an upload with a top-level logs: {upload: true}, never inside
    config. A status carrying a new diag report (failed paint, brownout,
    panic or watchdog reset) writes an error row to Events, and by default
    pulls the log up on that same wake; the "Collect a device's log after a
    failed paint" switch in Settings › Server turns that off. POST /log with
    Content-Type: text/plain stores a batch (64 KB cap, 413 above) under
    data/core/device_logs/<device_id>/, keeping the newest 20 uploads up to
    1 MB per device; JSON log lines keep their single Events row. Deleting the
    device deletes its logs.

  • Touch off during quiet hours (#327). A new quiet-hours switch, off by
    default, on the app-level window and on a device's override (touch panels
    only). Inside the window, the REST status response reports
    touch_enabled: false for that wake, so firmware v1.40.0 or later puts the
    touch controller to sleep instead of keeping it awake for a tap. The saved
    setting is untouched, so the first wake after the window turns touch back
    on. Always-on panels and the relay config mailbox are unaffected.

  • The relay can cap frames per panel per day. A relay configured with
    FRAME_DAILY_LIMIT answers a frame upload past the cap with 429 rate_limited and a Retry-After of the next 00:00 UTC; the upload is not
    stored and the panel keeps its last frame. The count rides the frame pointer
    the Worker already reads and writes, so the cap adds no storage operations.
    Unset means unlimited, so self-hosted relays are unaffected. Tesserae holds
    uploads for a capped panel until the reset instead of retrying on every
    render, and Settings → Cloud relay shows which panels are paused, when they
    resume, a suggestion to refresh them less often, and a link to self-hosting
    the relay. The contract gains the
    rate_limited code.

  • Xteink X4 Pro in 4-level grayscale (#335). xteink_x4_pro_gray is the
    server half of the CrossInk x4-pro build, which ships with grayscale on
    and already announced that id: same panel block as xteink_x4_pro, with
    the renderer overridden to esp32_gray2_bin (96000-byte 2-bpp frame).
    Confirmed on real hardware by the contributor, which also confirms the
    mono entry's panel geometry and portrait_flipped.

  • ha_entities can hide the last-updated badge (#336). A new
    "Show last updated" toggle (default on) drops the 'just now' / 'Nm ago'
    badge, the changed-row wash and the changed count in the title row for
    compact layouts. ha_entities 0.8.0.

  • list_widgets can ask for less too (#257). Bridge 0.18.0 passes q and
    fields through to the catalog route, and the tool text explains when to use
    them. EXPECTED_VERSION follows, so Settings → System → MCP offers the
    upgrade.

  • /api/mcp/catalog can be asked for less (#257). ?q= keeps the
    widgets whose key, name or full description contains every term,
    case-insensitively and ignoring punctuation, and ?fields= trims each entry
    to the named fields (key is always kept; an unknown or empty field list is
    a 400 that lists the valid ones under valid_fields).
    A filtered response carries a filter block with how many of the catalog's
    widgets matched, so an empty list reads as "nothing matched". Without either
    parameter the response is unchanged.

  • KOReader e-readers as a device kind. koreader_client pairs a jailbroken
    Kindle, a Kobo, or any other e-reader running KOReader with the new
    Tesserae plugin over the REST
    device protocol. The reader reports its own screen size at pairing and takes
    16-level greyscale frames from the esp32_gray_bin packer, decoded on the
    device, with If-None-Match so an unchanged dashboard costs no repaint.
    Hardware entries for six Kindle and four Kobo models fix the default panel
    and store listing; all are awaiting a confirmed test on real hardware. The
    Kindle quickstart and the clients page now lead with this plugin; KOReader's
    TRMNL plugin remains documented as the 1-bit alternative.

Fixed

  • Touch buttons on a rotated panel draw upright, and no button goes missing (#343).
    On a panel whose firmware draws its own touch controls (protocol v2), the renderer
    left every button, switch, slider and stepper blank for the device to fill in. Two
    things went wrong. The firmware draws labels and icons in its own framebuffer
    orientation, so on a panel set to 90° or 270° (a portrait-native reTerminal Sticky
    used in landscape) or to a flipped mount they came out sideways or upside down and
    spilled out of the button. Buttons on such a turned frame now stay out of the touch
    spec and the server paints them; taps still reach them through their action, as on
    any other panel. And a primitive the spec skips, such as a button with no action,
    was blanked but never drawn, so it disappeared from the panel; the renderer now
    blanks only what the spec carries. Switches, sliders and steppers on a turned frame
    are still drawn by the device, because a server-painted one would not respond to a
    tap; their labels still read sideways until the firmware learns about rotation.

  • No "Switch to MQTT" on REST-only hardware (#341). The device card
    offered the transport flip on every instance, but the flip only rewrites
    the manifest; nothing is sent to the device. On a reTerminal Sticky, whose
    firmware has spoken REST only since July 2026, taking it left the device
    polling REST as before while the card badge read "HTTP" and every render
    was published to the broker for nothing. The button is now offered only
    where the client could follow (the generic ESP32 folder kinds and the Pi
    and Pico clients); hardware SKUs on the native firmware, KOReader and
    PicPak get no switch to MQTT, the route refuses a hand-crafted POST, and
    an instance an older version let you switch is put back on REST at
    startup. The confirm dialog and the REST transport page say so.

  • ha_energy "Today vs yesterday" chart is on a clock axis (#339). The
    sparkline binned Home Assistant history by sample count, and a solar
    sensor that sits at 0 W all night has almost no night samples, so the
    night was squeezed to nothing and yesterday's peak could land after
    sunset. Both lines are now calendar days in the panel's timezone, 48
    half-hour slots each, binned by timestamp with a state carried forward
    until it changes. Today's line stops at now and the "now" pip rides its
    last point. The two lines share an x-axis, so the sun peaks line up.

  • Home Assistant history longer than a day comes back whole (#339).
    ha_core asked HA's history endpoint for a start time only, and HA ends
    the period one day after the start when no end_time is given. The
    ha_energy sparkline asks for 48 hours, so it only got the day before
    yesterday into yesterday at the current time; yesterday's line went flat
    from there and today's was a flat carry of that last value. History
    requests now send end_time as now. The HA data service's hours
    option (up to 720) was cut to its oldest 24 hours the same way.

  • The Home Assistant Energy widget uses the configured timezone for its clock (#340).
    Its displayed time and hour-based state now follow Tesserae's app timezone
    instead of the server's local timezone.

  • Typing a value into a slider's number box now counts as a change (#337).
    The box next to every slider (pre-dither contrast, saturation, and the rest
    of the device-page fields) copied its value onto the range input without
    firing an event, and the box itself has no form association, so the save
    bar never appeared and the value was never saved unless the thumb was
    dragged. The range input now re-emits input and change when the box
    is edited.

  • A whole-frame change no longer logs a warning (#337). A gallery photo
    swap or a picture-quality edit legitimately repaints the whole panel, so
    push not diverted to patches for over_budget and
    render_settings_changed now logs at INFO; the other reasons stay at
    WARNING.

  • A TRMNL client's screen is now the buffer its frames are turned onto (#333).
    The trmnl_png, trmnl_png_gray16, and trmnl_png_color renderers rotate
    the finished composition 90° onto the client's reported buffer when the two
    disagree on aspect, so a landscape dashboard on a portrait Kindle running
    KOReader is no longer served at the wrong aspect and squashed by the
    client's scaler. The size a client reports on its first poll (png-width
    / png-height, or Width / Height) is recorded as the panel's native
    buffer and the composition canvas is seeded to match the stored
    orientation; later polls, missing headers, and zero or absurd values leave
    the panel alone. Native dims guessed from the preset table never trigger
    the turn.

  • The first-run wizard no longer loops back to the opt-in step (#330).
    The device step's Continue, Skip and Next buttons still pointed at the
    online-features step from before it moved to second place, so skipping the
    pairing code sent setup back through timezone and transport forever. They
    now go on to the dashboard step, and Welcome's Get started now leads to the
    opt-in step instead of passing over it.

  • An LXC install from cloud-init can update itself again (#328). The
    cloud-init cloned the newest release with --branch <tag> --depth 1, which
    left a detached, shallow checkout that fetched only that one tag, so the
    Edge update check failed with a bare git usage error. The cloud-init now
    clones main in full and moves it back to the release tag. The updater also
    repairs a checkout made the old way on its next check (fetching every branch,
    deepening the history, and moving a detached checkout onto main on an Edge
    update), and the Stable channel now follows only vX.Y.Z release tags, not
    the MCP bridge's mcp-v* tags.

  • The MCP bridge stays responsive during a slow render. Bridge 0.17.1 runs
    each tool on a worker thread instead of the server's one event loop, so a
    render_preview that takes several seconds no longer queues every other tool
    call behind it. Tesserae now reports 0.17.1 as the current bridge.

  • Canvas widgets load assets through Home Assistant Ingress. The Canvas editor
    prefixed widget script imports but left root-relative stylesheet and image URLs
    inside widget shadow roots pointed at the HA frontend. It now prefixes those
    asset URLs after rendering, matching the Send preview.

  • One hung render no longer stops every dashboard from rendering. The warm
    browser renders every dashboard on a single worker. The font wait before the
    screenshot had no time limit, so a font request that never answered could
    hold that worker indefinitely, and every later dashboard push failed at the
    105 s pool deadline with a bare TimeoutError until the app restarted, while
    calibration pages (which skip the browser) still went through. The font wait
    is now capped at 5 s like the image wait. A render whose caller has given up
    is dropped rather than run for nobody, and if the worker is still inside one
    render past that render's deadline, the pool starts a fresh worker and
    Chromium and moves the queued renders to it. The push history now says
    "browser pool gave no result within 105s" instead of a bare TimeoutError.

  • The LXC cloud-init installs the current release again. scripts/cloud-init.yaml
    cloned a tag written into the file, which was last edited at v0.71.2 and had
    gone hundreds of releases stale, so anyone provisioning a container from it
    got a Tesserae from months ago. It now resolves the newest v* tag from the
    repo at boot and clones that, falling back to main if the tag list can't be
    read, so the file cannot go out of date again.

Added

  • The battery chart's data feed now carries millivolts as well as percent.
    /devices/battery/<id>/series.json reported only the percentage, which is
    a lookup against a discharge curve that barely moves through the middle of
    a LiPo's range. Two panels shedding the same charge per day can therefore
    report very different percent per day depending on where they sit on it,
    which makes "percent per day" a poor way to compare one panel against
    another or to chase a drain complaint. The raw reading was already being
    stored on every heartbeat; it is now returned alongside, with the same
    per-device calibration offset applied, and null for firmware that sends a
    percentage only.

  • A panel reports the colours it can dither, not only the inks it prints.
    list_devices has always answered with the raw ink palette, and the
    composing instructions have always said to design in full colour and not
    flatten a layout to those inks. That does not work: the prose is an
    abstraction and the palette is six named hexes, so an agent given both
    reaches for the hexes and the dashboard comes out poster-flat. The mixes
    are now named too, each one pair of inks at one ratio with its hex computed
    from the panel's own palette, so a Spectra 6 panel answers with six inks
    and eighteen blends and a BWRY panel with four and ten. Each carries a
    safe_on, because a dithered colour holds across a card fill and speckles
    on a hairline, which is the rule that decides where it can be used.
    Greyscale and one-bit panels report none, their levels being the whole
    vocabulary already.

Changed

  • A panel's refresh_floor_s is now shown rather than ignored. 41
    hardware profiles list a repaint floor for their glass, and the server read
    the field nowhere, while schema/hardware.schema.json called it a lower
    bound on poll cadence "surfaced in the Settings UI" and
    docs/dev/adding-hardware.md said it was "enforced on the always-on path",
    an enforcement removed in v0.332.0. The "?" help on the device card's
    cadence fields now says what the profile lists, the Companion API reports
    it as panel.refresh_floor_s, and the schema and the hardware guide
    describe the field as the profile's declaration. Nothing gates on it, and
    nothing here promises a repaint rate: the declared values are authored by
    protocol rather than measured, and the reTerminal Sticky lists 60 while its
    own notes put a full paint at 1.2 s.

  • The MCP instructions now cover craft, not just mechanics. An agent was
    told how to place an element and nothing about making the result look
    designed, so dashboards shipped on the renderer's fallback font, on
    whatever icon weight came to hand, with sizes scattered through the markup
    as raw px, and with a week of readings printed as today's number instead of
    drawn. The instructions now name the bundled families by job, state that
    duotone and fill icons survive the dither where the thin weights speckle,
    ask for a type scale declared up front, map data to the chart form that
    suits it, and give the Chart.js overrides that keep a chart legible on
    e-ink. They also record what the code sandbox does not inherit: the page
    theme, style and accent tokens stop at its edge, which was true and written
    down nowhere.

  • A new dashboard is no longer preceded by a read of the existing ones.
    list_pages carried no guidance on when it was for, so the gap was filled
    with a reflexive survey of the operator's other work before any building
    started. The instructions now say to go straight to the build, keeping the
    cases where reading a page is right: a request that points at one, or
    checking a name is free.

  • A button press on a relayed panel now lands inside its awake window.
    A press over the cloud relay is store-and-forward: home only learns of
    it on its next relay poll, then has to render the next rotation step
    and upload it before the panel stops polling and goes back to sleep.
    A cold render of a widget-heavy dashboard was the part of that budget
    that lost the race, and the frame then only showed up at the next timer
    wake, which read as the press doing nothing. Home now keeps the steps
    either side of a relay panel's current one composed while it sleeps,
    refreshed every time a new frame reaches its mailbox, so the answer to
    a press is a promote rather than a render. The warm compositions live
    for the current step's dwell (one minute at least, thirty at most), so
    the page a press brings up is never older than the frame it replaces.

  • A dashboard can declare how often the panel showing it should wake.
    Wake cadence has always been a device property, but the content is what
    knows how often it changes: a once-daily agenda on a five-minute panel
    woke roughly 288 times a day to collect 287 304s (discussion #24). A
    dashboard now carries an optional sleep_interval_s, and a sleeping
    device showing it wakes on that instead of its own interval — longer
    for a daily page, shorter for a transit board. Clamped to the device
    kind's own min/max, because only the device knows what its firmware
    accepts, and skipped entirely for an always-on panel, which is not on
    the sleep grid at all. Set it in Dashboards → Wake, or over MCP with
    PATCH /api/mcp/pages/<id>/canvas; leaving it empty hands the device
    back its own interval, which is what every existing dashboard does.

Fixed

  • A device's firmware line no longer paints over Last seen. The
    Devices table gave firmware a fixed, narrow column, and a client that
    reports free text there rather than a bare version -- a CircuitPython
    client reporting CP client: 0.1.0 / app API: 0.4.0 -- ran straight
    through it and over the column beside it (#325). Firmware now takes
    the flexible column and clips with an ellipsis, with the full string
    on hover; the stacked phone layout wraps it instead. A value that
    isn't a bare version number also stops being given a v prefix, so
    that same client no longer reads as vCP client: ....

  • The device page's section links work in Firefox. Clicking a link
    in the vertical nav scrolled the page to the section and then also
    scrolled the clicked link into view so the phone strip keeps it
    centred. Two scroll requests against the same box is a browser
    coin-flip: Chrome drops the second as a no-op, Firefox honours it and
    cancels the first, so the click did nothing at all (#326). The strip
    now scrolls its own scrollport, which desktop's sticky column never
    needs.

  • An anchored device section lands below the topbar, not under it.
    The sub-nav's sticky offset and the scroll-margin a section comes to
    rest on were both a fixed 16px, measured from the top of the viewport
    rather than from the bottom of the sticky topbar, so clicking a
    section put its heading behind the bar (#326). The bar's own height
    moves: its nav wraps onto a second row between roughly 900px and
    1100px wide, which is where this was reported. Its measured height is
    now published as --t-topbar-h and both offsets derive from it. The
    highlight follows: the scroll-spy probe used a quarter of the viewport
    height, which on a short window sits above the line a clicked section
    lands on, so the pill above the one you clicked lit up instead.

  • Deck navigation now reaches panels paired through the cloud relay.
    Stepping a deck served the new page instantly over REST but left a
    relayed panel on the previous image, because a step that finds its
    frame already warmed swaps it into the live slot without going through
    a push, and uploading to a panel's relay mailbox hangs off the push.
    The swap now tells the delivery listeners, so the selected frame is
    sealed and uploaded like any other. This covers every cached step, not
    just a button press: timed deck advances, the return to the home page,
    touch navigation, the companion app and the Decks page controls were
    all affected. Warming stays silent, and a step back to an identical
    frame still uploads nothing.

  • The NASA APOD widget no longer shows a half-loaded picture. It
    asked for NASA's HD file, which is routinely several megabytes, and
    the renderer only waits a few seconds for the network to settle before
    it screenshots whatever has arrived. It now takes the standard
    ~1000px image, which is larger than any supported panel anyway, and
    falls back to the HD file only when NASA offers nothing else.

Don't miss a new tesserae release

NewReleases is sending notifications on new releases.