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>
190 lines
11 KiB
Markdown
190 lines
11 KiB
Markdown
# 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.
|