The panel now pairs, fetches its image and files its logs against this
application rather than against the TRMNL cloud.
Four endpoints: /api/setup issues a token on first contact,
/api/display hands back an image and a wake interval, /api/log stores
firmware diagnostics, and /api/device/image/<hash> serves the bytes.
The wake interval is where freshness and battery are traded off. In BYOS
nothing can be pushed: the device sleeps, wakes, asks and sleeps again.
So the interval is short while the shop trades and long overnight, and
it is shortened further whenever a change of state falls inside it —
the door opening in twenty minutes means waking in twenty-one,
whatever the base interval says.
The image filename is the hash of its own bytes. The firmware skips the
redraw when the name is unchanged, which is the whole battery strategy,
and the URL is immutable, unguessable and safe to cache forever. Two
integration tests pin this: unchanged data must yield the same filename
and store one row, changed hours must yield a different one.
MAC addresses are normalised before use. They are a primary key here,
and firmwares are inconsistent about case and separators; without this a
panel could register twice by capitalising itself differently. Header
names are read in both the hyphen and underscore spellings for the same
reason — the TRMNL docs and the Seeed sources disagree, and being
liberal costs nothing while being wrong costs a blank shop window.
Pairing is deliberately made to survive a rendering failure. The token
is issued once and only its digest is kept, so a device stranded by a
failed response would be registered yet hold no credential, and unable
to register again. The welcome image is worth far less than that. This
was found by running the flow, not by reading it.
satori, yoga and harfbuzz are marked external: bundling rewrites the
relative path satori uses to load its WebAssembly, and the renderer dies
on a missing hb.wasm.
The integration tests run against a real Postgres, in CI too. Mocking
Prisma here would only prove the mock works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
With the move to BYOS there is no TRMNL cloud to interpret a Liquid
template, so the application draws the screen itself.
The pipeline is satori (flexbox to SVG) then resvg (SVG to pixels) then
a hand-written 1-bit encoder. Chromium was the alternative and was
rejected: half a gigabyte and a real memory appetite in the runtime
image, for a screen of eight blocks. Playwright still handles the E2E
tests, in its own pinned image, never in the application one.
The payoff is testability. The layout is asserted on the element tree
and the geometry on the SVG text, so a rendering regression shows up as
a readable diff instead of a pixel comparison. The BMP and PNG encoders
are verified field by field against their specifications, including an
independent CRC-32 for the PNG: a device rejecting a malformed image is
expensive to debug from a shop window.
Two properties are pinned because the battery depends on them: the same
payload must produce byte-identical output, and changed hours must
produce different output. The filename handed to the device is a hash of
these bytes, and the firmware skips the redraw when it is unchanged.
The fonts are vendored into public/fonts and the logo into public/brand,
both committed. Rendering must not depend on an install tree, a CDN or
the network, or the bytes drift and the panel wakes for nothing.
`npm run screen:preview` writes a real 1-bit image plus a magnified view,
which is where clipping and thin strokes give themselves up. That is how
the week strip was caught clipping and condensed.
`npm run brand` rebuilds the assets: it locates the wordmark band in the
shop logo rather than hard-coding offsets a future revision would break,
and thresholds it with the panel's own encoder. sharp is a devDependency
used only there; nothing at runtime needs it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
Every string the panel shows is computed here, server-side and in both
languages: the status word, the formatted hours, the next-change line,
the week strip and the banner. The renderer downstream only draws, so it
has no access to the schedule and cannot disagree with this file about
what the shop's hours are.
Closing soon is still open and opening soon is still closed: the large
word states the fact and the line under it carries the nuance, which is
the only thing a passer-by can read from the pavement.
A message someone took the trouble to write always wins the banner over
the automatic notice. When its English translation has not landed yet,
the French line shows alone rather than a placeholder.
The snapshot test is the regression guard for the whole display
pipeline: any change to the shape or to a computed string has to be
acknowledged there before it can reach the panel. lib/screen is at 100%
statements and 96% branches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd