One agent doc per repo, and it is CLAUDE.md. Written fresh rather than reformatted, and shaped around the fact that this repo holds two components with nothing in common: ESP-IDF firmware flashed over USB, and a Python gateway that ships tag to CI to Watchtower. The rule that a protocol change must update docs/architecture.md is carried forward, as is the standing one that tatlock is never modified from here -- this repo consumes its public API only. Liveness is recorded per component rather than as one claim. The gateway is confirmed up from the container; the firmware is written down as undetermined, because no device was attached and there is no remote telemetry path, and an invented method would have been worse than an admission. The one figure carried over without re-measuring, a boot time taken from the old file, is marked as carried rather than verified. Co-Authored-By: Claude <noreply@anthropic.com>
9.9 KiB
CLAUDE.md — desklock
Two components, one repo, coupled by a shared WebSocket protocol:
firmware/— ESP-IDF (C, LVGL 9) app for a Waveshare ESP32-P4-WIFI6-Touch-LCD-3.4C (3.4" round 800×800 touch display, dual mics + ES7210 AEC, ES8311 codec + speaker). Flashed over USB; not containerized.gateway/— Python/FastAPI containerdesklock-gateway, port 8600, part of thetatlock-uiPortainer stack (system-admin-toj/containers/stacks/tatlock-ui.yml, verified against the live container andCONTAINERS.md). Orchestrates STT → Tatlock chat → TTS; carries no ML dependencies itself.
The device↔gateway protocol is specified in docs/architecture.md under "WebSocket
protocol (device ↔ gateway)". Any protocol change updates that file in the same
change — it is the contract, not a description of one side's behavior.
Never modify Tatlock from this repo. desklock consumes Tatlock's public API only
(http://tatlock:8000 on docker-dataplane); this is a standing cross-repo rule, not
local policy.
Keep the firmware thin. No STT, no TTS, no conversation logic on the device — that intelligence belongs in the gateway or in Tatlock itself.
Secrets never go in source. Firmware gets them via gitignored firmware/main/secrets.h
(verified: gitignored, and #included by gw_client.c/net.c) or NVS; the gateway via
DESKLOCK_* environment variables.
Gotchas — firmware
- Toolchain: ESP-IDF ≥ 5.4 (this box has 5.5, installed at
~/esp-idf), not Arduino, not PlatformIO. Targetesp32p4.source ~/esp-idf/export.shis required every shell —idf.pyis not on the non-interactive PATH otherwise. - BSP:
waveshare/esp32_p4_wifi6_touch_lcd_xcfrom the ESP Component Registry, pulled automatically viamain/idf_component.yml. Reference implementations: waveshareteam/ESP32-P4-WIFI6-Touch-LCD-XCexamples/esp-idf/—08_lvgl_demo_v9(display),06_I2SCodec(audio),04_wifistation(Wi-Fi). Check the official example before guessing a pin mapping. - Flashing needs the
dialoutgroup; a login session started before that membership took effect needssg dialout -c "bash -lc 'source ~/esp-idf/export.sh >/dev/null && idf.py -p /dev/ttyACM0 flash'"— a plainidf.py flashworks after any re-login. - PSRAM 200 MHz requires
CONFIG_IDF_EXPERIMENTAL_FEATURES=y. Without it,CONFIG_SPIRAM_SPEED_200Mis silently dropped to 20 MHz and the 800×800 MIPI-DSI framebuffer underruns (lcd.dsi.dpi: can't fetch data…spam, LVGL lock never frees, task watchdog). Verified both settings present infirmware/sdkconfig.defaults. - Wi-Fi radio stack must be esp_hosted ≥ 2.x on both the P4 host and the C6 slave,
non-negotiable. 1.x is formally incompatible with IDF 5.5 (esp-hosted-mcu#47) —
symptom is control-plane-only: RPC/scan/connect all work, but no data frame ever
flows (no DHCP, no ARP, no ping). Waveshare's examples and the factory C6 slave
firmware both pin the wrong (1.x-era) version. The host manifest pins
espressif/esp_hosted: "^2.12"(verified infirmware/main/idf_component.yml); the matching slave image is embedded asmain/c6_slave.bin, andc6_ota.cflashes the C6 over SDIO at boot whenever it reports a version below 2.x. - Boot-loop assert
xTaskCreateStaticPinnedToCore … xPortcheckValidStackMembeforeapp_mainmeans internal SRAM starvation (hosted 2.x is hungry). KeepCONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM=yand the reducedWIFI_RMT_*buffer counts insdkconfig.defaults(both verified present); checkheap_init:pool lines in the boot log when the binary grows. - SDIO clock is conservative by design:
CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ=20000(verified), ample for 16 kHz voice — raising it to 40 MHz is untested on this board's data path. - Wi-Fi diagnosis ladder: set
WIFI_DIAG_MODE 1indesklock_main.c(verified the macro and#ifguard exist, currently0) — the device becomes APDESKLOCK-DIAG(passworddesklock123, page athttp://192.168.4.1/, verified inwifi_diag.c), proving radio+SDIO+IP with zero external network variables. Ladder: L0 SDIO control → L1 softap data → L2 STA to any network → L3 STA to "Outside" → L4 gateway. - Non-interactive boot-log capture: avoid
idf.py monitor(interactive) — open/dev/ttyACM0at 115200 with pyserial, pulse RTS to reset, read ~8s. Reported boot time (~1.6s todesklock: DeskLock up) is carried fromAGENTS.mdand was not re-timed this pass — no device was connected in this session (see Liveness below). - If the device doesn't enumerate, hold BOOT while pressing RESET for download mode.
Gotchas — gateway
- Gateway speech deps (
faster-whisper,piper-tts) are an optional extra —make setupalone runs the app and the test suite without them.make setupinvokespython3.12explicitly; systempython3on tower-of-joy is 3.8. ruffandmypyare not on the non-interactive PATH — they exist only insidegateway/.venv/bin/oncemake setuphas run. Usemake lint/make typecheck, or invoke.venv/bin/ruff/.venv/bin/mypydirectly; a bareruff/mypywill fail to resolve, which is why the.claude/settings.jsonallow list uses the venv-relative paths andmaketargets rather than bare tool names.- Tatlock replies open with a
<think>block — always strip it viatatlock.strip_reasoning()(gateway/src/desklock_gateway/tatlock.py) before TTS or display. Verified present and called at the one call site. - Low power is a stated hardware requirement — read "Power management" in
docs/architecture.mdbefore touching the face/render loop. - Gateway health check is
GET /healthz(verified inmain.pyand matches the container healthcheck intatlock-ui.yml), not/health.
Commands
# Firmware (requires `source ~/esp-idf/export.sh` first; ESP-IDF ≥ 5.4, this box has 5.5)
cd firmware && idf.py build
idf.py -p /dev/ttyACM0 flash monitor
# Gateway
cd gateway && make setup # venv + dev deps (no ML models)
make setup-speech # additionally install faster-whisper + piper
make run # uvicorn on :8600 with reload
make test # pytest (tests/test_health.py, test_tatlock.py, test_commands.py)
make lint typecheck # .venv/bin/ruff + .venv/bin/mypy
Liveness
- Gateway (
desklock-gatewaycontainer, port 8600): confirmed live —docker psshows the container running under that name (method: direct container inspection; blind spot: none relevant here, this confirms the process is up, not that every route behaves correctly — that would need a request against it, not checked this pass). - Firmware / device: liveness is undetermined and cannot be established the way
the gateway's can. No
/dev/ttyACM0was present in this session (checked:ls /dev/ttyACM*found nothing) and there is no remote telemetry — the device only proves itself alive over a physical USB serial connection or by joining the LAN and speaking the WebSocket protocol, neither of which this session had access to. Do not infer device state from repo contents or from the gateway being up. strip_reasoning()reachability: confirmed by direct read ofgateway/src/desklock_gateway/tatlock.py(method: source read of the one call site; blind spot: does not confirm it's exercised by a live request — that's whattests/test_tatlock.pyis for, not re-run this pass).
Work tracking
This repo's vault is standalone — its tickets and internal decisions live in its own
.pql/ and governance/, and travel with a clone (.pql/changelog/ is committed).
/home/jpmschweitzer/.local/bin/pql ticket list
/home/jpmschweitzer/.local/bin/pql plan whatsnext
/home/jpmschweitzer/.local/bin/pql decisions list
pql is not on the non-interactive PATH — use the absolute path above. From inside this
repo no --vault flag is needed (pql anchors at the nearest .git/ ancestor, which is
this repo) — but that also means a bare pql run from the workspace root will not
see this repo's tickets, and a write from the workspace root would go to the wrong
vault. Cross-repo/stack-level decisions (host, network, deploy mechanics — none specific
to desklock were found at the time of writing) live in the workspace vault instead:
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain desklock
Git
- History is linear — no merge commits. Work on
main, or a short-lived branch that is fast-forwarded and deleted. This is the workspace-wide policy; there is no per-repo exception here. - Conventional Commits (
feat:,fix:,refactor:,docs:,chore:). - Stage explicitly — never
git add -A(denied by.claude/settings.jsonpolicy). - Update
CHANGELOG.mdunder[Unreleased]for user-facing changes.
Releasing (gateway only — firmware has no release flow)
Deploy is not automatic — confirm one is wanted first.
- Bump
versioningateway/pyproject.toml. - Move
[Unreleased]entries into a datedCHANGELOG.mdsection. - Commit, tag
vX.Y.Z, push with tags. .gitea/workflows/build.ymlruns lint + pytest on every push tomain; on av*tag it additionally builds and pushesgit.schweitz.net/jpmschweitzer/desklock-gateway:{latest,tag}and pings Watchtower.- Verify:
curl http://192.168.86.149:8600/healthz.
Architecture
docs/architecture.md is the source of truth for system design, the face design
(sim/face/index.html is its visual source — change both together and verify with
~/bin/claude-screenshot, noting its --virtual-time-budget starves
requestAnimationFrame, so sim animation is driven by setInterval instead), power
budget, latency budget, and the full WebSocket protocol spec. Not restated here because
it is detailed enough to drift if duplicated — read it directly.