Repository navigation
Conversation
0Downtime
added a commit
to 0Downtime/StackChan
that referenced
this pull request
Aug 24, 2026
* face_tracking: phase 0 instrumentation + phase 1 deadband/EMA tuning
Phase 0 — bench-baseline logging. One ESP_LOGI per 5s window emits
samples / detections / unique-frame FPS / time-in-Tracking / servo
command count. Counters reset per window. Cheap to leave in place;
gives a same-format comparison across phase 1+ tuning runs. Bench
procedure documented in dotty-private/probes/face-tracking-naturalness.md.
Phase 1 — constant tweaks to fix the "head freezes once locked" feel:
- kEmaAlpha 0.5f → 0.7f: small natural face sway now actually moves
the smoothed target instead of being damped halfway.
- kDeadbandFrac 0.06f → 0.02f: 3% deadband per axis → 1%, so
breathing / micro-shifts clear the threshold and re-issue commands.
- kLookAtSpeed unchanged at 500.
Comment block records prior values + revert path for each constant
(one-line back-out if it feels twitchy or chattery on bench).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* face_tracking: phase 2 — idle-motion overlay during tracking lock
Replace IdleMotionModifier::pause/resume with setTrackingMode(bool).
While a face is locked the modifier still runs, but emits a reduced,
longer-cadence drift action set instead of going silent. Goal: Dotty
keeps an alive gaze without snapping off the locked face.
idle_motion.h
- New setTrackingMode(bool). Idempotent. On false→true, records
_tracking_entered_ms so the overlay can gate on steady-lock duration;
on true→false, kicks _next_tick to +500 ms so idle motion resumes
promptly instead of waiting a full 4–8 s.
- New perform_tracking_overlay(): keeps Small observation only (halved
ranges, ±75° yaw / ±40° pitch vs the idle ±150° / ±80°), drops Random
look and Quick glance entirely (would snap off the face), keeps
Return-to-center but gated on >5 s of continuous lock so we don't
yank away the moment a face is acquired.
- Overlay interval 12–24 s (vs idle's 4–8 s) — kTrackingOverlayMin/MaxMs.
- _paused replaced by _tracking_mode; the only old caller paired
pause→resume with the new tracking lifecycle, so the rename is a
semantic clean-up too.
face_tracking.{h,cpp}
- pauseIdleMotion/resumeIdleMotion collapse into setIdleTrackingMode(bool).
- Idle→Tracking now calls setIdleTrackingMode(true); GracePeriod expiry
calls setIdleTrackingMode(false). Same name-resolution guard against
a removed modifier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* py32_io_expander: cache REG_LED_CFG to kill refreshLeds read flood
Port of v1.3.0 fix from dotty-privacy-led-step-1-3 (commit 8513f9d)
so this branch carries the fix when face-tracking phase 0/1/2 work
eventually merges back. Identical change: cache the LED count at
setLedCount() time and drop the read-modify-write in refreshLeds().
Build verified locally; not flashed (device runs v1.3.0 from the
LED branch).
* face_tracking: phase 3 — chat-state-aware profiles
Keep face_tracking + idle_motion alive across LISTENING / SPEAKING /
STANDBY (Option C from the plan). Per-state behaviour is driven by a
ChatProfile pushed from stackchan_display.cc::SetStatus on each chat-
state transition. Solves the user-observed "head sits frozen during
chat states" — the previous lifecycle removed both modifiers off-IDLE,
so no amount of EMA tuning could move the head while Dotty was
listening or talking.
face_tracking.{h,cpp}
- ChatProfile struct (alpha, overlay min/max ms, amplitude_scale,
allow_wake_word_invoke). Three inline constexpr profiles in the
header: kChatProfileIdle (0.7 / 12-24s / 1.0x / wake-word YES),
kChatProfileListening (0.8 / 8-12s / 1.0x / no), kChatProfileSpeaking
(0.4 / 30-45s / 0.5x / no).
- setChatProfile(): updates _alpha + _profile, threads overlay
cadence/amplitude through to IdleMotionModifier via the new setters.
- WakeWordInvoke("face") now gated on _profile.allow_wake_word_invoke.
Only IDLE has it true; a face re-acquired mid-chat no longer stomps
on the running session.
- Adds kName + name() override so SetStatus can resolve the modifier
by name (same pattern already used for idle_motion).
idle_motion.h
- setIntervalRange(min, max): drives the tracking-overlay cadence
(Phase 2's kTrackingOverlayMin/MaxMs become defaults).
- setAmplitudeScale(float): multiplies the small-observation deltas
in perform_tracking_overlay (1.0 = Phase 2 default ±75°/±40°).
- Full-idle 4-8s interval and action mix are unchanged — Phase 3
only tunes tracking-overlay behaviour.
stackchan_display.cc::SetStatus
- Lazy-create face_tracking + idle_motion at function entry (any
chat state, only when not sleeping). They stay alive for the whole
session; only the sleep handler still removes them.
- Each chat-state branch (LISTENING / STANDBY / SPEAKING) sets
profile_to_push to the matching ChatProfile.
- Unified setChatProfile dispatch after the if-chain.
- if (is_idle) / else block now only manages idle_expression
(separate concern: face expressions, not gaze) and the cyan right
LED ("ready, watching for you" UX semantics — IDLE-only).
Note — does not address the destructive-farewell modifier-removal bug
(tasks.md, separate). Phase 3 makes the regression more visible since
a livelier head goes more dead-eyed when the modifiers stay removed
post-farewell. That bug should be fixed before this ships in earnest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* stackchan_camera: pause head servos during still capture
Capture() now holds Motion::setModifyLock for the V4L2 dequeue window so
face-tracking, idle-motion, and IMU shake reactions don't move the head
mid-shutter. RAII guard mirrors the adjacent ArbiterGuard so every return
path (including errors) clears the lock. Closes the matching gap in
face_tracking by gating _maybeIssueLookAt on isModifyLocked(), same
pattern as idle_motion.h:133/169 — keeps the EMA / state machine /
face_detected events live through the pause so post-shutter commands
target the fresh face position. Avatar blink/expression deliberately
left alive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Revert "py32_io_expander: cache REG_LED_CFG to kill refreshLeds read flood"
This reverts commit a8ed1d1bf255460f63f6cd952c1aec6618d7a813.
* firmware: bump PROJECT_VER 1.3.0 -> 1.3.1
Releases the face-tracking-naturalness work (phase 0/1/2/3) plus
ef29d59 (motion pause during still capture) on top of the privacy-LED
v1.3.0 base. fw-v1.3.0 was cut on dotty-privacy-led-step-1-3 alone;
this is the first release containing both feature stacks merged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* privacy_leds: WAN-bound semantics + bridge-driven upload signal
The privacy LEDs were "device peripheral active." That undersells the
threat model: the user cares about data leaving the LAN, not about the
mic/camera ADC being open. Switch the contract — pulsing now means
WAN-bound, steady means local-only.
- Rename MicState::Stream -> WanBound; extend CameraState with
Uploading. Reconcile against an explicit bridge-driven flag instead
of the old audio-service-derived poll, which conflated "audio
service is ticking" with "audio leaving the device" and was
effectively dead (the upgrade path was never reached).
- Add Privacy (0x1A) avatar-WS message: JSON {"mic": "upload_start"|
"upload_end"|"none", "camera": ...} routes through PrivacyLeds public
setters that preserve the friend-class invariant — only PrivacyLeds
writes pixels 6/11 via setRgbColor_privacy_only. Bad/missing fields
are logged and ignored.
- 2 s failsafe timeout in update(): a missed upload_end (bridge crash,
network drop) self-heals back to steady. No way for the LED to lie
about WAN-bound state for more than 2 s after the last signal.
- get_privacy_state MCP tool string mapping updated to the new enum
names (wan_bound, active, uploading) so the bridge / xiaozhi side
reports the new contract truthfully.
Bridge-side wiring (bridge/privacy_signal.py + camera_upload_pulse
context manager around _call_vision_api) ships in dotty-stackchan; the
firmware-side WS handler is ready but no transport is currently
deployed (avatar WS server isn't running), so this lays the
foundation. Follow-up: wire bridge -> firmware transport (override
get_server_url() to a bridge-hosted endpoint, or stand up the
upstream Go server), and find a mic ASR observation point so
setMicWanBound also fires.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* sound_localizer: Instance() singleton + ring buffer for wake-time direction snapshot
The ambient sound localizer already emitted sound_event(direction) on
direction change, but the existing 750 ms emit-cooldown means a wake-word
landing inside that window has no recent direction info. Restructure so
every stereo frame writes per-channel energy into a 200-slot ring (~2 s
at 10 ms frames) regardless of cooldown, and expose GetRecentDirection
(window_us) to aggregate L/R energy across the most recent window.
- Promote SoundLocalizer to a function-local-static singleton via
Instance() so HandleWakeWordDetectedEvent in xiaozhi-esp32 can read
it. The audio task (writer) and main task (reader) coordinate via
std::atomic<size_t> on _ring_head with release/acquire semantics —
no mutex; tolerated torn slot reads are dominated across the ~150
entries in a typical aggregation.
- OnStereoFrame: HPF + per-channel energy now run unconditionally and
feed the ring before the cooldown / energy / direction-change gates
decide whether to emit a sound_event. Cost is ~320 FPU ops per
10 ms frame on the S3 — negligible.
- stackchan_display.cc: switch the registered callback from a function-
local static to SoundLocalizer::Instance(), so the same instance is
visible to the wake-word handler.
Server consumer that uses this (_perception_wake_word_turner) is
already in bridge.py (dotty-stackchan 564b71d).
* idle_motion: empty-room backoff slows servo cycles when no face for 2 min
Servo-lifespan lever from the tasks.md "minimise total servo movement"
audit (private repo, line ~157). DS-style hobby servos have a finite
gear/potentiometer cycle count; today Dotty fires idle motion every
4-8 s continuously when no face is in frame, which is wasted wear in
unattended rooms.
When the empty-room timer (>120 s since the last face_lost transition,
or since boot) has elapsed AND we are not currently in tracking mode,
the next idle delay is drawn from 15-30 s instead of the constructor's
4-8 s. Cuts idle cycles roughly in half during overnight / nobody-
home periods. Snaps back to the normal 4-8 s mix the moment a face
is re-acquired (setTrackingMode(true) → setTrackingMode(false) re-
stamps _last_tracking_active_ms on the way out).
Preserves the alive feeling — the head still drifts, just less often
when there's no audience to perform for. Doesn't touch:
- tracking-overlay cadence (12-24 s) — unchanged
- the four-action mix (random look / small observation / quick
glance / return-to-center) — unchanged
- constructor defaults (4-8 s) when an audience is present
- face-tracking deadband (kDeadbandFrac) — left at 0.02f per the
cpp comment history; widening that is a separate decision
Bench-verification pending (need to flash + watch idle behavior over
~5 min with no face, confirm slower cadence; then walk in front and
confirm normal cadence resumes within a few seconds).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* identity: cull dlib biometric face-recognition path (firmware) (#1)
* identity: cull dlib biometric face-recognition path (firmware)
Companion to dotty-stackchan PR #2. Description-based identity (Layer 4 v1.5 — VLM matches a free-form description against household.yaml's appearance: field) is the sole identity path; on-device biometrics are gone. Build green: stack-chan.bin 0x4fa720 bytes, 3% free.
Removed:
- firmware/main/stackchan/face/face_recognizer.{cpp,h} — NVS-backed enrollment cache + ESP-DL embedding stub (recognize() always returned kUnknown)
- firmware/main/stackchan/face/parental_gate.{cpp,h} — PIN gate for face_unlock
- firmware/main/stackchan/face/PRIVACY.md — doc for the dlib path
- face_detector.cpp recognition + emission block: FaceRecognizer::init(), the per-frame recognize() call, the JSON-escape + SendEvent("face_recognized") path, and the supporting state members (_last_recognize_ms, _last_emitted_ms, _last_emitted_identity, kRecognizeThrottleMs, kEmitStaleMs)
- 4 face MCP tools in hal_mcp.cpp: self.camera.face_enroll / face_recognize / face_forget / face_list
- StackChanCamera::EnrollFace / RecognizeFace / ForgetFace / ListFaces method bodies + DeriveFaceUrl + SimpleBridgeRequest helpers (only the face methods used them)
- Stale #include "face_recognizer.h" + #include "application.h" + <string> in face_detector
Kept (description path needs these):
- face_detector.cpp: ESP-DL detector that writes bbox to FaceDetectionResult
- face_tracking.cpp: emits face_detected / face_lost on the perception bus
- StreamJpegToBridge in stackchan_camera.cc: multipart helper used by Explain() to feed the VLM (room_view / take_photo path)
- Capture() / Explain() MCP tools
Server-side coordination:
- The bridge listener in custom-providers/xiaozhi-patches/textMessageHandlerRegistry.py still accepts both face_detected and face_recognized(identity=unknown) — that branch becomes dead-but-harmless after this firmware ships, and a small server-side cleanup PR will drop it in coordination with the flash.
Net: 1223 lines removed, 8 added across 10 files. Zero orphan refs (FaceImage, kRecognize*, _last_emitted*, DeriveFaceUrl, SimpleBridgeRequest, EnrollFace, RecognizeFace, ForgetFace, ListFaces, FaceRecognizer, ParentalGate all gone).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* display: drop 3 set-but-not-used vars in SetStatus
motion (line 548), is_listening (579), is_speaking (580) are all assigned but never read in StackChanAvatarDisplay::SetStatus. Likely vestiges of an older code path that branched on these flags before the ChatProfile push pattern landed. Compiler had been emitting -Wunused-variable / -Wunused-but-set-variable for them on every build.
No behaviour change. Build still green: stack-chan.bin still in 3% free envelope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* face_tracking: capture-pending guard fixes Layer 4 recognition race
Identification was intermittent because the head could move between the
face_detected emit and the bridge's take_photo MCP call arrival,
producing "no one in view" failures. The existing MotionPauseGuard inside
StackChanCamera::Capture() only covers the ~200-500 ms still itself, not
the gap before it.
CapturePendingGuard now spans the whole window: acquired immediately
before SendEvent("face_detected"), released on (a) the next observed
Capture (lastCaptureTimestampMs ticks past the baseline), (b) face_lost
firing before take_photo arrives, (c) a 5 s ceiling if take_photo never
shows (bridge container down, network blip), or (d) modifier teardown
on sleep entry.
Motion::setModifyLock(bool) is now a refcounted std::atomic<uint8_t> so
the new long-lived outer guard composes with Capture's inner guard and
the IMU shake reaction without any of them silently clearing each
other's hold. Underflow on over-release is clamped at 0 with a warning
log rather than wrapping to 255.
PROJECT_VER unchanged at 1.3.1 (intended bump to 1.3.2 didn't land
pre-build; will bump on next firmware change rather than rebuild now).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* face_tracking: re-seed EMA on capture-pending guard release
Side effect of the capture-pending guard: head freezes for ~2 s during
take_photo, but face_detection_result keeps writing fresh frames the
whole time. _smooth_x/y EMA-track those frames, so when the guard
releases, _maybeIssueLookAt fires with _last_cmd_valid=false and the
deadband is bypassed — the first command targets a position that has
been EMA-blended over a 2 s window of slight head movements, producing
a visible "spin" snap as the head catches up.
Fix: on guard release (capture-observed or timeout-release path), if a
face is currently detected, re-seed _smooth_x/y from the live raw
position. The next servo command then targets the actual current face
location, not the drifted EMA value, eliminating the snap.
Brett observed the spin live in the bench session; verified by xiaozhi
log showing match=brett every cycle while head was perceptibly catching
up post-photo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* patches: i2c_device defensive — log + sentinel on I2C failure
Upstream xiaozhi-esp32 I2cDevice::ReadReg/WriteReg/ReadRegs use
ESP_ERROR_CHECK on the i2c_master_transmit_receive call, which aborts
the device on ANY I2C glitch. Bench session caught this in action: a
single AXP2101 PMIC battery poll failed during a SPEAKING window
(audio_detection task hogging CPU 1, AFE FEED overflowing, I2C
scheduling slip), ESP_ERROR_CHECK aborted, device rebooted mid-sentence.
Replace the ESP_ERROR_CHECK calls with explicit error handling:
- ReadReg: log warning + return 0xFF sentinel (unlikely real value
for most regs; AXP2101's IsCharging/IsDischarging both return false
on this since 0xFF >> 5 = 3, not 1 or 2)
- WriteReg: log warning, return silently
- ReadRegs: log warning, leave buffer untouched (caller must not assume
valid contents)
Constructor's ESP_ERROR_CHECK on i2c_master_bus_add_device stays — that
is a hard boot-time failure where aborting is correct.
Battery display may briefly show "255%" on a glitch instead of a real
value. Massively preferred to the device rebooting.
Does NOT fix the root cause (the SPEAKING-state CPU/audio cluster that
generates the I2C glitches in the first place — separate task entry).
This patch turns crashes into logs so we can keep diagnosing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* sound_localizer: drop direction-change gate + halve energy threshold
Claps stopped registering entirely after the HPF was added (commit
57e12cb, ~300 Hz cutoff for HVAC rejection): clap energy through the
filter sat below the original kEnergyThreshold of 1e9, and the direction-
change gate was silently dropping any clap from the same side as the
last emit (e.g. the boot self-test fires `left`, then every left-side
clap gets filtered). Brett bench-confirmed: zero sound_event lines from
multiple clap attempts across the session.
Two changes:
- Remove the `if (dir == _last_dir) return;` gate. The 750ms cooldown
alone bounds emit rate to ~1.3 events/sec, which handles the original
spam concern for sustained sounds. _last_dir kept around in case
future logic needs it.
- kEnergyThreshold: 1000000000 → 500000000. First-iteration guess —
drop further if claps still don't fire, raise if background chatter
spams the bus.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* idle_motion: profile-driven action set + drop quick-glance branch
Phase 3 of the modes refactor. Adds IdleProfile enum (NORMAL,
LOOKING_AROUND, SLEEPY, SURVEILLANCE) + per-profile parameter table
+ setIdleProfile() setter. Replaces the 4-action random branch with
3 profile-shaped actions (small offset 60%, gentle look 25%, return
to centre 15%) and drops the ±500° / speed-250-400 "quick glance"
branch that produced the violent post-photo head-snap.
Default cadence bumped 4-8s → 8-15s. NORMAL profile uses ±31° yaw
delta, ±16° pitch delta, speed 80-180 (gentle). The new profile
setter is unwired today — Phase 4 ModeManager will call
setIdleProfile() on mode entry. Tracking-overlay path (face-locked)
untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: Phase 4 StateManager — six-state mutex + LED contract
Lays the rails Phases 5-8 hang behaviour off. One firmware modifier owns
the high-level state machine, drives the LED contract, and emits
state_changed events back to the bridge.
- NEW stackchan/modes/state_manager.{h,cpp} (~210 LOC):
- State enum: idle / talk / story_time / security / sleep / dance.
- State pip writer on left ring index 0; toggle pip writers on right
ring indices 8 (kid_mode warm pink) and 9 (smart_mode orange).
- 5 Hz re-assertion so chat-state full-ring writes don't permanently
clobber the pips.
- SECURITY pip flashes white at 1 Hz (phase computed from age since
state entry).
- applyIdleProfile() maps each state to an IdleProfile (SECURITY ->
SURVEILLANCE, SLEEP -> SLEEPY, else NORMAL).
- emitStateChanged() publishes "{\"state\":\"<name>\"}" through the
same Application::SendEvent pipe as face_detected / head_pet_*.
- onFaceDetected / onFaceLost hooks for camera-edge transitions
(IDLE <-> TALK only — sticky states ignore camera edges).
- hal_mcp.cpp: add `self.robot.set_state(state)` and
`self.robot.set_toggle(name, enabled)` MCP tools so the bridge and
xiaozhi-server admin relay can drive the StateManager.
- face_tracking.cpp: call StateManager::onFaceDetected /
StateManager::onFaceLost alongside the existing SendEvent emissions.
Local transitions land before the bridge round-trip so the state pip
updates immediately on camera edges.
- stackchan_display.cc: register the StateManager modifier alongside
Breath/Blink/HeadPet/ImuEvent in SetupUI() so it's alive across all
chat-state lifecycles, not coupled to LISTENING/SPEAKING/STANDBY.
- main/CMakeLists.txt: add CONFIGURE_DEPENDS to the GLOB_RECURSE so
newly-added .cpp files (e.g. state_manager.cpp) are picked up by
incremental builds without a manual `idf.py reconfigure`. Hit this
trap once during the Phase 4 build — the new file linked clean as
a header but produced no .o because GLOB had been cached at
configure time. CONFIGURE_DEPENDS makes CMake re-glob each build.
- idle_motion.h: bundle the post-Phase-3 dwell fix that's been sitting
in the working tree — face_lost grace-expired no longer kicks
`_next_tick = now + 500`. With Phase 3's slower idle cadence the
500 ms hop produced a "big move away" the moment the user walked
out of frame after a photo. Use the normal interval range instead
so the head settles before the first post-face-lost idle action.
Authoritative state taxonomy + LED contract + transition diagram live
in dotty-stackchan/docs/modes.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: revert CMakeLists.txt CONFIGURE_DEPENDS — ESP-IDF rejects it
Shipped CONFIGURE_DEPENDS as a "tidy-up" with d78118b expecting it would
auto-detect new .cpp files on incremental builds. Build broke on the
next add (Phase 5 head_pet.cpp edit reconfigured CMake) with:
CMake Error at /project/main/CMakeLists.txt:4 (file):
CONFIGURE_DEPENDS is invalid for script and find package modes.
ESP-IDF's component_get_requirements phase evaluates each component's
CMakeLists.txt in script mode (via __component_get_requirements ->
include() of the file in a stripped context). CONFIGURE_DEPENDS is
explicitly disallowed in CMake script mode. The reason d78118b's build
appeared to succeed was that the GLOB cache had been refreshed by an
earlier `idf.py reconfigure` — CONFIGURE_DEPENDS itself never executed
at runtime.
Going back to the plain GLOB. Workaround when adding new .cpp under
main/ is `idf.py reconfigure && idf.py build` (now documented in the
firmware skill's build-errors.md). Inline comment in CMakeLists.txt
explains the trap so the next person doesn't try the same fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: Phase 5 — sleep state behaviour (pose, torque-off, wake hooks)
Hangs the actual sleep behaviour off the Phase 4 rails. Three trigger
paths wake Dotty: face_detected (camera stays on), capacitive head pet,
and the "wake up" voice phrase (already wired in Phase 4 via the
bridge's _send_set_state path).
state_manager.h:
- New onHeadPet() public hook for capacitive-touch wake.
- New private onEnterSleep() / onExitSleep() — sleep edge side-effects.
- New _sleep_torque_release_pending member tracks the deferred torque
release (we can't kill torque mid-pose without freezing the head
wherever it happens to be).
state_manager.cpp:
- setState() runs onEnterSleep() / onExitSleep() on the entry/exit edge
AFTER applyIdleProfile() (so the SLEEPY profile is in place when we
lock idle_motion out) and BEFORE emitStateChanged() (so the bridge
sees the event after the firmware is fully in its new pose).
- onEnterSleep() takes the motion modify lock to mute idle_motion's
micro-moves during sleep, sets avatar to Emotion::Sleepy + speech
bubble "Zzz…", initiates moveWithSpeed(0, 450, 80) for the face-down
pose, and arms _sleep_torque_release_pending. Direct avatar API —
bypasses StackChanAvatarDisplay::SetEmotion("sleepy") which has its
own legacy hard-sleep path (face detector off, modifiers removed)
that we explicitly do NOT want here.
- onExitSleep() re-enables servo torque BEFORE any motion command
(servos can't move while torque is off), kicks the wake-up tilt
moveWithSpeed(0, 70, 80), releases the modify lock, sets avatar
back to Neutral + clears the speech bubble. If face_tracking is
acquiring at the same time (SLEEP -> TALK via face_detected),
its lookAt overrides the wake pose within ~30 ms — that's the
desired feel: "Dotty looks up, then at you".
- _update() releases servo torque the moment motion.isMoving() goes
false, then clears the pending flag. Cheap (one flag check + one
isMoving() per tick) and only fires once per sleep entry.
- onFaceDetected() extends to also fire from SLEEP. Goes straight to
TALK (not IDLE) so the conversation path engages without an extra
state hop.
- onHeadPet() wakes from SLEEP -> IDLE. Goes to IDLE rather than
TALK because there's no face yet — user touched the head, they
may or may not be in front of the camera; face_tracking's normal
IDLE -> TALK on detection takes over from there.
modifiers/head_pet.cpp:
- On the press edge (where head_pet_started already gets emitted),
call StateManager::onHeadPet(). Internal state-gating means it's
a no-op outside SLEEP; included unconditionally so the wake path
doesn't depend on the bridge round-trip latency.
Phase 4 already wires the "wake up" / "come back" / "are you there"
voice phrases via receiveAudioHandle.py + _NON_CONVERSATIONAL_STATES,
so no bridge-side change is needed for that wake path.
Build verified clean, binary 5,233,296 bytes (+3.5 KB from Phase 4),
3% partition still free.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: raise sound_localizer kEnergyThreshold 5e8 -> 1.2e9
Ambient conversation cluster (500M-900M observed in live data 2026-04-27)
was drowning the activity feed. 1.2B sits above the conversational band
while still catching genuine claps (1B+). Threshold history captured in
the comment block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: Phase 6 - security state pan + angry face
Wires the SECURITY entry/exit hooks into setState (mirroring Phase 5's
sleep hooks) and implements the deliberate "robocop" surveillance pan:
- onEnterSecurity takes the motion modify lock, latches the avatar to
Angry, spawns a dedicated Core 1 FreeRTOS worker (sec_pan, 4 KB) that
sweeps yaw -500 -> +500 -> 0 at speed 50, with 3 s settle + 1 s/4 s
dwells (~14 s cycle). Lock + atomic flag + binary semaphore pattern
matches face_detector's start/stop teardown.
- onExitSecurity signals the worker, waits up to 2 s for clean exit,
releases the lock, goHome(80), restores Emotion::Neutral.
idle_motion stays on the SURVEILLANCE profile under the lock (no random
nudges fight the deliberate sweep), and the motion lock is refcounted so
camera/face-tracking guards still nest cleanly inside.
Also refines onEnterSleep pose: moveWithSpeed(0, 450, 80) -> goHome(80).
Reads as "Dotty has gone still" rather than "Dotty is drooping," which
is the deliberate feel for sleep on a 7-DOF servo where pitch=450 was
near the limit anyway.
Pairs with bridge/security_watch.py + the /xiaozhi/admin/take-photo
relay to drive the 20 s photo cadence; that work lives in dotty-stackchan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: emit named-movement perception events
Adds five new SendEvent calls so the bridge / dashboard can see what
the head is actually doing instead of inferring it from state_changed
alone. All emits use the existing Application::SendEvent(name, json)
contract — no new transport.
state_manager.cpp:
- dance_started / dance_ended around emitStateChanged() in setState().
Carry the prev/next state name so the bridge can correlate.
- sleep_pose ("phase":"wake_tilt") emitted in onExitSleep() right
before the wake-tilt moveWithSpeed, so the dashboard sees the
motion command even though it doesn't see the servo write itself.
- security_pose ("phase":"scan_start") emitted in onEnterSecurity()
on the entry edge. One emit per pose tenure rather than one per
pan leg — the per-leg phases would be debug-grade and noisy.
idle_motion.h:
- idle_motion emitted in perform_idle_motion() with action
(small_offset / gentle_look / return_to_center) and profile
(normal / sleepy / surveillance / looking_around). Fires on the
actual servo move, not next-tick scheduling, so the dashboard
event aligns with what the user sees.
- New profileName() helper for IdleProfile -> string.
Pairs with dotty-stackchan companion changes — bridge gates autonomous
photos / TTS on dance_active and broadcasts head_turn perception
events from the sound + wake-word turners, so the perception feed
covers every named movement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: state arc + listening pixel; drop privacy LED subsystem
LEDs are now state/mode-only. Left ring 0-5 paints the current mutex
state (idle off, talk dim green, story warm, sleep dim blue, security
white flash, dance rainbow). Right 8/9 stay as kid_mode/smart_mode
toggle pips. Right 6 becomes a steady-red listening indicator while
xiaozhi is in LISTENING (the only chat sub-state worth a turn-taking
LED — thinking and speaking live on the face avatar).
Privacy LED system removed wholesale: the friend-class guard in Hal,
PrivacyLeds + Mic/CameraState enums, the Privacy WS DataType handler,
the get_privacy_state MCP tool, and the boot self-test all gone. The
indicator was always-on (mic+camera are always live) so it carried no
signal. CameraPeripheralGuard's V4L2 stream lifecycle survives — moved
to stackchan/camera/camera_stream_guard.{h,cpp} as CameraStreamGuard.
Talk colour migrates dim cyan (0,40,60) -> dim green (0,60,0) so the
firmware aligns with the dashboard's existing #22c55e.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: refresh stale set_left_leds comments in state_manager
set_left_leds was repurposed to drive only the right-ring listening
pixel — it no longer paints the left ring, so comments referencing it
as a chat-state full-ring clobber are stale. Replace with the actual
purpose of the 5 Hz tick: SECURITY flash phase + defense-in-depth
re-assert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: move listening pixel from right 6 (top) to right 11 (bottom)
Bench feedback (2026-04-28): the listening pixel reads more naturally
at the bottom of the right ring — keeps it visually separated from the
toggle pips at 8/9. Functionally identical, just a relocation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: drive idle ↔ talk on xiaozhi LISTENING/STANDBY edges
Bench feedback (2026-04-28): with face_detector currently broken
(StreamCaptures failing), the talk arc never lit because face_detected
never fired. The OLD chat-state animation masked this by painting the
left ring green from xiaozhi's LISTENING handler regardless of mutex
state; the new state-arc design only renders the mutex, so the gap
became visible.
Fix: voice activity is the second presence signal. xiaozhi LISTENING
now triggers IDLE/SLEEP -> TALK and STANDBY triggers TALK -> IDLE,
mirroring face_detected/face_lost. Sticky states (story_time, security,
dance) own their own exits as before. Either signal is sufficient on
its own — face_detected and voice both drive the same transitions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: hide status-bar clock until SNTP confirms a time sync
The on-board PCF8563 RTC boots with stale or factory-default time when
its coin battery is missing/depleted, producing an obviously wrong
clock display (e.g. "9:00pm" on every cold boot). Bench feedback
2026-04-28.
Fix: track an explicit `_sntp_synced` flag set from the existing
`time_sync_notification_cb`. Expose via `Hal::isTimeSynced()`. Status-bar
TimeLabel renders "—:— ——" until the flag flips, then renders the
real time. Brief "syncing" placeholder is better than confidently-wrong
time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: resync bridge state on boot + on idempotent setState calls
Two related state-desync bugs surfaced at the bench (2026-04-28):
1. Bridge cached "talk" across firmware reboots — firmware boots in
IDLE but never emits state_changed because no transition happens,
so the dashboard kept showing "talk" indefinitely.
2. Clicking the dashboard's current state (e.g. "Idle" while firmware
is already IDLE) was a no-op — setState early-returned on
next == _state, so no state_changed fired and the desync persisted
even when the user tried to re-sync manually.
Fix:
- setState always emits state_changed, even when next == _state. The
transition logic (entry/exit hooks, dance edge events, etc.) still
only fires on a real change. The bridge can now treat
setState(currentState()) as a deliberate re-sync.
- On the first xiaozhi STANDBY after boot, stackchan_display.cc calls
setState(currentState()) to force a re-emit. Bridge wakes from
reboot with the correct firmware state, no user action needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: StateManager owns full right LED ring + face indicator
Right ring (6-11) is now wholly owned by StateManager::writePips and
re-asserted at 5 Hz so MCP writes / dance keyframes can no longer
permanently clobber any indicator. Pixel 6 = face state (yellow=detected,
green=identified, mutex), 7+10 reserved-and-locked-off, 8 kid_mode,
9 smart_mode, 11 listening.
New self.robot.set_face_identified MCP tool (no args) — bridge calls it
after a roster match; firmware lights pixel 6 green for 4 s with
auto-revert. Face-detected promotes pixel 6 to yellow via the existing
onFaceDetected hook; face_lost extinguishes.
set_led_color and set_led_multi restricted to LEFT ring only; right-ring
writes rejected with warn log. Tool descriptions tightened to make the
contract explicit.
Listening pixel writes now route through StateManager::setListening and
emit a chat_status perception event (edge-deduped) so the bridge can
mirror listening state on the dashboard.
Keyframe::apply no longer writes the right ring (compile-time
enforcement on top of the runtime tick). Built-in dances unaffected
(rightRgbColor was always empty); custom JSON dances will see the
field preserved on Keyframe but not applied to hardware.
Closes the audit-discovered stuck-pixel-6 (white) and listening-pixel
race conditions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: head-touch debounce — raise threshold + 3-sample debounce
Bench observation: ~1.3 spurious head_pet events/minute when nobody was
touching the robot. Two changes work together to suppress them:
1. Raise touch_threshold from 1 (LOW, sat at the SI12T noise floor) to
2 (MID). Single-level filter that drops the bottom of the noise band.
2. Add debounce_samples=3: require three consecutive 50 ms samples of
is_touched() before accepting the IDLE→TOUCHED transition. Filters
sub-150 ms capacitive blips (RF/EMI/proximity drift) without making
deliberate pets feel laggy. Counter resets on any non-touched sample
or on state exit.
is_touched() now takes the threshold as a parameter so the gesture
processor's per-tick sample uses the configured value rather than the
hardcoded 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: trigger DanceModifier on state→dance + boot/SetStatus instrumentation
Two unrelated firmware fixes from the same dashboard click-through bug
sweep that produced the bridge-side commit on dotty-stackchan.
B2 — state→dance silently no-op'd. setState(DANCE) emitted dance_started
but nothing instantiated the DanceModifier; the Timeline that drives the
keyframe choreography was never constructed, so the head sat still and
the left-ring rainbow never painted. Added onEnterDance()/onExitDance()
hooks mirror of onEnterSecurity() and wired them into the setState()
prev/next switch. onEnterDance() spawns a DanceModifier(Happy) and
tracks its pool id; onExitDance() removes it (benign no-op if the
timeline already self-destroyed via requestDestroy()). The default
sequence is Happy (sway + happy expression + open mouth, ~4 s) — the
other built-ins in dance.h (Robot, Panic, LookAround) are reserved for
voice/bridge-driven choreography that wants a specific feel.
B6 — face detection / identification never fired the entire test
session, with no face_detected events reaching the bridge and pixel 6
dark. Added two diagnostic logs to triangulate whether the break is
modifier registration, lazy-create on SetStatus, or the camera pipeline:
- boot_audit at end of StackChanAvatarDisplay::SetupUI(): probes
state_manager / face_tracking / idle_motion by name. state_manager
should be non-null at boot; the lazy ones legitimately read nil.
- lazy_create on first SetStatus call that creates either modifier:
logs status + assigned ids so we can confirm FaceTrackingModifier
actually entered the pool when xiaozhi connected.
Captured logs from this build show modifier wiring is correct
(state_manager=0x3fcd80d8, lazy_create fired with ft_id=5 im_id=6) and
phase0 ticks 76 Hz with samples=380+. The break is downstream:
StackChanCamera VIDIOC_QUERYCAP fails at boot (errno=22 EINVAL), so
CameraStreamGuard::startStreaming fails and FaceDetector::StreamCaptures
fails on every tick. fps=0 → no detections → no events → pixel 6 stays
off. Same root cause as B5 (Failed to capture photo). Camera-driver
investigation tracked separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: head-touch threshold MID→HIGH to kill remaining spurious events
threshold=2 (MID) still let through spurious head_pet events when channel 2's noise floor occasionally pulled neighbours to MID for ≥150 ms with no contact. HP-PROBE caught two such fires at i=[1,2,2] and i=[0,2,1]. Bumping to 3 (HIGH) demands the firmest reading across a channel.
Post-bump idle baseline: 0 events / 18.5 min (vs 30/24min pre-fix, 9/16min post-debounce). Bench-confirmed real pets still register.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: fix V4L2 ioctl EINVAL — restore Linux _IOR encoding after lwip clobbers it
Root cause of B5 (`Failed to capture photo`) and B6 (face detection
never firing).
stackchan_camera.cc transitively pulls in lwip/sockets.h via
stackchan.h -> application.h -> audio_service.h -> audio_debugger.h ->
sys/socket.h. lwip and esp_video both define _IO / _IOR / _IOW, with
incompatible encodings:
- lwip: ((long)(IOC_OUT|((sizeof(t)&IOCPARM_MASK)<<16)|((x)<<8)|(y)))
- esp_video (linux/ioctl.h): _IOC(_IOC_READ,(type),(nr),(_IOC_TYPECHECK(size)))
VIDIOC_QUERYCAP is a text macro (`_IOR('V', 0, struct v4l2_capability)`)
that expands using whichever _IOR is in scope at the use site. After
the lwip include in this TU, _IOR was the BSD encoding — but the
esp_video dispatcher (esp_video_ioctl.c, separate TU, no lwip) sees
the Linux definitions, so its case-constants stayed Linux-encoded.
Mismatched cmd values made the dispatcher hit its default branch and
return ESP_ERR_INVALID_ARG -> errno=22 EINVAL on every camera ioctl.
Fix: explicitly #undef and redefine _IO / _IOR / _IOW / _IOWR with the
Linux encoding after stackchan.h. Reordering the videodev2.h include
isn't sufficient because linux/ioctl.h uses #pragma once. The
underlying _IOC machinery isn't redefined by lwip and is still
Linux-flavoured here, so _IOC / _IOC_NONE / _IOC_READ / _IOC_WRITE /
_IOC_TYPECHECK behave as esp_video expects.
Verified on hardware: cap struct populates correctly
(driver="DVP" caps=0x84200001), VIDIOC_STREAMON now succeeds, face
detector reaches fps=2.6-3.2 with face flowing through. Cold-boot
serial captures preserved at:
~/Repos/dotty-private/audits/v4l2-querycap-2026-04-28/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: retune kid_mode pip from (168,80,100) to (220,80,80) salmon
Old hue had B>G after RGB565 quantization, giving a cool cast that read
as purple/magenta on the PY32-driven pixel. Salmon (G==B) renders as
unambiguous warm pink at (216,80,80) post-quantization.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: throttle face_tracking lookAt for first commands after capture release
The "take a photo, immediately turn away" symptom (logged 2026-04-27,
expanded 2026-04-28) was bench-traced 2026-04-29 against the
HEADMOVE instrumentation. Single rep, single offending writer:
setModifyLock(false) refcount 1->0 t=24832
capture-pending guard released (capture observed dt=2028 ms)
HEADMOVE [face_tracking] yaw=240 pitch=241 speed=500 isMoving=1
Three things make that one write visibly violent:
- speed=500 (kLookAtSpeed) — designed for steady-state tracking,
not for "first command after the head was frozen for 2 s".
- isMoving=1 — a prior idle_motion pan to (0, 173) at speed=102
was still in flight when face_tracking issued. The motion lock
prevents future writes during a Capture but does not cancel
queued moves; on release the in-flight trajectory is abandoned
mid-arc and the servo redirects under a fast spring.
- _last_cmd_valid was reset on Idle→Tracking, so the deadband
cannot suppress the first post-Tracking command no matter how
small the delta from the user's actual position.
Fix: on guard release (both observed-capture and timeout-release
branches), set _post_release_throttle = 2 and re-seed
_last_cmd_x/y/_last_cmd_valid from the live raw face position.
In _maybeIssueLookAt, while the throttle counter > 0, use a
softer kPostReleaseLookAtSpeed (200) instead of kLookAtSpeed (500)
and decrement per *issued* command (so deadband-skipped ticks
don't burn the budget). Revert is a one-line constant flip.
Branched off dotty rather than instrument/headmove-trace so the
fix can ship independently of the trace logging.
Phase 3 of the plan at
/home/brett/.claude/plans/take-photo-wild-movements-fix.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: gate face_tracking WakeWordInvoke on GetDeviceState() to kill double wake on flickering walk-in
The prior `_profile.allow_wake_word_invoke` gate was IDLE-only by design
but lagged: `stackchan_display.cc::SetStatus` pushes LISTENING/SPEAKING
into the profile, and on a flickering walk-in the modifier can cycle
Idle->Tracking->GracePeriod->Idle (800 ms grace) faster than the first
invoke's state propagates back. The 2026-04-29 trace showed two
AfeWakeWord opus encodes per walk-in, two take_photo round-trips, and
two TTS chunks ~280 ms apart that interleaved into garbled playback.
`Application::GetInstance().GetDeviceState()` reflects the device state
machine synchronously, so the second acquisition while the first session
is still SPEAKING/LISTENING is suppressed at the call site without
depending on the profile-flag propagation path.
Closes Item 1 of the photo + tracking bug cluster.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* instrument: tag every head-write site for HEADMOVE trace logging
Phase 1 of the take_photo wild-movements investigation
(/home/brett/.claude/plans/take-photo-wild-movements-fix.md).
Additive instrumentation only — zero behaviour change.
Adds a trailing `const char* tag = "unknown"` parameter to every
head-write entry point (Motion::moveWithSpeed/moveYawWithSpeed/
movePitchWithSpeed/lookAtNormalized/lookAtPoint/goHome and
Servo::moveWithSpeed) and threads a string-literal tag from each
caller. Motion-level entries log:
HEADMOVE [<tag>] yaw=N pitch=N speed=N locked=R isMoving=B t=ms
Servo-level entries (caught at the bottleneck so writers that
bypass Motion — MCP set_head_angles, animation keyframes,
json_helper, the stackchan_display default pose — are still
visible) log:
HEADMOVE [<tag>] servo angle=N speed=N t=ms
Motion::setModifyLock now logs every refcount transition with
old->new so a single `grep HEADMOVE\|setModifyLock` over the
serial monitor reveals causality across acquire/Capture/release.
Tagged callers (writer name → tag string):
face_tracking -> face_tracking
idle_motion (4 sub-paths)-> idle_motion_{small_offset,gentle_look,return_center,overlay}
state_manager poses -> state_manager_{wake_tilt,sleep_pose,security_*}
thinking / speaking -> thinking / speaking
head_pet -> head_pet_{restore,perform}
imu shake reaction -> imu_shake_react
app_setup servo wizard -> app_setup_*
hal_ezdata user updates -> ezdata_{yaw,pitch}
MCP self.robot.set_head_angles -> mcp_set_head_angles
animation Keyframe::apply -> keyframe_servo
json_helper -> json_helper
stackchan_display default pose -> stackchan_display_pose_default
Easy to revert post-bench (single commit on a dedicated branch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: privacy sleep — disable camera + mic on enter-sleep
onEnterSleep now stops FaceDetector (V4L2 stream tears down via the
refcounted CameraStreamGuard) and disables the audio service's
wake-word detection + voice processing. Mic-side audio capture
halts entirely; xiaozhi-server sees silence for the duration of
sleep. onExitSleep mirrors xiaozhi's idle-state defaults — face
detector back on, wake-word back on (voice processing stays off
until LISTENING).
Wake from SLEEP is now touch-or-dashboard only: head-pet
(capacitive) or set_state MCP. The sleep edges in onFaceDetected
and onVoiceListening are removed as defense-in-depth — the
detectors shouldn't fire during sleep anyway, but the explicit
IDLE-only guard kills any race during teardown.
Replaces the previous Phase 5 design where face detection stayed
alive during sleep so face_detected could wake Dotty. The "isn't
listening" UX promise wasn't quite true under that design; this
commit makes it literally true.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: route privacy sleep mic-off through xiaozhi privacy gate
state_manager.cpp::onEnterSleep now calls
Application::SetPrivacyGate(true) instead of toggling
EnableWakeWordDetection / EnableVoiceProcessing directly. The gate
lives inside xiaozhi (see xiaozhi-esp32 commit "application: add
privacy gate for SLEEP mic+camera disable") and pins these disables
across the kDeviceStateIdle / kDeviceStateListening transitions
HandleStateChangedEvent fires after the "going to sleep" TTS settles,
so xiaozhi's state and reality stay consistent.
Replaces a tick-loop re-assertion in StateManager::_update — fix at
source, not by racing in a polling loop. Also drops the AbortSpeaking
+ setListening edge-calls since the gate forces SetDeviceState(Idle)
and the normal state-change plumbing clears the listening pixel via
display->SetStatus(STANDBY).
onExitSleep mirrors: SetPrivacyGate(false) re-arms wake-word through
xiaozhi's normal idle-state path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: clear listening LED edge on enter-sleep
The privacy gate's forced SetDeviceState(kDeviceStateIdle) is
display-gated (skips SetStatus) to preserve the Sleepy avatar, so it
no longer naturally clears the listening pixel via the
display->SetStatus(STANDBY) → set_listening_pixel(false) path. For
the voice "go to sleep" flow — where xiaozhi was in LISTENING when
the SLEEP MCP arrives — the red pip would otherwise stay lit.
Add an explicit setListening(false) edge call in onEnterSleep so the
StateManager's _listening flag is cleared and the next 5 Hz pip
repaint extinguishes the red.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: timeout fallback for sleep torque release
The settle-based torque release in StateManager::_update fires when
motion.isMoving() reports false — but isMoving() polls the SCS bus
busy register, which can keep returning true if the servo holds
position with hardware micro-corrections, so the path isn't always
reachable in practice. Servos stayed torqued through sleep,
defeating the longevity benefit.
Add a 3 s timeout fallback. The preferred settle-based release still
fires first when the goHome pose converges cleanly (~1 s at speed 80);
the timeout bounds servo-on time when the bus check never reports
settled. Log line distinguishes which path fired so we can see in
serial which is winning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: face-identified flicker grace + perception event emit
setFaceIdentified() was no-opping when face_lost fired in the brief gap
between bridge VLM completion and MCP arrival (~150-300ms ws round-trip)
because the strict !_face_detected guard rejected the call. With the
HuMan detector flicker (face_lost/face_detected pairs every ~1s while a
person stands in frame) this kept the green pip yellow most of the time.
Soften the guard: accept the call if a face is detected OR was lost
within kFaceIdentifiedFlickerGraceMs (1500ms). Tighter than the green-pip
auto-timeout so a long-departed face can't accidentally re-light it.
Also emit face_identified_applied / face_identified_rejected perception
events on both paths so the bridge dashboard can see whether the firmware
actually honoured the MCP — closes the diagnostic loop that previously
required serial-monitor visibility to tell apply from no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: clear LEDs before AXP self-off on long-press
PY32 IO expander stays powered after AXP cuts the system rail, so any
pixels lit at shutdown silently keep sourcing current from the battery
while the device appears off. Two paths fixed:
- Idle-shutdown lambda now runs PrepareForPowerOff() (showRgbColor(0,0,0)
+ setServoPowerEnabled(false)) before pmic_->PowerOff().
- Power-button long-press: AXP hardware-cuts the rail at 4s with no
firmware callback. Enable PKEY long-press IRQ (reg 0x42 bit 2) and
poll status reg 0x4A at 5 Hz from a dedicated esp_timer; on hit, run
the same cleanup and call PowerOff() ourselves before the 4s
hardware threshold. AXP's hardware-off remains as a safety net.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: fix AXP2101 PEK IRQ register addresses (0x41/0x49 not 0x42/0x4A)
Previous attempt put the PKEY long-press IRQ enable + status checks on
INTEN3 (0x42) / INTSTS3 (0x4A). Per XPowersLib AXP2101 enum, PKEY events
are bits 8-11 of the 24-bit IRQ value — that's byte 1, i.e. INTEN2 (0x41)
/ INTSTS2 (0x49). Bit-within-byte (2) was already correct.
Symptom of the bug: long-press did not trigger the cleanup path; LEDs
stayed lit after AXP self-off. Idle-shutdown half of the original
commit was unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: tighten pitch servo limits to (30, 870)
Ported from upstream m5stack/StackChan b7a2ee9 (v1.4.1).
Prevents stall buzz / mechanical strain at travel extremes. Face
tracking uses motion().lookAtNormalized() which respects the configured
limits at runtime — no callers hardcode the old (0, 900) range.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: add Pmic::IsExternalPowerConnected() helper
Ported from upstream m5stack/StackChan b7a2ee9 (v1.4.1).
Pure additive helper on the AXP2101 Pmic wrapper — reads status reg
0x01, decodes current direction (bits 5-6) and charge status (bits 0-2)
to report whether the device is on external power, including the
charge-complete steady state.
Not wired into any caller yet. Future use: replace the IsCharging() /
IsDischarging() pair in GetBatteryLevel() / power-save gating with a
single "is on AC?" check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: rate-limit FT6336 I²C error log + track consecutive failures
Inspired by upstream m5stack/StackChan b7a2ee9 (v1.4.1), adapted to
keep our existing direct-I²C error-return path intact (avoids touching
patches/xiaozhi-esp32.patch and triggering an xiaozhi-esp32 rebuild).
UpdateTouchPoint() now counts consecutive read failures and emits a
warn log at most once per second, so silent I²C glitches become
observable without spamming the console.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* firmware: re-emit state_changed on channel (re)connect (#21)
Fix bridge<->firmware state divergence (issue #21). The server-side
perception store (dotty-behaviour) is in-memory and re-initialises
current_state to "idle" on restart; firmware never re-emitted
state_changed on reconnect (it only fires on an actual setState), so the
store, dashboard, and state-gated consumers (e.g. idle_photographer)
stayed wrong until the next firmware transition.
StateManager gains a public reemitState() wrapper over emitStateChanged().
Application::InitializeProtocol's OnAudioChannelOpened callback now
schedules a reemit via the modifier pool, so every successful channel
open — server reconnect, network-drop recovery, the proactive idle
close+reopen, and reboot — re-syncs the server store. Idempotent on the
consumer side (level-set), so safe on every open.
Also resyncs patches/xiaozhi-esp32.patch with the firmware tree: the
committed patch had drifted and was missing the SLEEP privacy gate
(SetPrivacyGate) and wake_word_detected perception-event commits. The
patch is now a full `git diff v2.2.4` of the current tree — builds clean
and applies cleanly to a fresh v2.2.4 checkout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* firmware: coordinate server-owned dance choreography
* fix: keep firmware credentials private and align wake defaults
---------
Co-authored-by: Brett Kinny <brett@squarewavesystems.com.au>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Brett Kinny <14231653+BrettKinny@users.noreply.github.com>
Owner
|
Thank you @0Downtime, and sorry this sat for so long. You found and fixed this two months before we did. We hit the same shutdown on 2026-10-20 and fixed it independently in — Brett, via Claude Opus 5.5 (AI-assisted) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
ReadRegs()path.ReadReg(0x49)form.Root cause
I2cDevice::ReadReg()returns0xFFwhen an I2C transaction fails.Pmic::ConsumePekLongPress()then tested bit0x04in that value, interpreted the failed read as a real PEK long-press IRQ, and intentionally called the AXP power-off path.This was reproduced on StackChan firmware 1.3.3 while TTS was active. The final serial messages were:
Impact
Transient PMIC I2C timeouts no longer shut StackChan down. Successful reads containing the real long-press bit retain the existing cleanup and power-off behavior.
Validation
python3 -m unittest discover -s firmware/tests -p 'test_*.py' -v- 5 tests passed.idf.py buildwith the pinned ESP-IDF 5.5.4 container - passed and producedstack-chan.bin.git diff --check- passed.The existing build warning that the smallest app partition has 2% free space is unchanged and unrelated.