A Waveshare ESP32-C6-Touch-LCD-1.47 sits on my desk, joins WiFi and draws the live state of the agents running in herdr as a small kawaii face with an agent list beneath it.
The panel is portrait, always — 172×320, face above list. After flashing there is no cable to the PC: the device polls a tiny HTTP bridge running on the machine.
| Fact | Detail |
|---|---|
| Hardware | ESP32-C6FH8, 172×320 IPS, capacitive touch, QMI8658A IMU |
| Transport | PC bridge on the LAN, device polls `GET /state` |
| Firmware | ESP-IDF 5.5.1, LVGL UI, WiFi station |
| Radio | 2.4 GHz only — the C6 has no 5 GHz |
| Started | September 2026, still being pushed to today |
What's in it
Idle, working, blocked and done are expressions, not log lines. The whole point is being able to glance at the desk instead of switching windows.
herdr → Unix socket → bridge/herdr_status_bridge.py → GET /state → device. The bridge is the only piece that knows about herdr; the firmware just draws.
bridge/fixtures/ holds idle/working/blocked/done/empty documents, and tools/ui_host_test/ renders the UI on the host and asserts on pixels.
In detail
What is actually inside it
The bridge has no dependencies: reading a Unix socket and serving two JSON endpoints does not need a framework, and a bridge that cannot fail to install beats an elegant one.
bridge/herdr-status-bridge.service lives in the repository and installs with make bridge-service-install — a status display that needs a manual start after a reboot is one you stop trusting.
components/esp_bsp/ carries the board drivers (JD9853 panel, AXS5106L touch) plus two patches to the vendor touch code, documented in AGENTS.md §11.
| Detail | Value |
|---|---|
| Bridge | `0.0.0.0:8787` — `GET /state`, `GET /stats` |
| Bridge deps | Python 3 standard library only |
| Toolchain | ESP-IDF 5.5.1 |
| Entry points | `make bridge-once`, `bridge-service-install`, `bridge-service-status` |
Technical notes
The decisions behind it
Why a bridge instead of talking to the machine directly?
herdr speaks over a Unix socket, which a microcontroller cannot join. The bridge is a single-file translation layer — socket in, HTTP out — and it keeps the firmware boring: the logic that changes stays on the PC.Why polling rather than pushing?
What does the host-side UI test assert?
The client end of the wire
Two JSON documents, and what the device does when they stop arriving
The device is a plain HTTP client. It polls /state every CONFIG_HERDR_POLL_PERIOD_MS (1 s by default) plus one round trip, and asks for /stats only every fifth poll — session figures move slower than the mood, and that endpoint is the expensive one, because the bridge reads the agents' own logs to answer it. The two bodies are the whole contract:
GET /state
{"v":1,"gen":17285,"stale":false,
"agents":[{"id":"w5:p1","kind":"omp","label":"PoC","status":"working","focus":true}]}
GET /stats
{"v":1,"gen":17285,"stale":false,"sessions":1,
"totals":{"in":25120,"out":6103,"calls":188,"messages":160,"age_s":1840,"cost_micro":4564666},
"agents":[{"id":"w5:p1","in":25120,"out":6103,"calls":188,"messages":160,"age_s":1840,
"cost_micro":4564666,"model":"opus","tok_s":85}]}
Three details carry most of the weight. gen bumps on every successful poll and stale flips true five seconds after the last one, so a client can tell "same picture, re-sent" from "new picture" without diffing. The agent list is sorted before it is sent — blocked, working, done, idle, unknown, ties by pane id — so the first row is always the one that wants attention. And money travels as integer micro-dollars (1_000_000 = $1), which lets the device print two decimals with integer arithmetic and no float drift.
The bridge ASCII-sanitises every string on the way out: herdr prefixes terminal titles with markers like π > or ⠸, and the bundled Montserrat fonts stop at 0x7F, so anything beyond that would render as a missing glyph. Labels are truncated to 40 bytes and model names to 15, for the device's 16-byte buffer.
Failures degrade rather than break. The poller keeps the last agent rows and flips online off after three consecutive failed polls; the renderer then clears the list, because a stale "working" is worse than a blank — but the snapshot is only hidden, not thrown away, so the rows return on the first successful poll. Both HTTP clients pin IPv4 (HTTP_ADDR_TYPE_INET) because the bridge listens on 0.0.0.0 while the router also answers an AAAA for its name, and an unpinned lookup can hand lwIP that one first: a connect timeout that looks exactly like a firewall or a stale address.
| Signal | Value |
|---|---|
| Poll period | CONFIG_HERDR_POLL_PERIOD_MS — 1 s by default; the real cadence is this plus one GET |
| HTTP timeout | 3 s per request; both clients are built per poll, so a host that is a name is re-resolved every time |
| Stale | stale turns true 5 s after the last successful herdr poll |
| Offline | 3 consecutive failed polls flip online off; the renderer then blanks the list |
| /stats cadence | every 5th poll; 6 consecutive failures clear the numbers to "no data" |
| Body buffer | 8192 B — 2410 B measured at 14 agents; a body that does not fit is dropped whole, and the drop now logs at ERROR |
| Poll stack | 24 KB; the task logs its own low-water mark (12.7 KB measured after the first poll) |
| WiFi retry | 1 → 2 → 4 → 8 → 10 s backoff, forever; modem power save off (WIFI_PS_NONE) |
Behind the face
One mood per screen, from a precedence rule over the agent list
The mood is not a per-agent badge. mood_for() reduces the whole list to exactly one of six states, and everything visible follows from that single value.
An unreachable bridge short-circuits the rest: !online is OFFLINE, whatever the last list said. Otherwise one blocked agent outranks any number of working ones, working outranks done, done outranks idle, and a list with no agents at all is SLEEP — which is why a single stuck agent wearing an amber ! is the device's loudest state. unknown counts as idle.
The kawaii face (0015/lvglkawaiiface, MIT, vendored) ships seventeen expressions; each mood gets the one that reads right, plus a reaction that plays first and settles a few beats later — a blocked screen surprises you, then looks worried. OFFLINE and SLEEP are the two moods with no bar and no background wash, so they are the only ones that show the plain screen; WORKING and DONE get their body colour with its chroma pushed 25% at a stronger opacity, so a busy screen is visibly busier.
| Mood | Headline and face |
|---|---|
| BLOCKED | NEEDS YOU — worried, reacting surprised; amber, a sweat drop, the loudest state |
| WORKING | WORKING — working hard, reacting cool; the only mood whose bar sweeps, one 1.4 s lap |
| DONE | DONE — happy, reacting excited; confetti bursts on the change |
| IDLE | IDLE — neutral, reacting smirk |
| SLEEP | NO AGENTS — sleepy, no reaction; no bar, no wash, no foot |
| OFFLINE | OFFLINE — sad, reacting confused; no bar, no wash, list cleared |
The panel that looked dead
The touch driver, three faults deep, each settled by measurement
Touch did nothing for a whole session while the driver looked healthy, the boot log was clean and the I2C health check said all was well. Three faults were stacked.
The read shape. The AXS5106 answers the vendor's two transactions — write the register address, then read in a separate transaction with a STOP between — and NACKs a read joined to the address phase by a repeated start. A probe that tried all four shapes over a single touch made it unambiguous: two-step 8 and 14 bytes both returned ESP_OK; one-call 8 and 14 both returned ESP_ERR_INVALID_STATE. The returned block decodes to one finger at x=93, y=197. The project keeps the two-step form but now checks the transmit result, so a blind receive never follows an unacknowledged address phase — that is what used to knock the QMI8658A on the same bus out of its own reads. The read is 8 bytes (count + finger 1, registers 0x01–0x08) rather than the vendor's 14: this panel is single-touch, so there is nothing beyond finger 1 to fetch.
The lost release. Reading only on the asserting edge loses the end of a touch, and it fails silently. A release that is not announced as another report leaves the last finger-down sample sitting in the touch handle, and nothing else clears it: the coordinate getter consumes the sample by zeroing the point count. LVGL then held one pointer press that never came up — no tap, no long press, no swipe, with a healthy driver and a clean log. The driver now also reads while its own last report said a finger was down, and treats a read that finds nothing pending as the release (finger up (no report pending)).
The pulse. The controller announces a report with a short pulse, not a held level, so a driver sampling the INT level from LVGL's indev timer saw roughly one touch in eight — and the one it did see worked perfectly end to end, which made a missed report look like a gesture-recognition bug. The driver now registers an ISR on the falling edge and reads when the ISR flag is set, or the line freshly asserts (a missed edge, or a held line), or the last report said finger down. Init logs report interrupt registered on GPIO 21, so the running image says which mechanism it has.
Two smaller things came out of the same hunt. Reading on every poll — the vendor IDF driver's behaviour — gets every read NACKed at idle and lets the controller latch into a state it does not recover from, so an idle panel must not be polled for data. And reset timing is now the vendor's own Arduino timing, 200 ms asserted and 300 ms settle: the earlier 10 ms pulse brought the controller up far enough to ACK and report a point count while leaving its coordinate registers at zero, which is exactly where the (172,0) coordinates came from.
Worth knowing before trusting a clean boot: the vendor's touch_axs5106_init() is literally return ESP_OK; — no chip-ID check — and its i2c_write() had its body commented out and fell off the end of a non-void function. A clean boot proves nothing about the panel; only a press does. A failed read is retried once 3 ms later, and eight consecutive failures reset the controller through its reset pin — this part has one, unlike the IMU.
Checked without eyes on the panel
The UI is compiled on the host and asserted on pixels
make ui-test builds a host LVGL 9 together with main/ui_companion.c unmodified and runs pixel assertions. That constrains the firmware: ui_companion.c and the shared types header must stay free of esp_* includes or the host build stops compiling. Four shims in tools/ui_host_test/esp_shim/ stand in for the ESP headers, and the real vendored kawaii face is compiled too — the face the harness renders is the face the panel draws.
The harness registers a display whose flush callback blits into an RGB888 framebuffer and a pointer device whose read callback returns scripted press/release positions, then advances a virtual clock with lv_tick_inc(30) and lv_timer_handler(). It asserts on pixels, not vibes: exact centre colours, pixel counts within a band, label text by position, and — because LVGL does not clip a label — that a stats row's two columns cannot collide. It also counts bridge polls to pin down the click rules: zero after a hold, one after a tap or a double tap.
Two details are load-bearing. The harness clears its framebuffer before every scenario, because nothing else does: a screen only paints the pixels it covers, so without the clear a scenario reads the previous one's leftovers. And it identifies UI timers by period when it tears a screen down, which is why the companion's eye-glance interval is a random countdown inside a fixed 200 ms tick rather than a variable timer period. The suite registers 19 scenarios and writes frame_*.ppm (gitignored) so the art can be eyeballed; it exits non-zero on failure.
On hardware the same instinct shows up as self-reporting: the touch driver logs finger down (x,y) and finger up, and a session with zero I2C read error! lines is the touch-bus health signal — minutes without errors means the controller is ACKing and returning data, though only a press proves it reports coordinates.
Links
Where to find this one
