No repo here scanned for committed credentials. The hook is self-contained
rather than delegating to a Makefile, because this repo has none and a hook
reaching into a sibling repo breaks the moment this one is cloned elsewhere.
Scans the outgoing range rather than full history: history carries settled
findings — test fixtures, vendored third-party code — and a gate that fails
on something unfixable gets bypassed within a week.
Setting core.hooksPath means pql init must replant its replication shims into
.githooks, which is why they are gitignored here alongside the tracked
pre-push. Same layout pql itself uses.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
Commits a .claude/settings.json rather than leaving permissions to
per-developer local state, and initialises a pql vault for this repo's
tickets and internal decisions.
Every git deny rule appears in both the `git <verb>` and `git * <verb>`
forms. Only the second catches `git -C <path>`, and without it the whole
deny list is decorative -- it looks like a policy and stops nothing.
The allow list carries pql's absolute path alongside the bare name.
pql is installed to ~/.local/bin, which is on the login PATH but not the
one a non-interactive shell gets, so the bare-name rules match nothing on
their own and every call would prompt anyway.
.gitignore now covers .claude/settings.local.json, which is machine-local
and must never be shared. `pql init` contributed the .pql/* rules with an
exception for the changelog, which is the replication log of record and
has to be committed for tickets to travel with a clone.
Co-Authored-By: Claude <noreply@anthropic.com>
Every figure in the latency budget was stale, in both directions. TTS was
listed at ~1.9 s per sentence but measures ~0.24 s warm for 4.5 s of audio;
the full Tatlock flow was listed at 11-25 s but measures ~10-13 s for simple
turns. Both sets of numbers predate the current model.
The VRAM section now carries real figures and the reason they matter: on
2026-08-07 Tatlock ran against a 9.3 GB model, leaving 7 MiB free, and every
transcription failed with CUDA out of memory while the Speaches container
still reported healthy. The budget is the constraint, not slack.
Also replaces the retired tatlock.schweitz.internal hostname in the topology
diagram with the docker container name.
Co-Authored-By: Claude <noreply@anthropic.com>
Network migration: the gateway's default Tatlock URL is now the docker
container name (http://tatlock:8000); the retiring tatlock.schweitz.internal
domain is gone from config and docs. Deployments that set
DESKLOCK_TATLOCK_BASE_URL are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The homelab is retiring the *.schweitz.internal domain; in-network
machine-to-machine traffic uses docker container names on the
docker-dataplane network. The deployed tatlock-ui stack already overrides
DESKLOCK_TATLOCK_BASE_URL (Tatlock runs on the host), so only the
fallback default changes.
Docs follow: AGENTS.md M2M guidance now points at container names with
*.schweitz.net reserved for browsers, architecture.md drops the retired
domain (the registry name now matches what CI actually pushes since
c477019), and the README diagram loses the stale hostname.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The .internal registry domain is being retired; git.schweitz.net now
serves the registry without SSO on /v2/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes: volume voice command no longer hangs in the thinking spinner (gong
stops holding the busy state), and the butler filler is reworded to avoid
a text-to-speech mid-phrase pause.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Kokoro inserts an unnatural ~0.2s pause before "for you", so "Let me check
on that for you, sir." came out as two phrases. Reword to "Let me check on
that, sir." — same intent, clean pacing (measured: no internal silence gap).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
A spoken volume command left the face stuck in the thinking spinner. The
gateway sends state:thinking -> command -> state:idle, but the device's
state:idle handler is gated on !audio_is_playing(), and the feedback gong
had been setting s_playing for its (up to 5 s) duration — so the idle was
ignored and nothing re-sent it. The gong is a UI cue, not reply playback,
so it no longer sets s_playing. This also drops the 5 s wake-gate the gong
was imposing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Volume goes to eleven (0–11 scale, gong feedback, mute), a voice command
service that handles volume/mute without the LLM, and an immediate butler
filler line with a spinner during the Tatlock wait.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Tatlock turns take 10-25s, which is a long silence after a request. Speak
a canned "Let me check on that for you, sir" immediately (synthesized once
and cached), then re-assert the thinking state so the device keeps its
effort-face spinner up until the real reply arrives. On the device, guard
the playback-done handler so the filler audio finishing doesn't drop the
spinner back to idle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Recognize simple device commands in the transcript and act on them without
a Tatlock round-trip. commands.match() maps volume up/down, mute/unmute,
"set volume to N", and "goes to eleven"/max to a "command" message sent
straight to the device; the utterance never reaches the LLM. Matching is
deliberately precise so real requests ("set an alarm for a quarter to
eleven") are not hijacked. Adds the "command" message to the device
protocol in docs/architecture.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Rework the speaker volume from a 0-100 percentage to an 11-step level
(0..11, mapped to the codec's percent), with mute that remembers the
prior level so unmute restores it. Any non-silent change plays the gong
as feedback; a new change cuts the in-flight gong off and restarts it
rather than queueing another. The tap overlay shows the level number and
0..11 bar, and a "11" drifts up off the bar when you hit maximum.
Also lands the device side of gateway volume commands: gw_client routes a
"command" message to face_volume_command, which applies the change and
shows a compact auto-hiding volume HUD (a centered bar) — the gateway
half that sends these lives in a following commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Adds a way to capture the live LVGL screen off the device and rebuild it
as a PNG on the host, so UI changes can be verified remotely without a
camera. A watcher task polls the USB-serial-JTAG RX for a trigger byte and
streams the current screen as raw RGB565 straight to the USB FIFO (framed
by ###SHOT_BEGIN/END### with a CRC); firmware/tools/device_shot.py and the
device-screenshot skill drive it from the host.
Writing straight to the USB FIFO bypasses the primary UART console, which
at 115200 baud would take ~37s per frame. The whole capability is behind
DESKLOCK_DEVMODE (off by default, enable at deploy time with
-DDESKLOCK_DEVMODE=ON) so production spends no internal RAM on the watcher
and nothing extra runs on the render path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Tapping the face now brings up an overlay with a microphone button and
volume down/up, plus a live level bar showing the current output volume.
Icons are drawn from a subset of the Phosphor glyph font. Tapping the dim
scrim behind the controls dismisses them; they also auto-hide after a few
seconds of inactivity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Each stream periodically swaps to a different one of the pooled streak
sprites on a staggered timer, so the columns keep changing rather than
sitting statically tiled — the rain reads as alive in the corner of the
eye. The pool stays at 5: each sprite is ~15 KB of internal RAM and more
starves esp-hosted's internal-RAM task stacks into a boot loop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
The rain was 40 live text labels; moving a label re-rasterizes its
katakana glyphs every frame, reading the font from PSRAM and blending
against the framebuffer. That per-frame load starved the DSI's
framebuffer read and flashed the panel blue while a voice reply
downloaded (confirmed: with the rain removed the flash vanished).
Pre-render a small pool of streak sprites once into internal RAM and
blit them; movement is now a cheap opaque copy that never touches the
PSRAM bus. Also drain the rain during audio (listening/speaking) rather
than freezing it. This clears the last of the load flicker while keeping
the dense look.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Bump raw_buf_almost_empty_thrd 512 -> 1024 right after display start so
the bridge demands a DMA refill with more slack still in the FIFO,
letting it ride out a PSRAM-bus latency spike instead of draining to the
blue underrun colour. Symptom mitigation for the bus-arbitration
starvation; the register isn't otherwise exposed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
The stock 1500 Mbps (the P4 maximum) makes the DSI transmit each line in
a tight burst, so the framebuffer reads are bursty and lose the PSRAM
bus to the Wi-Fi SDIO DMA under load. 720 spreads the read out and
tolerates the contention. Espressif's documented underrun mitigation;
the floor at this pixel clock is ~480 Mbps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Comment-only. Moving the esp-hosted transport buffers off PSRAM
(MEMPOOL_PREFER_SPIRAM=n), to keep the radio's SDIO DMA off the DSI's
bus and stop it starving the framebuffer read, was tried against the
flicker — but it exhausts internal RAM and boot-loops in esp-hosted init
before Wi-Fi comes up. Documented so it isn't retried; the setting stays
=y.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Two secondary mitigations for the DSI underrun (the primary fix is the
reduced DPI clock in the BSP). Raise LV_INV_BUF_SIZE 32 -> 128 so the
busy rain states stay partial-redraw instead of collapsing into a
full-screen redraw, which dumps a ~1.3 MB PSRAM write burst that
competes with the DSI's continuous framebuffer read. Advance the matrix
rain every other tick (doubled step, visually identical) to halve how
often its ~80 labels invalidate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Start the display via bsp_display_start_with_config with a 16 KB LVGL
task stack. The 8 KB default overflows once LV_INV_BUF_SIZE is raised to
128 (the partial-flush path's stack frame scales with it) — the guard
fires as a "Stack protection fault" boot loop otherwise.
Add FACE_LOADTEST (gated off): a load-emulation harness that cycles
isolated load types — a render-only face ladder, light/heavy render plus
a network stream, render plus playback, and all three stacked. Each
phase logs a marker and shows an on-screen label. It pinned the blue
flicker to a DSI PSRAM-bandwidth underrun (the driver logs "underrun
happens"), which is serial-verifiable without eyes on the screen. Kept
for future bus-contention debugging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
The blue flicker during voice activity is a MIPI-DSI framebuffer-read
underrun on the PSRAM bus, not tearing. The DSI DMA reads the 800x800
framebuffer continuously (~138 MB/s at the stock 80 MHz clock); under
load the Wi-Fi (esp-hosted SDIO) and audio-playback DMA on the same
PSRAM bus starve the DSI FIFO, which the hardware then paints blue
("lcd.dsi.dpi: can't fetch data from external memory fast enough").
Drop the DPI pixel clock to 40 MHz (~54 fps): halves the continuous
read and gives the FIFO slack to ride out bursty SDIO latency spikes.
Still smooth for the face UI, and lowers display power.
Vendored as a local component because managed_components/ is gitignored
(same pattern as the esp_hosted fork) — a local component overrides the
same-named waveshare/ managed dependency, so the fix survives a clean
build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
Root cause (investigation): the BSP default tear-avoid mode is
TRIPLE_PARTIAL. On ESP-IDF 5.5 the MIPI-DSI driver has no
on_frame_buf_complete callback (added in IDF 6.0 -> the boot warning
"buffer-switch...may not function on MIPI DSI"), so the adapter falls
back to on_refresh_done, which fires every refresh (~60Hz) as a fake
vsync. In PARTIAL mode that release path is NOT submit-gated, so when
one LVGL frame takes >1 refresh to render it over-releases the buffer
that is still being scanned out -> LVGL draws into the live front
buffer -> tearing/flicker.
Why it only started with the wake word, and only under load: at idle
(sparse rain) a frame renders in <16.6ms so exactly one submit per
refresh -> harmless. The always-on WakeNet added constant CPU/PSRAM
load that pushed the heavy-rain frames (listening=16, thinking=40
streams) past one refresh -> triggered the over-release. User
correctly identified it as a resource starve exposing the latent bug.
Fix: switch to TRIPLE_FULL, which IS submit-gated even without the
callback (one release per actual submit). Same 3 framebuffers, zero
memory cost, app-side one-liner via bsp_display_start_with_config so
the managed component is untouched. Trade-off: full-screen redraw per
frame; if the rain gets choppy under load, cheaper rain rendering
(canvas / half-rate tick) is the follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Say "Computer" -> chime + listening -> speak -> AFE VAD detects you
stopped -> auto-sends. No taps. Touch still works as a manual override.
- esp-sr 2.4.6 added; wn9_computer_tts model packed into a new "model"
flash partition (MODEL_IN_FLASH). App moved to 8M, model 4M.
- audio.c: replaced the on-demand capture_task with an AFE pipeline —
feed_task is the SOLE mic reader (-> afe->feed); detect_task fetches,
watches wakeup_state for the wake word and vad_state for end of
speech, and forwards AFE-cleaned audio upstream during an utterance.
One mic reader ever.
- short rising chime acknowledges the wake audibly.
Fixes from adversarial review before trusting it:
1. utterance framing (blocking WS sends) moved OFF the AFE fetch thread
onto an app_task event queue (EV_TOUCH/EV_WAKE/EV_SPEECH_END) — a
1.5s send could stall fetch and drop the first ~1.5s of speech.
2. app_task is now the single serializer of start/end -> no TOCTOU
double-start (was: two utterance_start on a tap during wake).
3. VAD accounting resets on every streaming (re)start (wake OR tap),
not just wake -> a tapped utterance can no longer end instantly on
stale silence.
4. chime/reply set s_playing (+DMA tail hold) and detect_task skips the
mic while s_playing -> our own audio no longer streams into STT or
false-triggers the wake at a playback boundary (no AEC yet).
5. NULL-checked AFE create + feed buffer; tasks only start if AFE is up.
Verified on hardware: model loads, AFE inits with the Computer word,
boots and connects clean, no crash/wedge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause (verified against our exact IDF tree, not the community guess):
the "258" in "sdio_write_task: Failed to send data: 258" is NOT a timeout
(that is 263). 258 = 0x102 = ESP_ERR_INVALID_ARG. On the ESP32-P4, block-
mode CMD53 writes require the SOURCE buffer to be 64-byte (cache-line)
aligned; the IDF sdmmc driver rejects a misaligned source with INVALID_ARG
BEFORE any bus activity. esp_hosts write loop then declares "Unrecoverable
host sdio state" and reboots the whole P4. The audio TX payload is not
64-aligned, so streaming mic audio wedged on the very FIRST frame (which is
exactly what we saw: listening -> instant Failed to send -> reboot).
This also explains why buffer/queue/clock/retry tuning all did nothing: the
write never reached the bus. And why our symptom was instant, not after
~100 writes (the community block-mode-desync theory) — it is the first
misaligned buffer, every time.
Fix: vendored esp_hosted 2.12.11 as an editable local component (overrides
the registry copy) and bounce a misaligned TX payload through one aligned
DMA scratch buffer in hosted_sdio_write_block (port_esp_hosted_host_sdio.c).
TX is serialized by the bus lock so a single static bounce buffer is safe;
freed in hosted_sdio_deinit. Host-only change — no C6 reflash.
VERIFIED ON HARDWARE (autonomous self-test): 40s of continuous mic-audio
upstream streaming — the traffic that previously wedged on the first frame
— ran clean, zero timeouts, zero reboots. A guarded SDIO_TX_SELFTEST harness
is kept (compiled out) for future SDIO stress testing.
Credit: root cause + patch designed via multi-agent investigation; the
precise 258=INVALID_ARG decode (correcting the upstream community timeout
assumption) came from checking our actual esp_err.h.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the "blue screen on tap": NOT a firmware bug. The touch
fix worked (reached "listening"), and the mic audio then streamed
upstream — which instantly triggered the upstream SDIO wedge (#167):
sdio_write_task: Failed to send data: 258 (timeout) -> host restart
i.e. the same wedge we fought overnight, but now provoked reliably by
the sustained ~32KB/s mic upload instead of waiting minutes. The wedge
is a host->slave SDIO write timeout: the slave RX buffers fill because
its Wi-Fi TX cannot drain fast enough.
Attempt: restore the slave Wi-Fi TX path shrunk during the RAM hunt and
enlarge the SDIO queues so the burst is absorbed and drained:
- WIFI_RMT_DYNAMIC_TX_BUFFER_NUM 24->64, STATIC_RX 8->16, RX 24->32
- WIFI_RMT_TX_BA_WIN 16->32
- ESP_HOSTED_SDIO_TX_Q_SIZE / RX_Q_SIZE = 32
- SDIO clock 10->20 MHz for drain headroom (still 1-bit)
Boot verified: no RAM famine, connects clean. Whether it survives the
audio burst is the open question upstream has not solved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TOUCH CRASH (the blue screen): app_on_touch ran a blocking WebSocket
send (up to 5s) directly in the LVGL touch callback, stalling the
MIPI-DSI flush into a garbage/blue frame + task-watchdog reboot on
every tap. Now touch_cb only gives a semaphore; a dedicated app_task
does the blocking sends, audio, and face changes off the render
thread. WS send timeout cut 5s to 1.5s as belt-and-braces.
RAIN = REACHABILITY (user request): rain now falls only while the
gateway WebSocket is live (gated on gw_connected in rain_tick). It
drains gracefully on disconnect, resumes on reconnect, a genuine
glanceable reachable signal. Idle density bumped 2 to 4 so
connected-idle reads distinctly from disconnected-black.
CALM THE WEDGE CHURN (user request): transient wifi/WS drops no longer
slam to the x_x error face or a CONNECTING banner. Boot goes straight
to the calm idle face (dry until connected). Only a sustained 30s+
outage escalates to x_x (clock_cb); the ~15s wedge-recovery just shows
a brief rain pause.
Two fixes from adversarial concurrency review before flashing:
- persistent single capture task (was xTaskCreate per utterance; a
rapid re-tap or WS-stop-vs-app-start race could put two readers on
one mic/I2S handle and corrupt the codec)
- reset s_talking on disconnect (app_on_disconnect) so the first tap
after a mid-utterance drop starts fresh, not the stop branch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Night-watch caught a 21s wedge (vs 5-13min baseline) landing exactly
on the boot ping-diagnosis + wifi-stats-dump burst, which hammered the
freshly-recovered SDIO link on every reboot. That scaffolding proved
connectivity long ago; removing it makes each recovery light and clean.
ping_target/ping_diag_task kept as no-op symbols for manual use.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stops the RuntimeError spam ('Cannot call receive once a disconnect
message has been received') every time the device's watchdog reboots.
Redeployed to the tatlock-ui stack.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The BT-off custom slave (2.12.12) can't survive the C6's OTA rollback
protection: it flashes, the C6 boots it, reverts to 2.12.11, host
reflashes -> ~30s reboot loop, worse than the wedge. BLE-coex
hypothesis is UNTESTABLE via OTA (custom slaves won't stick; needs
direct C6 UART flash, out of scope tonight). Reverted to the
self-healing 2.12.11 baseline: ~5-13min MTBF, 15s auto-recovery.
That is the shippable overnight state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wedge clock is immune to every network-layer change (~300s
regardless of bus width, clock, power-save, DHCP). A slave-side
periodic starver remains; the C6's BLE controller (BT_LE_SLEEP_ENABLE=y
in stock slave, shares the radio/RF arbiter with Wi-Fi) is the prime
suspect. DeskLock uses no Bluetooth, so the stock slave carries a
coexistence scheduler we can delete outright. Custom slave 2.12.12
(CONFIG_BT_ENABLED=n, ESP_HOSTED_CP_BT=n), OTA'd over SDIO. If the
metronome stops, BLE coexistence was starving the SDIO peripheral.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four controlled experiments characterize it: 72-byte frame times out
at CMD53 after the slave advertised credits; bus width/clock/power
save have no effect; removing dhcpc stretches MTBF ~5min -> ~13min.
Slave-side peripheral stall, upstream bug. Device self-heals in ~15s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wedge metronome (~300s: 297/297/322/437s across bus widths,
clocks, and power modes) matches FRITZ DHCP T1 renewal at half of a
10-min lease. Every wedging build ran an active dhcp client. Static
addressing removes the renewal TX path entirely; if the metronome
dies, root cause #2 is the DHCP renew packet through hosted's write
path. (2.9.7 experiment abandoned: slave image bootloops/rolls back
on this C6; version matrix documented for upstream.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1-bit bus alone did not stop the sdio_write timeout wedge (recurred
at ~5min uptime). Modem power save is the new prime suspect: the C6
napping mid-SDIO-transaction matches the minutes-scale periodicity.
Always-powered appliance -> PS_NONE costs ~50mW and removes the
entire failure class if correct. SDIO clock also halved to 10MHz.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Night-watch caught the instability in the act at 7min uptime:
E H_SDIO_DRV: sdio_write_task: Failed to send data: 258 (timeout)
E H_SDIO_DRV: Unrecoverable host sdio state -> SW_CPU_RESET
i.e. esp-hosted-mcu#167. Known mitigation: 1-bit SDIO bus (#148),
still ~80x voice bandwidth. Also: recovery reboots no longer ring
the gong (esp_reset_reason gate) - a butler doesn't bong at 3am.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the dead data path (assoc/scan/RPC fine, zero data
frames): esp-hosted 1.4.x is formally incompatible with IDF 5.5
(esp-hosted-mcu#47) and the factory C6 slave firmware was ancient
(couldn't even answer a version RPC). Waveshare's examples pin 1.4.* —
do not follow them.
Fix:
- host: espressif/esp_hosted ^2.12 (+ esp_wifi_remote 1.6)
- slave: 2.12.11 network_adapter.bin embedded in the app (c6_ota.c
streams it to the C6 over the SDIO RPC channel at boot when the
reported version is < 2.x; ~10s, no wires, idempotent)
- RAM diet for hosted 2.x's footprint (MEMPOOL_PREFER_SPIRAM,
reduced WIFI_RMT buffers) — without it internal SRAM famine
boot-loops in xTaskCreateStaticPinnedToCore before app_main
- conservative 20MHz SDIO clock for first verified data path
Verified on hardware: DHCP lease (even that healed), 8/8 pings to
router and tower-of-joy at 1-5ms, WebSocket to the gateway connected,
device registered at /devices as desklock-p4 with fw version.
Also: wifi_diag.c L1 SoftAP diagnostic mode (DESKLOCK-DIAG) with
review fixes, boot ping ladder in net.c, static-IP fallback,
face_status() line, docs for the whole failure taxonomy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- gateway: GET /devices registry (per-IP connect count, timestamps,
device/fw from new 'hello' protocol event); graceful utterance
errors -> error event; uvicorn ws-ping-timeout 120s
- firmware: hello on WS connect; static 192.168.86.53 fallback after
15s without DHCP; scan-on-boot removed (kept for diagnostics);
ping_interval 8s; fonts regenerated --no-compress (compressed
glyphs render blank with LV_USE_FONT_COMPRESSED off); 8MB factory
partition; montserrat_28; LVGL lock timeout semantics fixed
- docs: sauron endpoint plan, power ladder, versioning policy
Known issue: 'Outside' AP association succeeds but no L2 traffic
flows (no DHCP, no ARP) — worked once at 22:05 then never again;
ToJ side verified clean. Investigation ongoing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The device now runs the complete face contract on glass: seven states
with glyph expressions (custom 140px face font), matrix rain as pooled
label streams (22px DejaVu+Noto katakana font), rage kaomoji frames
(64px), orbit arc + elapsed counter wait cues, blink/talk/thought
animations, idle clock (SNTP, Europe/Amsterdam), and the power ladder
(active/ambient 35%/dormant 5% with rain parked).
Voice path: ES7210 mic capture task streams 16k PCM over
esp_websocket_client to the gateway; reply PCM buffers to PSRAM and
plays via the audio task; touch-to-talk (tap to speak, tap to send).
Protocol mapping per docs: thinking->effort, audio->speaking,
error event->rage (auto-composes after 2 loops), WS loss->error face.
Bring-up fixes: 8MB factory partition (fonts overflowed 1.5M),
bsp_display_lock(0) is try-lock in this adapter (use UINT32_MAX),
gong synth yields to feed IDLE0, wifi scan diagnostics (found SSID
case mismatch), esp_websocket_client pinned ~1.3 (1.4 needs IDF>5.5).
Verified on hardware: boots, connects to Wi-Fi and holds an open
WebSocket to the gateway. Sauron (iMac TTY endpoint + ops console)
planned in architecture.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User verdict: audible and butler-non-intrusive on the bare 2W speaker.
Recorded as the house sound signature in architecture.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Additive gong synthesis: seven inharmonic partials with per-partial
decay (fundamental 165Hz rings 2.6s, highs die in 0.2s), a detuned
pair for shimmer, 45ms distant attack, and three feedback-comb
reflections (95/210/370ms, net gain .93) for a dense stone-space tail.
Normalized to -12dBFS at codec volume 58: quiet but voluminous.
Synthesized into PSRAM at boot (~4s), plays 5s, blocking is fine for
bring-up — audio moves to its own task in phase 2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
C5->E5 half-second chime with fade-out at boot, right after the face
renders. Codec opens in slave mode, esp_codec_dev write path works.
Phase 1 bring-up is now complete: display, touch, audio, 200MHz PSRAM.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- firmware boots to a rendered LVGL face in 1.57s (eyes + smile + tag)
- sdkconfig: CONFIG_IDF_EXPERIMENTAL_FEATURES=y unlocks SPIRAM 200MHz
(silently degraded to 20MHz before -> MIPI-DSI underrun -> LVGL lock
starvation -> task watchdog); mirrors official 08_lvgl_demo_v9 config
- architecture.md: power management section (user prime concern) —
active/ambient/dormant/night ladder, levers, hard edges, <=300ms wake
- AGENTS/CLAUDE: replace stale bring-up warnings with verified commands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gateway service now lives in system-admin-toj/containers/stacks/
tatlock-ui.yml (shared docker-dataplane network: speaches by service
name, host-run Tatlock by LAN IP). Drop deploy/desklock-gateway.yml.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live smoke test showed /v1/chat/completions replies open with the
Steward's <think> block (Open WebUI convention) — without stripping,
the device would speak the internal monologue aloud.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Settles the Python version question: floor >=3.11, no ceiling.
Container moves to python:3.13-slim, CI tests on 3.13. The embedded
Piper fallback resamples with numpy (already present via [speech]).
Verified with a live round trip at 16 kHz.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>