Expose peripheral LEDs as a HA Light entity#5
Conversation
Adds the protocol scaffolding for peripheral-driven Light entities. The peripheral declares its LED capabilities by sending register_light after connecting; LVA creates a matching ESPHome Light entity that HA can control. HA-side changes flow back to the peripheral as light_command events, routed by object_id. The handler logic, entity class, and example script update follow in subsequent commits.
ESPHome RGB Light entity for peripheral LEDs. Effects list, RGB support, and brightness support are constructor parameters set by the peripheral_api server when it processes a register_light command, so the entity reflects what each peripheral can actually do rather than a single hardcoded contract. state_dict() includes object_id for routing on the broadcast side when multiple Lights are registered. The wiring (entity construction from registration) follows in the next commit.
Adds the runtime plumbing for peripheral-declared Light entities. models.py gains LightRegistration (the spec a peripheral declares) plus state.pending_lights and state.led_light_entities to track registrations and the live entities they materialise into. peripheral_api._dispatch_command handles register_light by appending a LightRegistration to state.pending_lights and asking the satellite to materialise it if one is already running. Repeat registrations for the same object_id are no-ops, so a reconnecting peripheral doesn't duplicate entities. satellite.register_pending_lights iterates the spec list and creates LEDLightEntity instances on first satellite construction or when the peripheral_api adds a new one mid-session. The entity's on_changed callback closes over object_id so light_command events are routed to the right peripheral hardware when more than one Light exists. The startup wait that gives peripherals a chance to register before HA enumerates entities follows in the next commit.
Adds --peripheral-startup-wait (default 2 seconds) that runs after the peripheral API starts but before serve_forever begins handling HA connections. The ESPHome server is bound during the wait, so HA's connection sits queued in the kernel; once the wait elapses, HA enumerates and sees any entities peripherals registered in the meantime. Late-registering peripherals (started after HA is already connected) still require an HA integration reload to surface their entities, but the common case of "everything starts at boot" needs no manual intervention.
The script now sends register_light on connect so LVA materialises an
HA Light entity ("light.<satellite>_leds") that exposes on/off,
brightness, RGB, and the two effects ("Voice Assistant", "None").
Sending on every reconnect is safe; LVA dedupes by object_id.
Incoming light_command events update the user state and force a
re-render. Tinting follows HAVPE: WAKE_WORD and LISTENING use the
user color; THINKING, SPEAKING, MUTED, and timer states keep their
semantic colors. User brightness scales every animation. is_on=False
shows no LEDs at all (new OFF pseudo-state); effect="None" holds a
solid user color and skips pipeline animations (new MANUAL pseudo-
state).
set_state grows a force flag so light_command events trigger an
immediate re-render even when the underlying assist state hasn't
changed. Events for other peripherals' lights are ignored by
matching against object_id.
Cycles the LEDs through the HSV color wheel in unison, with each LED offset by a third of the wheel so the strip always shows a slice of rainbow rather than one solid color shifting. Five-second period. Demonstrates how to wire a custom effect into the script: declare it in the register_light effects list, add a corresponding AssistState pseudo-state and override mapping in set_state, and an animation method that reads brightness through self._brightness so HA's brightness slider still scales it.
docs/peripheral_api.md gains: - light_command under HA-driven entity events (LVA → peripheral) - register_light under a new "Entity registration" command category - A short explanation of the startup wait and what happens to late registrations examples/ReSpeaker 2mic HAT/DOCS.md gains: - Updated animation table to note Wake word and Listening use the user color rather than fixed Blue/Cyan - A "Home Assistant Light entity" section covering the three effects (Voice Assistant, Rainbow, None), how on/off and brightness work, and how RGB color flows into the animations
- __main__.py: collapse the multi-line help string for --peripheral-startup-wait into a single line (black) - entity.py: move LightCommandRequest / LightStateResponse above the ListEntities* imports (isort, alphabetical strict order) - satellite.py: replace the closure-via-default-arg lambda in register_pending_lights with functools.partial so mypy can infer the callback type
The lgpio Python package on aarch64 falls back to building from source
when no prebuilt wheel matches, and the build needs two things the
original Dockerfile didn't install:
1. swig, to generate the Python bindings from lgpio.i.
2. The liblgpio C library itself to link against, which isn't packaged
for upstream Debian (Raspberry Pi OS ships it; deb.debian.org
doesn't). Build it from joan2937/lg, pinned to tag v0.2.2 for
reproducibility.
Without this, ``docker compose up --build`` failed at the pip step with
"Failed building wheel for lgpio" and the peripheral container never
came up, which in turn meant LVA didn't start either (the picompose
project aborts on the first failed build). The whole device looked
correctly imaged and connected to the network but exposed no ESPHome
API on port 6053, so adding it to HA failed with "Unable to connect
to the ESPHome device".
Caught while smoke-testing on a Pi 3 + ReSpeaker 2-Mic HAT v2.
Debian 13 (trixie) has been stable since August 2025; bookworm is now oldstable. The main LVA Dockerfile already uses trixie, so this brings the 2-mic peripheral example in line with current Debian stable and with the rest of the repo. The lgpio-from-source build step from the previous commit still applies — neither bookworm nor trixie ships liblgpio in upstream Debian (it's Raspberry Pi OS only), so the C library build runs regardless of the base image. The other peripheral example Dockerfiles (4-mic HAT, USB Mic Array, Satellite1 HAT, Jabra Speak 410) are still on bookworm; updating them is out of scope for this PR.
``group_add`` resolves names against the container's /etc/group, but the
python:slim base image doesn't define ``gpio`` or ``spi`` — so docker
compose errors out at container start with ``Unable to find group gpio:
no matching entries in group file``.
Pass the numeric GIDs instead, with ${GPIO_GID:-986} / ${SPI_GID:-989}
defaults that match a stock Raspberry Pi OS install. Users on other
distros can override in .env.
Same fix applies to the other peripheral example composes (4-mic HAT,
USB Mic Array, Satellite1 HAT); leaving those for a follow-up to keep
this PR focused.
lgpio creates notification files named ``.lgd-nfy-N`` in the process CWD at startup (used for its debouncing / watchdog mechanism). Compose runs the peripheral container as a non-root user so the gpio and spi group memberships line up with the host's device file ownership — but that user can't write to /app, which is root-owned by default. Result: every gpiozero pin factory fell through (lgpio first, because of the notify file failure; then RPi.GPIO and pigpio because they're not installed; then native because /dev/gpiomem isn't mapped), and the script crashed at first ``Button()`` with ``BadPinFactory: Unable to load any default pin factory!``. ``chmod a+w /app`` lets the runtime user create lgpio's notify files in CWD. Inside a container the security boundary is the container itself, so world-writable here is fine. Caught while smoke-testing on a Pi 3 + ReSpeaker 2-Mic HAT v2.
Two bugs caught during the hardware smoke test on a Pi 3 + ReSpeaker 2-Mic HAT v2: 1. LightCommandRequest wasn't in the message-routing tuple in VoiceSatelliteProtocol.handle_message, so HA's effect / brightness / on-off / RGB changes never reached LEDLightEntity.handle_message. The entity's on_changed callback never fired, no light_command event was broadcast, and the peripheral kept showing the pre-change animation regardless of HA-side changes. 2. The peripheral script's zeroconf-connected handler updated ha_connected=True but left assist_state alone. After any disconnect/reconnect cycle (including the brief one HA does during the initial pairing handshake), the script would stay stuck in NOT_READY's dim red pulse forever, because LVA doesn't proactively emit an idle event after reconnecting. Fix: when zeroconf arrives with status=connected, if assist_state is NOT_READY, transition to IDLE (or MUTED if muted). Pipeline states aren't disturbed. The Light entity registration itself works — the protocol scaffolding is correct, just these two glue points were missing.
The script previously sent ``start_listening`` when the centre button
was pressed and the satellite was idle. HA Voice PE's centre button
does the opposite — single press while idle toggles microphone mute.
Switch our default to match so users coming from HAVPE get the same
muscle memory.
The context-aware overrides on the single press handler still take
precedence (so an idle press is the fallback case):
timer ringing → stop_timer_ringing
pipeline active → stop_pipeline
media playing → stop_media_player
idle, unmuted → mute_mic
idle, muted → unmute_mic
To start a conversation you now use the wake word ("Hey Jarvis" by
default). If you want to keep a press-to-talk button, configure a
separate button on a different GPIO and wire it to start_listening
in your own script.
The previous Rainbow effect offset each LED by a third of the HSV wheel so the strip showed a static rainbow that rotated. Renaming to "Loop" and dropping the offset, so all three LEDs share the same hue at any moment and the whole strip reads as one shifting color cycling through the spectrum. Five-second period unchanged. Brightness scaling, on/off, and the fall-through to STATIC / OFF pseudo-states all behave as before.
HA's ESPHome integration calls _trigger_event on every EventResponse it receives, and validates that event_type is in the entity's declared list. The previous code yielded a state message during the SubscribeHomeAssistantStatesRequest handshake even when no button press had occurred yet, which surfaced as: ValueError: Invalid event type for event.<name>_button_press (note the double space — empty string). HA propagates the error through the entire ESPHome config entry, so when HA later tries to unload the integration to retry, it hits the cascade: ValueError: Config entry was never loaded! …because the partial load never completed. Event entities are inherently event-driven, not stateful — there's no "current state" to report at subscribe time, only events as they happen. Skip the response unless a press has actually been recorded. Caught during hardware smoke testing — the button hadn't been pressed since the fresh boot, so HA refused to load the integration at all and reported "the voice assistant is unable to connect to Home Assistant" in the UI.
Black collapsed multi-line log strings to one line (line length 200) but left the implicit ``"... " "..."`` concatenation intact, which pylint flags with W1404. Merge the fragments.
Three pre-existing type errors only surfaced with newer mypy: * Rename the loop variable in the dedupe pass (``extra`` → ``extra_player`` / ``extra_mute`` / ``extra_thinking``). Reusing the same name across three loops over different entity types made mypy unify them as ``MediaPlayerEntity`` and reject the later iterations, which the now-stale ``# type: ignore[index]`` comments hid in older releases. Distinct names let each loop infer its element type and drop the suppressions. * Tighten the wake-word ignore codes. ``wake_word.wake_word`` only exists on ``MicroWakeWord`` (``OpenWakeWord`` lacks it), so the right code is ``union-attr``; the previous comments used ``attr-defined`` and one was a bare ``# type: ignore`` with no code at all. No behavior change.
LVA never broadcast the muted bool to peripheral_api clients. The ``muted`` event signalled "we just entered the muted display state" but no event ever signalled "we just left it" — only the generic ``idle`` did, and peripherals can't infer mute state from an idle event since idle fires in many other situations (post-TTS, post wake word, after errors). As a result the ReSpeaker 2-Mic peripheral script's cached ``muted`` flag flipped to True on the first mute and stayed True forever. Its button handler keyed off that flag (``muted → unmute_mic``, ``unmuted → mute_mic``), so after the very first mute the button could only ever emit ``unmute_mic``: the user could unmute but never re-mute. The ``volume_muted`` event was already declared in the protocol and already handled by the peripheral script — it just had no emitter. Hook one into ``_set_muted`` so every mute transition (both directions) ships the boolean. The peripheral's existing handler clears its cached ``muted`` and switches the animator state back to IDLE automatically.
Comment-only pass. No behavior change. Reads more like prose, drops em-dashes, drops backtick inline code, and avoids compound hyphenations where a normal sentence reads cleaner.
Pure comment shrink, no behavior change. Both comments were explaining themselves in more lines than the code itself.
ButtonMultipressHandler was instantiated in async_main but its on_press / on_release methods were never called from gpiozero, so double, triple, and long presses never reached LVA and never played the corresponding sounds. Hook the multipress detector into ButtonHandler.setup. On every press down it now dispatches to the detector first and then to the context action; on release it forwards to the detector so the long-press timer can be cancelled. LVA already plays the matching sound when it receives button_double_press, button_triple_press, or button_long_press, so this is a wiring change only.
HAVPE's on_multi_click uses a 250 ms window between presses, not the 500 ms we had. It also runs without a debounce filter, so the 150 ms gpiozero bounce_time was unnecessarily eating fast double presses. Switch MULTIPRESS_TIMEOUT_MS from 500 to 250 (the HAVPE value), and drop BTN_DEBOUNCE_MS from 150 to 30 so the debounce stays small enough to fit inside the new window while still covering the actual mechanical bounce on a switch (a few milliseconds in practice). Sources for the HAVPE values: esphome/home-assistant-voice-pe @ dev home-assistant-voice.yaml, binary_sensor.center_button.on_multi_click LONG_PRESS_MS stays at 1000 ms, which already matched.
A double or triple press was toggling mute (or stopping the pipeline) on each individual press, because the context action fired on every press-down regardless of what the multipress detector eventually decided. That meant pressing twice quickly to play the double-press sound also toggled the mute switch twice. Move the context action behind the multipress state machine: on press-down the detector just accumulates count, and when the 250 ms window closes it dispatches based on the final count. Count == 1 fires the single-click action (mute toggle, stop pipeline, etc.), count == 2 fires button_double_press, and so on. This is how HAVPE's on_multi_click works: the on_press / on_release hooks drive LED feedback only, the actual click action is delivered on release after the trailing quiet window. Single press now has the expected ~250 ms delay before the action fires. Double and triple presses no longer side-effect the single press behaviour.
Pure comment shrink; no behavior change. The previous wording explained the design two or three different ways in the same file.
|
Alright, my first question here: Why do you want to change the button press to toggle the microphone instead of triggering the assistant? |
|
My bad, for some reason I thought that was the default HAVPE behaviour at the time, but it isn't. HAVPE's simple click cascade ends in |
Yes, I see this is the best behavior for that button to match the VPE.
|
Updated documentation for correct button controls.
|
I already reverted the button action. |
Removed unnecessary event emission for muted state.
|
@genericJE For the leds loop effect, are you sure that this option exists in the Voice PE whether to enable the voice assistant effect or loop effect? AFAIK, the option that only exists is to keep the leds always on and set their color which will behave even during the leds animation while the assistant is active, but the effect couldn't be disabled completely. |
|
Yes those aren't VPE options. VPE only does the always-on color/brightness and you can't disable its animation. I added |
Yes, I highly prefer to keep the examples in documentation close the the VPE behaviour as they are also purposed for the |
Match the HA Voice PE, which runs a single always-on Voice Assistant effect with no way to disable the animation. Drop the Loop and None effects from the ReSpeaker example (states, animations, and effect handling), register only Voice Assistant, and update the docs to match.
|
@omaramin-2000 thanks for the feedback, fair point. Dropped Loop and None in |
The HA Voice PE LED Ring defaults off and shows a solid color at idle only when the user turns it on, while its voice animations always run regardless. Default the core LEDLightEntity off to match, and in the ReSpeaker example gate the idle glow on the light's on/off: dark when off, solid user color when on, with the pipeline animations playing in every state. Turning the light off now only removes the idle glow, it does not disable the animations.
25d7755 to
d1dc6fe
Compare
|
Thanks! |
c27eef2
into
omaramin-2000:leds-and-buttons-events
|
Pushed a couple more commits to match the Voice PE ring more closely: the LED light now defaults off so idle stays dark until you turn it on (the voice animations still always play), and its default color is the same light blue the ring uses. |
Hi Omar, this is the larger of three PRs I'm sending. The two smaller ones (#3 on the empty
event_type, #4 on broadcastingvolume_muted) are bug fixes I ran into while testing this feature on a Pi Zero 2 W with the ReSpeaker 2-Mic HAT. If they land first, this PR rebases cleanly and the duplicate commits drop out on their own.The motivation is parity with the HA Voice PE. HAVPE exposes its LED ring as a Home Assistant Light entity so the user can set color, brightness, on/off, and pick an effect from the device page. Without that, the LEDs on a satellite are opaque from HA's side, with no way to dim them at night, choose a non default accent color, or build automations around them.
Protocol
A peripheral now declares its LEDs at connect time with a new command,
register_light, carrying its name,object_id, supported effects, and whether it does RGB and brightness. LVA materialises a matchingLEDLightEntity, HA enumerates it as any other ESPHome Light, and user changes flow back to the peripheral aslight_commandevents. Theobject_idround trips through both messages so a peripheral with more than one light can route events to the right hardware.Example registration after connect:
{ "command": "register_light", "data": { "name": "LEDs", "object_id": "leds", "effects": ["Voice Assistant", "Loop", "None"], "supports_rgb": true, "supports_brightness": true } }There is a small startup wait (
--peripheral-startup-wait, default 2 seconds) before LVA accepts HA connections so peripherals can register first. Peripherals that connect later still work, their entities just appear in HA after the next integration reconnect.The example
The ReSpeaker 2-Mic example registers a Light called
LEDswith three effects:Voice Assistant,Loop, andNone.Voice Assistantis the default and runs the pipeline animations from your branch with one change. Wake word and Listening pick up the user's RGB color (HAVPE style tint), while the semantic colors (Thinking yellow, Speaking green, Muted red, Timer cyan) stay hardcoded so they remain recognisable across customisation. HA brightness scales every animation.Loopcycles all three LEDs through the HSV color wheel in unison with a 5 second period (a single shifting color, not a spread rainbow).Nonesuppresses pipeline animations and holds the user's solid color, useful as a static accent or as a notification surface for HA automations.A few behaviour changes to the centre button to match HAVPE. Idle single press now toggles mic mute (
mute_micorunmute_mic); the previous default sentstart_listening, and users coming from HAVPE expected mute. To start a conversation the wake word is the path ("Hey Jarvis" by default). The script also wires up the multipress detector that was already in the file but not connected to GPIO, so double, triple, and long presses now play their corresponding sounds (LVA already routed those commands totts_player.play). Timings match HAVPE'son_multi_click: a 250 ms window between presses, and a 1 second hold for long press. To keep a multi click from firing the single click action on each individual press, the action is deferred until the gesture resolves, again matching HAVPE.A few small Dockerfile and compose tweaks for the example to run on a stock Pi OS. The base moves to
python:3.13-slim-trixie. lgpio's Python wrapper needs the systemliblgpioC library, which is not in Debian's repos (Raspberry Pi OS ships it, upstream Debian does not), so the Dockerfile clonesjoan2937/lgat a pinned tag and builds the C library before pip installslgpio.swigis added for the Python bindings.docker compose'sgroup_addresolves names against the container's/etc/group, whichpython:slimdoes not populate withgpioorspi, so the defaults are switched to numeric GIDs that match a stock Raspberry Pi OS install (overridable via.env)./appis made writable for the non root runtime user so lgpio can drop its.lgd-nfy-Nnotification files.Tested on a Pi Zero 2 W paired in HA. Add device shows the Light entity, the slider and color picker drive the LEDs in real time, effect switching works for all three, the mic mute toggle is correct in both directions on repeated presses, and double, triple, and long press each play their sound without affecting the single click action.
Happy to split any of this further if you'd prefer smaller pieces, rename anything, or change scope. There is a comment polish pass at the tip of the branch (no behavior change) that I kept separate so the reviewable diff stays focused on the protocol and the entity.