AI-assisted ESP-IDF + LVGL firmware for the JC3636K518C round knob display
Small firmware that an assistant and a human can both reason about has the same shape every time: a thin hardware layer, pure logic beside it, and a state machine on top. The reference firmware on this board grew to 13k lines of C and stayed workable only because of these rules.
components/
board/ pins + capability flags as macros; header-only, no code
ui/ display + touch + knob + backlight bring-up, LVGL screens
theme/ colour = state policy from the design tokens; no LVGL dependency
haptic/ DRV2605L driver
net/ Wi-Fi, HTTP client, streamed upload
app/ the state machine; the only module that knows about all the others
main/ app_main(): init order, nothing else
test/host/ gcc + Unity tests for pure modules
Dependency direction points inward: app → ui, net, haptic, theme; ui → theme,
board; net → board (for nothing but the name). ui never includes net. theme never
includes LVGL. board includes nothing.
Each component has CMakeLists.txt, include/<name>.h (public), and .c files. Private
headers stay next to the .c (foo_priv.h), not in include/.
A 300-line ceiling keeps a file readable in one sitting and small enough for an assistant to hold in context with its neighbours. When a file approaches it, split by responsibility, not by line count:
knob.c (GPIO + timer) and knob_decode.c (the detent state machine)net_upload.c (the protocol) and net_upload_put.c (one streamed PUT)screen_detail.c (layout) and screen_detail_status.c (status rendering)If a split produces two files that always change together, merge them back and split along a different seam.
Any code that makes a decision should compile on the host:
| Pure (host-tested) | Hardware wrapper around it |
|---|---|
knob_decode.c — samples in, ±1 steps out |
knob.c — GPIO read every 3 ms |
upload_plan.c — file size → parts, offsets, lengths |
net_upload.c — HTTP calls |
theme.c — state → colour, motion period |
screens call lv_color_hex() |
backlight_policy.c — level, floor, dim → effective duty |
backlight.c — LEDC |
Rules for a pure module: no esp_*, no FreeRTOS, no LVGL includes; state passed in as a
struct, zero-initialised means idle; no globals; no allocation. It is then a plain C file
that gcc compiles with Unity.
Test layout (see templates/knob-starter/test/host/):
test/host/unity/ vendored Unity (unity.c, unity.h, unity_internals.h)
test/host/stubs/ tiny stand-ins for sdkconfig.h and generated headers
test/host/test_knob_decode.c
test/host/test_upload_plan.c
test/host/test_theme.c
test/host/test_board.c includes the board header, asserts no two signals share a GPIO
scripts/test.sh compiles each test_*.c with its module and runs it
A test file: #include "unity.h", setUp/tearDown, TEST_ASSERT_EQUAL_INT, a main with
UNITY_BEGIN() / RUN_TEST / UNITY_END(). Keep tests behavioural: “one detent yields exactly
one step”, “a 17 MB file makes three 8 MiB parts, the last 1 MiB”.
Every public header opens with:
Then each function gets two or three lines: what it does, what it returns on failure, what it
does not do. Write the comment before the code; if it is hard to write, the function is doing
too much. Inside .c files comment the why (the lesson, the bug number, the datasheet
section), never the what.
theme.c changes when the design
changes, knob_decode.c when the switch behaviour changes.#ifdef forests.
net_set_result_cb(cb) lets app react to uploads without net knowing app exists.
Board differences are capability macros (BOARD_HAS_HAPTIC) checked in exactly one place.haptic on a board without one) must
honour the same contract: same return values, same “no-op is safe” guarantee.net.h exposes six functions; the
multipart machinery is net_priv.h.app depends on the net.h abstraction, not on
esp_http_client; pure modules depend on nothing. Opaque state structs (knob_decode_state_t)
keep callers from reaching inside.Coupling has three dimensions. Balance them and change stays cheap.
| Dimension | Meaning | Low | High |
|---|---|---|---|
| Integration strength | How much two modules know about each other | Shared value through a callback | Shared struct layout, shared globals |
| Distance | How far apart they live | Same file | Different component, different repo |
| Volatility | How often the reason to change arrives | Pins, panel init table | Screens, product rules |
The balance rule: strong coupling is fine at short distance; across distance, coupling must be weak. Problems come from strong coupling at long distance (two components sharing a struct) or from weak-but-chatty coupling between things that always change together (ten callbacks between two files that should be one).
Applied to the template:
knob.c ↔ knob_decode.c: strong, short distance, low volatility → fine.app ↔ net: one callback and three functions → weak across a component boundary → fine.ui ↔ theme: ui calls theme_state_color() and nothing else → weak → fine; the screens
(high volatility) never leak into theme (low volatility).net.h to fire a request. That is strong coupling across
distance between a volatile and a stable module. Route it through app.Cyclomatic complexity counts the independent paths through a function: one, plus one for every
if, else if, case, &&, ||, ?:, loop and early return inside a loop. It is the
best single predictor of where bugs hide and of what a reader (or an assistant) gets wrong.
Firmware grows it fast: error handling, state machines and hardware quirks all add branches.
Targets for the workshop, per function:
| Complexity | Verdict |
|---|---|
| 1–5 | Fine. Most functions in the template sit here. |
| 6–9 | Acceptable if the function is a flat dispatcher (one switch, one line per case). |
| ≥ 10 | Split before merging. Nesting deeper than three levels is the same signal. |
Tactics that work in C, all used in the template:
if (!cli) return false; at the top instead of wrapping the body
in if (cli) { ... }. Each guard is one branch, but nesting stays flat.theme.c), a state → text map, an
array of handlers indexed by an enum. One lookup replaces a switch that grows per state.knob_decode_step() returns +1/-1/0; the caller only
acts on it. The decision function is where the branches live, and it is host-tested, so the
effect function stays almost straight-line.if (resp_has_room(c, len)) beats
if (c->buf && c->len + len < c->cap - 1). The condition gets a name and its own test.net_http_put_stream() loops; put_once() does
one attempt. Neither has to know about the other’s branches.Measure, do not guess. Any of these works from the project root with no IDF:
pipx run lizard -C 10 components/ main/ # CCN per function, warns above 10 (or pip install lizard)
sudo apt install pmccabe && pmccabe components/*/*.c | sort -rn | head
Ask the assistant to run the tool on what it just wrote and to split anything flagged. It will usually propose a table or a guard clause on its own once it sees the number.
Run this before saying a firmware change is finished.
scripts/check-board-pins.sh passes: no GPIO literal outside components/board.scripts/test.sh is green.#include crosses the dependency direction in §1.wifi_secrets.h) untracked; wifi_secrets.example.h updated if keys changed.