Files
jpmschweitzerandClaude eca10a0dd0
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
docs: qualify the workspace decision references
D-26 and D-27 are workspace decisions, and this repo's own vault has none, so
a bare citation here means nothing resolvable — workspace D-21 requires the
vault to be named. Found by `make verify` in the workspace, which is the case
that rule was written for.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 17:58:14 +02:00

190 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `#include`d 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](https://github.com/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 (workspace D-27).
```bash
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 (workspace 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).
```bash
/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:
```bash
/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.