Skip to content

Expose peripheral LEDs as a HA Light entity#5

Merged
omaramin-2000 merged 32 commits into
omaramin-2000:leds-and-buttons-eventsfrom
genericJE:feat/peripheral-led-light-entity
Jun 10, 2026
Merged

Expose peripheral LEDs as a HA Light entity#5
omaramin-2000 merged 32 commits into
omaramin-2000:leds-and-buttons-eventsfrom
genericJE:feat/peripheral-led-light-entity

Conversation

@genericJE

@genericJE genericJE commented May 12, 2026

Copy link
Copy Markdown

Hi Omar, this is the larger of three PRs I'm sending. The two smaller ones (#3 on the empty event_type, #4 on broadcasting volume_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 matching LEDLightEntity, HA enumerates it as any other ESPHome Light, and user changes flow back to the peripheral as light_command events. The object_id round 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 LEDs with three effects: Voice Assistant, Loop, and None. Voice Assistant is 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. Loop cycles all three LEDs through the HSV color wheel in unison with a 5 second period (a single shifting color, not a spread rainbow). None suppresses 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_mic or unmute_mic); the previous default sent start_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 to tts_player.play). Timings match HAVPE's on_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 system liblgpio C library, which is not in Debian's repos (Raspberry Pi OS ships it, upstream Debian does not), so the Dockerfile clones joan2937/lg at a pinned tag and builds the C library before pip installs lgpio. swig is added for the Python bindings. docker compose's group_add resolves names against the container's /etc/group, which python:slim does not populate with gpio or spi, so the defaults are switched to numeric GIDs that match a stock Raspberry Pi OS install (overridable via .env). /app is made writable for the non root runtime user so lgpio can drop its .lgd-nfy-N notification 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.

genericJE added 20 commits May 10, 2026 11:09
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.
@omaramin-2000

Copy link
Copy Markdown
Owner

Alright, my first question here: Why do you want to change the button press to toggle the microphone instead of triggering the assistant?

@genericJE

Copy link
Copy Markdown
Author

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 voice_assistant.start when the satellite is idle, not a mute toggle. Want me to revert that commit?

@omaramin-2000

omaramin-2000 commented May 18, 2026

Copy link
Copy Markdown
Owner

Want me to revert that commit?

Yes, I see this is the best behavior for that button to match the VPE.

Updated documentation for correct button controls.
@omaramin-2000

Copy link
Copy Markdown
Owner

I already reverted the button action.

Removed unnecessary event emission for muted state.
Comment thread examples/ReSpeaker 2mic HAT/Dockerfile
@omaramin-2000 omaramin-2000 changed the title Expose peripheral LEDs as an HA Light entity Expose peripheral LEDs as a HA Light entity Jun 3, 2026
@omaramin-2000

Copy link
Copy Markdown
Owner

@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.

@genericJE

Copy link
Copy Markdown
Author

Yes those aren't VPE options. VPE only does the always-on color/brightness and you can't disable its animation. I added Loop and None to the ReSpeaker example as a demonstration of the register_light protocol, since a peripheral can declare any effects it wants, so other developers have a working example to build on. The actual VPE-faithful behavior is the Voice Assistant effect. I can remove Loop/None and keep just that one if you'd prefer to stay close to VPE.

@omaramin-2000

Copy link
Copy Markdown
Owner

I can remove Loop/None and keep just that one if you'd prefer to stay close to VPE.

Yes, I highly prefer to keep the examples in documentation close the the VPE behaviour as they are also purposed for the Voice Assistant effect. Howeverl, all of that is open-source and you could build your own integration with your own functionality as you like

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.
@genericJE

Copy link
Copy Markdown
Author

@omaramin-2000 thanks for the feedback, fair point. Dropped Loop and None in b26c470, so it's just the Voice Assistant effect now to match VPE. Protocol can still carry extra effects if anyone wants their own.

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.
@genericJE genericJE force-pushed the feat/peripheral-led-light-entity branch from 25d7755 to d1dc6fe Compare June 10, 2026 16:18
@omaramin-2000

Copy link
Copy Markdown
Owner

Thanks!

@omaramin-2000 omaramin-2000 merged commit c27eef2 into omaramin-2000:leds-and-buttons-events Jun 10, 2026
6 checks passed
@genericJE

Copy link
Copy Markdown
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants