# Tesserae renders your dashboard once in Chromium and hands the panel raw bytes

> Tesserae is a self-hosted server for e-ink panels: you compose a tile dashboard in the browser, a headless browser screenshots it at panel resolution, the frame is dithered against the panel's measured palette and packed into native bytes, and thin clients pull it over MQTT or REST. The interesting parts are the version coupling, the example compose file, and what the page admits it does not sandbox.

**dmellok/tesserae** — E-ink dashboard companion. Compose dashboards in a browser, render server-side, and push to e-ink panels over REST or MQTT.

- Repository: https://github.com/dmellok/tesserae
- Website: https://tesserae.ink
- Stars: 824 · Forks: 46
- Language: Python
- License: AGPL-3.0
- Published: 2026-09-10 · Updated: 2026-09-10 · Language: en
- Canonical page: https://hysenlabs.com/projects/dmellok-tesserae

## The panel receives bytes and nothing else to decode

The central decision is that the server pre-renders each frame into the exact bytes the panel controller wants, and the device firmware streams them straight to the panel. The page calls the result dumb firmware, and the reason is concrete: adding a new panel means a board header plus one driver, because the network, power, and provisioning stack never changes. Devices are described as deliberately thin, with no on-device image decoding at all. The same split runs through the whole system, with rendering separated from transport separated from hardware, so one instance can drive every panel in a house, each with its own dashboards, schedules, and rotations. The page's own caption for the hero image states the target plainly: nine panels, one server. A second repository holds the device firmware, which keeps the browser side and the hardware side from sharing a release cycle.

The hardware support table is honest about a second thing. A check mark means the panel has been confirmed on real hardware, either on the author's bench or through a community report, while TBD means a client and hardware profile ship but nobody has confirmed a frame on the physical device yet. The per-renderer bench matrix lives in the documentation rather than the page.

## The editor preview and the panel load the same URL

Dashboards are plain HTML, and the design goal is to make one class of bug impossible. The editor preview and the production render load the same URL, and a headless Chromium driven by Playwright screenshots the composition at panel resolution, so what you saw in the editor is what the panel gets. The page puts the reasoning in a sentence: looked fine in the editor and broke on the panel cannot happen by construction. What happens after the screenshot is device specific. The frame goes through Floyd-Steinberg dithering against the panel's measured colour palette, with the page making the reason for measurement explicit, since Spectra 6 and ACeP panels do not actually produce sRGB primaries, and the calibrated numbers come from a separate tool. The dithered frame is then packed into the byte format that panel wants: packed 4-bit Spectra 6, 1-bit monochrome, or 4-bit greyscale.

## Battery clients skip the download entirely when nothing changed

The two client families are handled differently for a reason tied to power. An always-on client, such as a Pi driving an Inky panel, gets a frame pushed over MQTT the moment a page changes, which costs nothing while the device is plugged in. A battery client, an ESP32 in the page's example, wakes, GETs its frame over REST with an `If-None-Match` header, POSTs battery level and RSSI telemetry, and goes back into deep sleep on a server-driven interval. A 304 on that GET is the optimisation that matters, because it skips both the download and the slow e-ink refresh, and a partial refresh on a panel that draws slowly costs more than the bytes do. Time comes from the HTTP `Date` header rather than a time sync daemon, so there is no SNTP on the device. The panel families named in the diagram are Pi with Inky, ESP32, Kindle, and TRMNL.

## Widget egress is allowlisted at the socket, and the trust model is a pull request

Third-party widgets are the part of the system that could hurt you, and the page is unusually clear about how little is enforced. A widget declares the network hosts it needs in a `requires:` block inside its own `plugin.json`, and the server enforces that allowlist at the socket layer, so an undeclared connection raises `CapabilityDenied` rather than quietly phoning home. Then the page names the limit of that protection: widgets otherwise run in-process, and the trust model is pull request review of the community catalog, not a sandbox. The threat model is documented separately and, in the page's words, includes what it does not catch. So the containment is a network boundary and a review process, and a widget that stays off the network can still read process memory. Community widgets install as pinned release tarballs that are sha256 verified, schema validated, and reviewed, from a catalog in its own repository.

## What leaves your server is one domain, three requests, and opt-in

The licence paragraph makes an unusual promise and then enumerates it. The self-hosted server needs no account and never has to talk to the project, and the only outbound contact named is a single domain, used for update checks, an anonymous install count, and a daily aggregate heartbeat. All of it is off by default and opt in: you are asked once during first-run setup, and it can be toggled later under Settings, then System, then Online features. The distinction between the promise and the list is worth keeping in view, because the sentence promising no contact and the list of what is sent are the same paragraph. A hosted version is announced as coming for people who would rather not run a server, with the self-hosted server staying the full product and staying free, and a dashboard built in the hosted version exportable to a server of your own. The waitlist lives on the product site.

## The example compose file uses latest while telling you to pin a tag

The quick start is three lines, and they pull the compose file straight from the branch tip:

```sh
mkdir tesserae && cd tesserae
curl -fsSLO https://raw.githubusercontent.com/dmellok/tesserae/main/docker-compose.yml
docker compose up -d
```

The first request to the local port walks through password setup and an onboarding wizard, and the same page lists five other routes: a Home Assistant app, LXC with Proxmox and MicroCloud, a shell installer for macOS, Linux, and a Pi, a Windows PowerShell installer, and a manual virtual environment. Two of those scripts sit at the repository root. The compose example itself is mostly an argument for host networking, and the reasoning is specific. The render-frame URL that gets embedded in MQTT pushes has to point at the host's real LAN address so panels can reach it, and bridge networking would hand the container a private 172.x address that LAN clients cannot see, breaking both frame fetching and the built-in MQTT broker URL. Host networking also makes mDNS work, so a local hostname resolves without extra plumbing, and it exposes the built-in broker on its port without a published ports block. The file warns that Docker Desktop on Mac and Windows still treats host networking as beta, and tells you to delete the network mode line and uncomment the ports and host-IP block to switch to bridge, with a Mosquitto sidecar variant documented for full MQTT v5. Against that advice, the service block itself is written against the latest tag, which is the one line the header comment asks you not to copy.

## The image is 970 MB because it carries its own browser

The image is built on Microsoft's Playwright Python base, and the Dockerfile argues the choice rather than asserting it: that image ships Chromium along with the X, fontconfig, and NSS libraries a headless browser needs, pre-installed at the right version, whereas the same libraries on a slim Python base amount to a long apt-get sequence that breaks every time the distribution renames something, and the base is maintained by the people who own the version coupling. The size is stated: roughly 970 MB compressed to pull and about 2.5 GB on disk, mostly Chromium and its sandboxes, justified by a self-hosted appliance that has to render real web pages. The coupling is also the sharpest failure mode: a Playwright mismatch boots the container fine and then errors at first render with a message about a missing executable under the browser cache path. The pin lives in two places, the image tag and a range in the project file, and the file says to bump them together.

## A Python 3.11 server with a frontend that has no build step

The project file explains several dependencies in comments, which makes the constraints legible. The server needs Python 3.11 or newer. A HEIF decoder is listed because iPhone photos arrive in that format by default, and without it the picture widget family falls through to broken image tiles whenever a library holds an unprocessed file; the comment notes the wheel bundles the native library on the main platforms so it stays a pure pip install. The browser client is pinned to a single minor for the coupling already described. The calendar widgets pull two pure-Python packages to get RRULE and exception expansion right without reimplementing the standard, and a further entry adds safe XML parsing for a CalDAV discovery request. Beside this sits a Node package file named as lint tooling, marked private, version zero, whose own description says the app ships its JavaScript and CSS as is and that it is not a build step. Its three scripts cover linting and formatting, and the root adds the matching config files for both tools.

## Conclusion

Tesserae is built for someone with several panels and a server, and the design holds up on inspection: one render path, measured palettes, byte level device clients, and an egress allowlist with an honest threat model. Two things to settle before you rely on it. Pin the image tag yourself, because the example ships latest while telling you not to, and decide early whether you want the opt-in call home to the project domain, since the claim that the server never needs to talk to anyone sits in the same paragraph as the list of what it sends.

## FAQ

### What does Tesserae do?

It is a self-hosted dashboard companion for e-ink displays. You compose tile based dashboards in a browser, the server renders the frame headless, and it pushes the result to one or more panels over MQTT or HTTP.

### How does Tesserae render for an e-ink panel?

A headless Chromium driven by Playwright screenshots the composition at panel resolution, using the same URL the editor loads. The frame is then dithered with Floyd-Steinberg against the panel's measured palette and packed as packed 4-bit Spectra 6, 1-bit mono, or 4-bit greyscale.

### What does Tesserae send outside my network?

One domain, api.tesserae.ink, for update checks, an anonymous install count, and a daily aggregate heartbeat. It is off by default, you are asked once at first run, and it can be toggled under Settings, System, Online features.

### How do I install Tesserae?

With three shell lines: make a directory, fetch the compose file over curl, and run docker compose up -d, then open http://localhost:8765. Other routes exist for Home Assistant, LXC and Proxmox and MicroCloud, macOS, Linux and Pi, Windows PowerShell, and a manual virtual environment.

### Are third-party Tesserae widgets sandboxed?

No. A widget declares the hosts it needs in a requires block in its plugin manifest and the server enforces that at the socket layer, raising CapabilityDenied otherwise, but widgets run in process and the trust model is review of the community catalog.

## Sources

- [dmellok/tesserae on GitHub](https://github.com/dmellok/tesserae)
- [License: AGPL-3.0](https://github.com/dmellok/tesserae/blob/main/LICENSE)
- [Project website](https://tesserae.ink)
- [README](https://github.com/dmellok/tesserae/blob/main/README.md)
- [Releases](https://github.com/dmellok/tesserae/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/dmellok-tesserae
