Files
desklock/CLAUDE.md
T
jpmschweitzerandClaude 2ef00c98de
Test, Build and Push / test-gateway (push) Successful in 12s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
build: add the root Makefile the previous commit should have carried
ff19320 removed gateway/Makefile but the git add that was meant to stage its
replacement aborted on an already-staged pathspec, so the deletion landed
alone and main briefly had no Makefile at all. This is the other half.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 15:04:43 +02:00

11 KiB
Raw Blame History

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 container desklock-gateway, port 8600, part of the tatlock-ui Portainer stack (system-admin-toj/containers/stacks/tatlock-ui.yml, verified against the live container and CONTAINERS.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. Target esp32p4. source ~/esp-idf/export.sh is required every shell — idf.py is not on the non-interactive PATH otherwise.
  • BSP: waveshare/esp32_p4_wifi6_touch_lcd_xc from the ESP Component Registry, pulled automatically via main/idf_component.yml. Reference implementations: waveshareteam/ESP32-P4-WIFI6-Touch-LCD-XC examples/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 dialout group; a login session started before that membership took effect needs sg dialout -c "bash -lc 'source ~/esp-idf/export.sh >/dev/null && idf.py -p /dev/ttyACM0 flash'" — a plain idf.py flash works after any re-login.
  • PSRAM 200 MHz requires CONFIG_IDF_EXPERIMENTAL_FEATURES=y. Without it, CONFIG_SPIRAM_SPEED_200M is 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 in firmware/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 in firmware/main/idf_component.yml); the matching slave image is embedded as main/c6_slave.bin, and c6_ota.c flashes the C6 over SDIO at boot whenever it reports a version below 2.x.
  • Boot-loop assert xTaskCreateStaticPinnedToCore … xPortcheckValidStackMem before app_main means internal SRAM starvation (hosted 2.x is hungry). Keep CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM=y and the reduced WIFI_RMT_* buffer counts in sdkconfig.defaults (both verified present); check heap_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 1 in desklock_main.c (verified the macro and #if guard exist, currently 0) — the device becomes AP DESKLOCK-DIAG (password desklock123, page at http://192.168.4.1/, verified in wifi_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/ttyACM0 at 115200 with pyserial, pulse RTS to reset, read ~8s. Reported boot time (~1.6s to desklock: DeskLock up) is carried from AGENTS.md and 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 setup alone runs the app and the test suite without them. make setup invokes python3.12 explicitly; system python3 on tower-of-joy is 3.8.
  • ruff and mypy are not on the non-interactive PATH — they exist only inside gateway/.venv/bin/ once make setup has run. Use make lint / make typecheck, or invoke .venv/bin/ruff / .venv/bin/mypy directly; a bare ruff/mypy will fail to resolve, which is why the .claude/settings.json allow list uses the venv-relative paths and make targets rather than bare tool names.
  • Tatlock replies open with a <think> block — always strip it via tatlock.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.md before touching the face/render loop.
  • Gateway health check is GET /healthz (verified in main.py and matches the container healthcheck in tatlock-ui.yml), not /health.

Commands

One Makefile at the root drives all three components. There is deliberately no gateway/Makefile any more — make test meant "the gateway's tests" or "nothing" depending on which directory you were standing in, and now it means the same thing everywhere (D-27).

make help                          # every target, self-documenting

make test                          # gateway pytest; reports firmware + sim as undetermined
make lint                          # ruff check + format --check
make typecheck                     # mypy

make setup                         # gateway venv + dev deps (no ML models)
make setup-speech                  # additionally faster-whisper + piper
make run                           # uvicorn on :8600 with reload

make build-firmware                # sources export.sh for you, then idf.py build
make flash PORT=/dev/ttyACM0       # flash + monitor
make serve-sim                     # face simulator on :8601

The firmware targets source ~/esp-idf/export.sh themselves. idf.py is not on PATH until that runs, so the old cd firmware && idf.py build fails with "command not found" for anyone who forgets — the same class of failure as four other tool misses on this host. Override with IDF_EXPORT=<path>/export.sh on another machine; the target fails loudly with that hint if the file is absent.

make test never reports green for the firmware. It has no suite, so it is undetermined, printed explicitly rather than skipped silently (D-26).

Liveness

  • Gateway (desklock-gateway container, port 8600): confirmed live — docker ps shows 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/ttyACM0 was 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 of gateway/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 what tests/test_tatlock.py is 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.json policy).
  • Update CHANGELOG.md under [Unreleased] for user-facing changes.

Releasing (gateway only — firmware has no release flow)

Deploy is not automatic — confirm one is wanted first.

  1. Bump version in gateway/pyproject.toml.
  2. Move [Unreleased] entries into a dated CHANGELOG.md section.
  3. Commit, tag vX.Y.Z, push with tags.
  4. .gitea/workflows/build.yml runs lint + pytest on every push to main; on a v* tag it additionally builds and pushes git.schweitz.net/jpmschweitzer/desklock-gateway:{latest,tag} and pings Watchtower.
  5. 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.