A Home Assistant wall-panel controller for the Guition ESP32-S3-4848S040 — a 4" 480×480 capacitive-touch display. Tessera shows a grid of device tiles that toggle Home Assistant entities over the WebSocket API, plus indoor/outdoor temperatures and a full thermostat control view.
Why "Tessera"? A tessera is a single small tile used to build a mosaic. The name fits a screen made of device tiles — and the configuration array that defines them is, fittingly, called
MOSAIC[].
- 3×3 tile grid, data-driven from a
MOSAIC[]array inconfig.h - Toggle lights / switches / fans via the HA WebSocket API, with optimistic UI updates
- Per-tile icons (Material Design Icons) and live value text (brightness %, fan speed %)
- Availability pips — green when the entity is reachable, amber when
unavailable - Fan low-start — optionally turn a fan on at a set speed instead of 100%
- Header: live clock + indoor and outdoor temperatures (read from HA, no third-party API)
- Thermostat control — tap the thermostat tile for a detail view with current temp, mode (Off/Heat/Cool/Auto) and setpoint +/- (dual setpoints in heat_cool)
- Swipe paging, idle dimming, and a wake-touch that only wakes the screen
- Guition ESP32-S3-4848S040 (ESP32-S3-WROOM-1 N16R8, 16 MB flash, 8 MB PSRAM)
- ST7701S RGB display, GT911 capacitive touch
- CH340 USB-serial (appears as a COM port)
- 3D-printed stand for the Guition ESP32-S3-4848S040: Thingiverse thing:7373058
- PlatformIO + Arduino framework
- LVGL 8.4, Arduino_GFX, links2004/WebSockets, ArduinoJson (pinned in
platformio.ini);TAMC_GT911is vendored underlib/(patched — see Known setup notes)
Before you start, you'll need:
- A running Home Assistant instance on your network, reachable over HTTP
(default port
8123) with the WebSocket API enabled (on by default). Tessera is a controller — it does not run Home Assistant itself. - A Home Assistant long-lived access token (HA → your profile → Security → Long-Lived Access Tokens, at the bottom of the page) for the panel to authenticate with.
- The entities you want to control already configured in Home Assistant —
lights, switches, fans, a
climatethermostat, and aweatherentity for the outdoor temperature. - A 2.4 GHz WiFi network — the ESP32-S3 does not support 5 GHz. The panel and Home Assistant must be reachable on the same network.
- A computer with PlatformIO and a USB cable for the initial flash (later updates can go over the network via OTA).
Configuration is split in two, and neither file is committed:
- Credentials — WiFi, HA host/port/token, timezone — are gathered by a setup
wizard and stored in
include/secrets.h. - Devices — your tiles and the header thermostat/weather entities — live in
include/config.h. This file holds no secrets, so it's safe to share or paste into an LLM (see Adding devices).
-
Install PlatformIO — the build/flash toolchain. Install either the PlatformIO IDE extension for VS Code or the command-line core (
pip install platformio); see platformio.org/install for all options. -
Get the code
git clone https://github.com/cruftbox/tessera.git cd tessera -
Connect the panel to your computer with a data-capable USB cable (a charge-only cable won't work). It appears as a serial port —
COMxon Windows,/dev/tty.*on macOS/Linux — which the wizard auto-detects when it flashes. -
Add your devices
cp include/config.h.example include/config.h
Edit
include/config.h: list your tiles inMOSAIC[]and set the header thermostat/weather entities — see Adding devices. -
Run the setup wizard (gathers credentials, then flashes)
python setup_wizard.py
It prompts for your WiFi, HA host/port, a long-lived access token (it tells you where to find it), and timezone (pick from a list); validates the token against your Home Assistant instance before continuing; writes
include/secrets.h; then offers to flash the panel over USB and confirm it connects. Run it with the PlatformIO Python, which bundlespyserial— on Windows that's& "$env:USERPROFILE\.platformio\penv\Scripts\python.exe" setup_wizard.py; the script header lists the macOS/Linux paths.Prefer to do it by hand? Copy
include/secrets.h.exampletoinclude/secrets.h, fill it in, and build withpio run --target upload(set your panel's port with--upload-port <port>or inplatformio.ini).
After the first USB flash, later updates can go over WiFi — no cable. Flash with
the tessera_ota environment, pointed at your panel's IP:
pio run -e tessera_ota -t upload --upload-port <panel-ip>Find the IP in your router's device list (or the panel's serial log on first
boot). The upload_port baked into platformio.ini is only a default —
override it per panel with --upload-port. Managing more than one panel?
python panels.py flash <name> --ota does this for you — see
Running multiple panels.
On Windows, the host firewall must let PlatformIO's espota helper receive
the panel's connection back, or the upload fails with "No response from
device". If you hit that, allow
…\.platformio\penv\Scripts\python.exe through the firewall (Private and
Public profiles). A failed OTA is safe — it writes to the inactive flash slot and
only switches over after a complete, verified transfer. Serial logging needs USB;
it isn't available over OTA.
config.h and secrets.h are compiled into the firmware, so each physical
panel is really its own build. Running more than one means keeping more than
one (config.h, secrets.h) pair straight — and telling the panels apart when
one is plugged in for an update. panels.py handles both:
python panels.py new kitchen # set up the connected board as a new panel, named "kitchen"
python panels.py new # ...or get prompted for a name
python panels.py register office # link the connected board to an EXISTING
# profile that isn't registered to a MAC yet
python panels.py flash # auto-detects which panel is connected, syncs
# its files into include/, and reflashes it
python panels.py flash kitchen # or flash a specific one by name
python panels.py flash kitchen --ota # ...over WiFi instead of USB
python panels.py list # show known panels and their MAC addresses
python panels.py identify # what panel is plugged in right now?Run it with the same PlatformIO Python used for setup_wizard.py (see
Setup above) — it needs pyserial and esptool, which that
interpreter already has. Every command also takes --port to skip
auto-detection, and flash takes --host (override the OTA target) and
--no-confirm (skip the post-flash serial check); run with --help for the
full list.
Each panel gets its own folder at panels/<name>/config.h + secrets.h
(gitignored, same as include/config.h/secrets.h) and a unique
OTA_HOSTNAME, so panels don't collide on the same <name>.local. Under the
hood, panels.py reads the board's hardware MAC address with esptool and
keeps a MAC → panel-name map in panels/registry.json, so flash with no
name works even if you can't remember which panel is on which USB port.
new will offer to base a new panel's layout, or its WiFi/HA credentials, on
an existing panel — handy since most panels in one house share the same
network and Home Assistant instance and only differ in which devices they
control. If you already have a single-panel setup (just include/config.h and
include/secrets.h, no panels/ folder yet), the first time you run new or
flash it offers to adopt your existing setup as your first named panel — if
that board wasn't plugged in at the time (so its MAC couldn't be recorded),
plug it in later and run python panels.py register <name> to link it.
Once you're using panels.py, treat panels/<name>/config.h as the source of
truth for that panel and edit it directly — include/config.h is just a
staging copy of whichever panel you last synced with new or flash, and
gets overwritten the next time you sync a different one.
Tessera is built to be configured and extended with an AI coding assistant (Claude, etc.) — and doing so is genuinely the easiest path. The config and the code are deliberately self-describing for exactly this.
- Setup & install — paste this README and
include/config.h.exampleinto an LLM and describe your setup ("Home Assistant at192.168.x.x, these devices…"). It can fill inMOSAIC[], pickICON_*glyphs, and walk you through running the setup wizard (which gathers your token and flashes the panel). - Adding or changing devices — paste your
config.hand say "add my garage light, entityswitch.garage." Every field (label,entity_id,page,on_pct,icon,on_kelvin) is commented and the available icons are listed, so the model has what it needs to produce a correctMOSAIC[]row. - Running more than one panel — paste
panels.pyalongside a description of your panels ("a kitchen one and an office one, same WiFi/HA") and it can walk you throughnew/flashfor each. - Modifying the firmware — each source file carries a module-header comment describing its role and gotchas, so an assistant can orient quickly to make UI or behavior changes.
- Troubleshooting — share serial output or symptoms; common pitfalls (the touch coordinate transform, ArduinoJson filter sizing, HA token / IP-ban) are documented in the code and below.
You don't need an LLM — everything is editable by hand — but the project is structured to make AI-assisted setup and extension fast and reliable.
Tiles are defined in include/config.h as rows of the MOSAIC[] array. Adding a
device that uses an existing icon needs no firmware changes — just a new row, a
rebuild, and a flash. Each row has six fields:
| Field | Meaning |
|---|---|
label |
Text shown under the icon (keep short — ~14 chars fits) |
entity_id |
The Home Assistant entity, e.g. light.kitchen. Tapping the tile toggles it. |
page |
Which screen the tile is on (0-based). Up to 9 tiles per page (3×3); swipe left/right to change pages. |
on_pct |
0 for a plain toggle. For a fan, a value >0 makes "turn on" start at that % speed instead of 100% (e.g. 16.67 ≈ the lowest of 6 speeds). Ignored for non-fans. |
icon |
An ICON_* name from the catalog at the top of config.h. |
on_kelvin |
0 for no change. For a tunable-white light, a value >0 sets that color temperature (in Kelvin, e.g. 3000 for warm white) when turning on. Ignored for non-color-temp lights. |
on_kelvin is the last field, so you can drop it entirely for a plain toggle —
the compiler zero-fills anything left off the end. icon is required and comes
before it, though, so don't skip on_pct while keeping icon; the fields are
positional, not named.
Example — add a garage light on the first page:
static const Tessera MOSAIC[] = {
// ...existing tiles...
{ "Garage", "switch.garage", 0, 0, ICON_LIGHTBULB }, // plain toggle (on_kelvin omitted)
{ "Garage", "light.garage", 0, 0, ICON_LIGHTBULB, 3000 }, // a tunable-white light, warm on
};Then rebuild and flash (pio run --target upload). The entity must already exist
in Home Assistant.
Easiest path: paste your config.h into an LLM and say "add my garage light,
entity switch.garage." The file documents every field and lists every available
icon, so the model has what it needs to produce a correct row.
Tile/header icons come from a generated LVGL font (src/mdi_icons.c) built from
Material Design Icons. The font is
committed, so a normal build needs no extra steps. The ICON_* names at the top
of config.h are the complete catalog — only those glyphs are compiled in.
To add a glyph that isn't in the catalog:
- Find the icon at pictogrammers.com/library/mdi
and note its hex codepoint (e.g.
F0335). - From
tools/, install the tooling once, then regenerate the font with your new codepoint appended to the existing--rangelist:(Keep every existing codepoint; just add yours ascd tools npm install # once — fetches @mdi/font + lv_font_conv npx lv_font_conv \ --font node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf \ --size 26 --bpp 4 --format lvgl --no-compress --no-kerning \ --lv-include lvgl.h -o ../src/mdi_icons.c \ --range 0xF0335,0xF1797,0xF02E3,0xF0769,0xF04B9,0xF1020,0xF1798,0xF0F55,0xF0595,0xF0393,0xF0XXX
0xF0XXX.) - Add a matching define in
config.hand use it in a tile. The glyph is a UCN with the codepoint zero-padded to 8 hex digits — e.g.F0608becomes:#define ICON_GARAGE "\U000F0608" // garage
- GT911 patch: the upstream
TAMC_GT911library callspinMode()on the INT/RST pins even when they're unused (-1), which on this board logsInvalid IO 255. A patched copy is vendored underlib/TAMC_GT911(the INT/RST pin operations inreset()are guarded), so a freshgit clone+ build works with no manual patching — and it is intentionally not inlib_deps, so a dependency install can't overwrite it. Seelib/TAMC_GT911/PATCH.md. - Serial output requires
ARDUINO_USB_CDC_ON_BOOT=0(already set inplatformio.ini) so logging goes to the CH340 UART rather than native USB-CDC. - Every build embeds the current git commit (via
scripts/git_version.py, no manual version bumping) and prints it on boot —Tessera starting... (firmware a1b2c3d). A-dirtysuffix means the working tree had uncommitted changes at build time, so what's on the panel isn't exactly what's in git history. Useful for telling which commit is running on which panel, especially withpanels.pymanaging more than one. - HA IP-bans repeated failed logins. If the panel retries a wrong token, Home
Assistant will ban its IP and every request — including a correct token
afterward — gets rejected until the ban clears. This is why the wizards
validate your token from the PC before ever flashing it to the panel. If
you do get banned: clear the entry in HA's
ip_bans.yamland restart Home Assistant. - The serial port is hardcoded to
COM7inserial_read.pyandplatformio.ini(monitor_port/upload_port).setup_wizard.pyandpanels.pyboth auto-detect the port when they flash, so this only matters for the serial monitor or a manualpioupload. To target a different port without editingplatformio.ini, setTESSERA_PORT(a COM port for USB, or an IP/*.localhostname for thetessera_otaenv) — seescripts/select_port.py.
src/ main, display, touch, ui, ha_client, ota, mdi_icons (generated font)
include/ config.h.example (devices), secrets.h.example (credentials), lv_conf.h
tools/ icon-font generation (@mdi/font + lv_font_conv)
scripts/ select_port.py (TESSERA_PORT override for pio upload/monitor)
panels/ per-panel profiles + registry.json (gitignored; created by panels.py)
setup_wizard.py first-run credential wizard — writes include/secrets.h, flashes
panels.py multi-panel manager — register/identify/flash by name or MAC
serial_read.py serial-monitor helper (reads the boot log over USB)
platformio.ini, partitions_16MB_ota.csv
MIT.
