Peripheral volume display (#411)
Adds the volume display effect to these boards that have LED Ring, when controlling the volume for LVA: - Satellite 1 HAT Board - ReSpeaker 4-Mic Array HAT - ReSpeaker USB Mic Array v2.0 This animation appears on the LED ring temporarily to display an arc that sweeps clockwise when the volume increases and anti-clockwise when the volume decreases. Triggers by the volume_changed event.
This commit is contained in:
1 parent
d1f5761f75
commit
adcef575b7
6 files changed
+183
-30
No files matched your search
@@ -44,7 +44,7 @@ The controller runs as a separate Docker container on the same Raspberry Pi. It
|
|||||||
All animations mirror the Home Assistant Voice PE ESPHome firmware exactly.
|
All animations mirror the Home Assistant Voice PE ESPHome firmware exactly.
|
||||||
|
|
||||||
| LVA state | Animation | Description |
|
| LVA state | Animation | Description |
|
||||||
|---|---|---|
|
| ---------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
| No HA connection / not ready | Red twinkle | Random red sparkle across all LEDs |
|
| No HA connection / not ready | Red twinkle | Random red sparkle across all LEDs |
|
||||||
| Idle | Off | All LEDs off |
|
| Idle | Off | All LEDs off |
|
||||||
| Wake word detected | Slow clockwise spin | Two trailing arcs at opposing positions |
|
| Wake word detected | Slow clockwise spin | Two trailing arcs at opposing positions |
|
||||||
@@ -55,6 +55,7 @@ All animations mirror the Home Assistant Voice PE ESPHome firmware exactly.
|
|||||||
| Error | Red pulse | All LEDs red, pulsing |
|
| Error | Red pulse | All LEDs red, pulsing |
|
||||||
| Timer ticking | Countdown arc | Arc proportional to `seconds_left / total_seconds` |
|
| Timer ticking | Countdown arc | Arc proportional to `seconds_left / total_seconds` |
|
||||||
| Timer ringing | Pulse + optional red | Full ring pulsing; red at corners if muted |
|
| Timer ringing | Pulse + optional red | Full ring pulsing; red at corners if muted |
|
||||||
|
| Volume changed | Volume Display arc | Temporary arc (2.5s) showing the current volume level, starting from LED 0 and sweeping clockwise |
|
||||||
| Volume muted | Solid ring + red indicator | Red at the bottom-mid LED |
|
| Volume muted | Solid ring + red indicator | Red at the bottom-mid LED |
|
||||||
|
|
||||||
"User color" comes from the Home Assistant Light entity described below (default: HAVPE-style blue). HA brightness scales every animation in this table. Semantic colors (Waiting/Listening cyan spin, Thinking pulse, Replying anticlockwise, Muted solid + red, Timer pulse/arc, Error red pulse) are hardcoded so they remain recognisable across user customisation.
|
"User color" comes from the Home Assistant Light entity described below (default: HAVPE-style blue). HA brightness scales every animation in this table. Semantic colors (Waiting/Listening cyan spin, Thinking pulse, Replying anticlockwise, Muted solid + red, Timer pulse/arc, Error red pulse) are hardcoded so they remain recognisable across user customisation.
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ SPI_DEVICE = 1
|
|||||||
# 10 ≈ 32 % (gentle default), 31 = maximum.
|
# 10 ≈ 32 % (gentle default), 31 = maximum.
|
||||||
LED_BRIGHTNESS = 10
|
LED_BRIGHTNESS = 10
|
||||||
|
|
||||||
|
# Volume Display effect
|
||||||
|
# Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda. Origin
|
||||||
|
# is LED 6 (bottom-mid) — the same reference LED already used for the
|
||||||
|
# volume_muted indicator (VOLUME_MUTED_LED_INDEX) — so the arc grows from
|
||||||
|
# the same spot that turns solid red when volume is silenced.
|
||||||
|
VOLUME_DISPLAY_SECONDS = 2.5
|
||||||
|
|
||||||
# LED that lights up solid red while the media player (speaker) output is
|
# LED that lights up solid red while the media player (speaker) output is
|
||||||
# muted at volume 0 — the `volume_muted` peripheral event. Ring index 6 is
|
# muted at volume 0 — the `volume_muted` peripheral event. Ring index 6 is
|
||||||
# the bottom-middle position (180°, directly opposite index 0 at the top),
|
# the bottom-middle position (180°, directly opposite index 0 at the top),
|
||||||
@@ -147,6 +154,10 @@ class SharedState:
|
|||||||
self.ha_connected: bool = False
|
self.ha_connected: bool = False
|
||||||
self.muted: bool = False # mic mute
|
self.muted: bool = False # mic mute
|
||||||
self.volume: float = 1.0
|
self.volume: float = 1.0
|
||||||
|
# Monotonic deadline until which the Volume Display arc takes over
|
||||||
|
# the ring, set on each volume_changed event. 0 (or in the past)
|
||||||
|
# means the arc is not showing.
|
||||||
|
self.volume_display_until: float = 0.0
|
||||||
self.volume_muted: bool = False # media player (speaker) mute
|
self.volume_muted: bool = False # media player (speaker) mute
|
||||||
self.timer_total_seconds: int = 0
|
self.timer_total_seconds: int = 0
|
||||||
self.timer_seconds_left: int = 0
|
self.timer_seconds_left: int = 0
|
||||||
@@ -173,6 +184,7 @@ class SharedState:
|
|||||||
"ha_connected": self.ha_connected,
|
"ha_connected": self.ha_connected,
|
||||||
"muted": self.muted,
|
"muted": self.muted,
|
||||||
"volume": self.volume,
|
"volume": self.volume,
|
||||||
|
"volume_display_until": self.volume_display_until,
|
||||||
"volume_muted": self.volume_muted,
|
"volume_muted": self.volume_muted,
|
||||||
"timer_total_seconds": self.timer_total_seconds,
|
"timer_total_seconds": self.timer_total_seconds,
|
||||||
"timer_seconds_left": self.timer_seconds_left,
|
"timer_seconds_left": self.timer_seconds_left,
|
||||||
@@ -478,6 +490,32 @@ class LEDRing:
|
|||||||
self._write()
|
self._write()
|
||||||
return 0.1
|
return 0.1
|
||||||
|
|
||||||
|
def _anim_volume_display(self, color: RGB, volume: float) -> float:
|
||||||
|
"""
|
||||||
|
Arc showing the current volume level, originating at LED 6
|
||||||
|
(bottom-mid — same LED as VOLUME_MUTED_LED_INDEX) and sweeping
|
||||||
|
clockwise around the ring.
|
||||||
|
|
||||||
|
Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda —
|
||||||
|
proportional arc with a partial-brightness leading LED, plus a red
|
||||||
|
indicator on the origin LED when volume is silenced.
|
||||||
|
"""
|
||||||
|
silenced_color = RED
|
||||||
|
volume_ratio = LED_COUNT * max(0.0, min(1.0, volume))
|
||||||
|
|
||||||
|
for i in range(LED_COUNT):
|
||||||
|
if i <= volume_ratio:
|
||||||
|
brightness = min(volume_ratio - i, 1.0)
|
||||||
|
self._set((VOLUME_MUTED_LED_INDEX + i) % LED_COUNT, _scale(color, brightness))
|
||||||
|
else:
|
||||||
|
self._set((VOLUME_MUTED_LED_INDEX + i) % LED_COUNT, BLACK)
|
||||||
|
|
||||||
|
if volume <= 0.0:
|
||||||
|
self._set(VOLUME_MUTED_LED_INDEX, silenced_color)
|
||||||
|
|
||||||
|
self._write()
|
||||||
|
return 0.05 # matches the 50ms update_interval of the ESPHome effect
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Animation loop
|
# Animation loop
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -494,7 +532,12 @@ class LEDRing:
|
|||||||
t_total = snap["timer_total_seconds"]
|
t_total = snap["timer_total_seconds"]
|
||||||
t_left = snap["timer_seconds_left"]
|
t_left = snap["timer_seconds_left"]
|
||||||
|
|
||||||
if anim == self.ANIM_OFF:
|
# Volume Display takes over the ring temporarily, regardless of
|
||||||
|
# the current pipeline state, then falls back to the normal
|
||||||
|
# animation once its deadline passes.
|
||||||
|
if snap["volume_display_until"] > time.monotonic():
|
||||||
|
sleep = self._anim_volume_display(color, snap["volume"])
|
||||||
|
elif anim == self.ANIM_OFF:
|
||||||
sleep = self._anim_off()
|
sleep = self._anim_off()
|
||||||
elif anim == self.ANIM_IDLE:
|
elif anim == self.ANIM_IDLE:
|
||||||
sleep = self._anim_idle()
|
sleep = self._anim_idle()
|
||||||
@@ -716,7 +759,10 @@ class LVAClient:
|
|||||||
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
|
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
|
||||||
|
|
||||||
elif event == "volume_changed":
|
elif event == "volume_changed":
|
||||||
self._state.update(volume=data.get("volume", 1.0))
|
self._state.update(
|
||||||
|
volume=data.get("volume", 1.0),
|
||||||
|
volume_display_until=time.monotonic() + VOLUME_DISPLAY_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
elif event == "volume_muted":
|
elif event == "volume_muted":
|
||||||
# Media player (speaker) mute state, carried as {"muted": bool}
|
# Media player (speaker) mute state, carried as {"muted": bool}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ All animations mirror the Home Assistant Voice PE ESPHome firmware exactly.
|
|||||||
| Error | Red pulse | All LEDs red, pulsing |
|
| Error | Red pulse | All LEDs red, pulsing |
|
||||||
| Timer ticking | Countdown arc | Arc proportional to `seconds_left / total_seconds` |
|
| Timer ticking | Countdown arc | Arc proportional to `seconds_left / total_seconds` |
|
||||||
| Timer ringing | Pulse + optional red | Full ring pulsing; red at mic positions if muted |
|
| Timer ringing | Pulse + optional red | Full ring pulsing; red at mic positions if muted |
|
||||||
|
| Volume changed | Volume Display arc | Temporary arc (2.5s) showing the current volume level, starting from LED 9 (bottom, between the micro-USB and AUX jack) and sweeping clockwise |
|
||||||
| Volume muted | Red mic indicator | LED 9 (bottom at the AUX jack corner) solid red to indicate the media player volume is zero; other LEDs off |
|
| Volume muted | Red mic indicator | LED 9 (bottom at the AUX jack corner) solid red to indicate the media player volume is zero; other LEDs off |
|
||||||
|
|
||||||
"User color" comes from the Home Assistant Light entity described below (default: HAVPE-style blue). HA brightness scales every animation in this table. Semantic colors (Waiting/Listening cyan spin, Thinking pulse, Replying anticlockwise, Muted solid + red, Timer pulse/arc, Error red pulse) are hardcoded so they remain recognisable across user customisation.
|
"User color" comes from the Home Assistant Light entity described below (default: HAVPE-style blue). HA brightness scales every animation in this table. Semantic colors (Waiting/Listening cyan spin, Thinking pulse, Replying anticlockwise, Muted solid + red, Timer pulse/arc, Error red pulse) are hardcoded so they remain recognisable across user customisation.
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ USB_PRODUCT_ID = 0x0018
|
|||||||
LED_COUNT = 12
|
LED_COUNT = 12
|
||||||
LED_BRIGHTNESS = 10 # 0–31 (APA102 5-bit global brightness register)
|
LED_BRIGHTNESS = 10 # 0–31 (APA102 5-bit global brightness register)
|
||||||
|
|
||||||
|
# Volume Display effect
|
||||||
|
# Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda. Origin
|
||||||
|
# is LED 9 (bottom, DOA 270°) — the same reference LED already used for
|
||||||
|
# the volume_muted indicator (VOLUME_MUTED_LED_INDEX), sitting between the
|
||||||
|
# micro-USB connector and the 3.5 mm AUX jack — so the arc grows from the
|
||||||
|
# same spot that turns solid red when volume is silenced.
|
||||||
|
VOLUME_DISPLAY_SECONDS = 2.5
|
||||||
|
|
||||||
# LED that lights up solid red while the media player (speaker) output is
|
# LED that lights up solid red while the media player (speaker) output is
|
||||||
# muted at volume 0 — the `volume_muted` peripheral event. LED 9 is the
|
# muted at volume 0 — the `volume_muted` peripheral event. LED 9 is the
|
||||||
# board's bottom-middle position (DOA 270°), sitting right between the
|
# board's bottom-middle position (DOA 270°), sitting right between the
|
||||||
@@ -134,6 +142,10 @@ class SharedState:
|
|||||||
self.ha_connected: bool = False
|
self.ha_connected: bool = False
|
||||||
self.muted: bool = False # mic mute
|
self.muted: bool = False # mic mute
|
||||||
self.volume: float = 1.0
|
self.volume: float = 1.0
|
||||||
|
# Monotonic deadline until which the Volume Display arc takes over
|
||||||
|
# the ring, set on each volume_changed event. 0 (or in the past)
|
||||||
|
# means the arc is not showing.
|
||||||
|
self.volume_display_until: float = 0.0
|
||||||
self.volume_muted: bool = False # media player volume zero
|
self.volume_muted: bool = False # media player volume zero
|
||||||
self.timer_total_seconds: int = 0
|
self.timer_total_seconds: int = 0
|
||||||
self.timer_seconds_left: int = 0
|
self.timer_seconds_left: int = 0
|
||||||
@@ -160,6 +172,7 @@ class SharedState:
|
|||||||
"ha_connected": self.ha_connected,
|
"ha_connected": self.ha_connected,
|
||||||
"muted": self.muted,
|
"muted": self.muted,
|
||||||
"volume": self.volume,
|
"volume": self.volume,
|
||||||
|
"volume_display_until": self.volume_display_until,
|
||||||
"volume_muted": self.volume_muted,
|
"volume_muted": self.volume_muted,
|
||||||
"timer_total_seconds": self.timer_total_seconds,
|
"timer_total_seconds": self.timer_total_seconds,
|
||||||
"timer_seconds_left": self.timer_seconds_left,
|
"timer_seconds_left": self.timer_seconds_left,
|
||||||
@@ -417,6 +430,32 @@ class LEDRing:
|
|||||||
self._leds.set(6, _scale(RED, factor))
|
self._leds.set(6, _scale(RED, factor))
|
||||||
self._leds.set(9, _scale(RED, factor))
|
self._leds.set(9, _scale(RED, factor))
|
||||||
|
|
||||||
|
def _anim_volume_display(self, color: RGB, volume: float) -> float:
|
||||||
|
"""
|
||||||
|
Arc showing the current volume level, originating at LED 9
|
||||||
|
(bottom, between the micro-USB and AUX jack — same LED as
|
||||||
|
VOLUME_MUTED_LED_INDEX) and sweeping clockwise around the ring.
|
||||||
|
|
||||||
|
Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda —
|
||||||
|
proportional arc with a partial-brightness leading LED, plus a red
|
||||||
|
indicator on the origin LED when volume is silenced.
|
||||||
|
"""
|
||||||
|
silenced_color = RED
|
||||||
|
volume_ratio = LED_COUNT * max(0.0, min(1.0, volume))
|
||||||
|
|
||||||
|
for i in range(LED_COUNT):
|
||||||
|
if i <= volume_ratio:
|
||||||
|
brightness = min(volume_ratio - i, 1.0)
|
||||||
|
self._leds.set((VOLUME_MUTED_LED_INDEX + i) % LED_COUNT, _scale(color, brightness))
|
||||||
|
else:
|
||||||
|
self._leds.set((VOLUME_MUTED_LED_INDEX + i) % LED_COUNT, BLACK)
|
||||||
|
|
||||||
|
if volume <= 0.0:
|
||||||
|
self._leds.set(VOLUME_MUTED_LED_INDEX, silenced_color)
|
||||||
|
|
||||||
|
self._leds.show()
|
||||||
|
return 0.05 # matches the 50ms update_interval of the ESPHome effect
|
||||||
|
|
||||||
def _apply_volume_muted_indicator(self) -> None:
|
def _apply_volume_muted_indicator(self) -> None:
|
||||||
"""
|
"""
|
||||||
Overlay a solid red dot on the bottom-middle LED (index
|
Overlay a solid red dot on the bottom-middle LED (index
|
||||||
@@ -585,7 +624,12 @@ class LEDRing:
|
|||||||
t_total = snap["timer_total_seconds"]
|
t_total = snap["timer_total_seconds"]
|
||||||
t_left = snap["timer_seconds_left"]
|
t_left = snap["timer_seconds_left"]
|
||||||
|
|
||||||
if anim == self.ANIM_OFF:
|
# Volume Display takes over the ring temporarily, regardless of
|
||||||
|
# the current pipeline state, then falls back to the normal
|
||||||
|
# animation once its deadline passes.
|
||||||
|
if snap["volume_display_until"] > time.monotonic():
|
||||||
|
sleep = self._anim_volume_display(color, snap["volume"])
|
||||||
|
elif anim == self.ANIM_OFF:
|
||||||
sleep = self._anim_off()
|
sleep = self._anim_off()
|
||||||
elif anim == self.ANIM_IDLE:
|
elif anim == self.ANIM_IDLE:
|
||||||
sleep = self._anim_idle()
|
sleep = self._anim_idle()
|
||||||
@@ -810,7 +854,10 @@ class LVAClient:
|
|||||||
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
|
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
|
||||||
|
|
||||||
elif event == "volume_changed":
|
elif event == "volume_changed":
|
||||||
self._state.update(volume=data.get("volume", 1.0))
|
self._state.update(
|
||||||
|
volume=data.get("volume", 1.0),
|
||||||
|
volume_display_until=time.monotonic() + VOLUME_DISPLAY_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
elif event == "volume_muted":
|
elif event == "volume_muted":
|
||||||
Volume_muted = data.get("muted", True)
|
Volume_muted = data.get("muted", True)
|
||||||
@@ -818,7 +865,7 @@ class LVAClient:
|
|||||||
if Volume_muted:
|
if Volume_muted:
|
||||||
self._state.update(assist_state=AssistState.VOLUME_MUTED)
|
self._state.update(assist_state=AssistState.VOLUME_MUTED)
|
||||||
elif self._state.assist_state == AssistState.VOLUME_MUTED:
|
elif self._state.assist_state == AssistState.VOLUME_MUTED:
|
||||||
self._state.update(assist_state=AssistState.VOLUME_MUTED)
|
self._state.update(assist_state=AssistState.IDLE)
|
||||||
|
|
||||||
elif event == "zeroconf":
|
elif event == "zeroconf":
|
||||||
status = data.get("status", "")
|
status = data.get("status", "")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Peripheral controller for the [Satellite1 HAT Board](https://futureproofhomes.net/products/satellite1-top-microphone-board) by FutureProofHomes, running alongside the Linux Voice Assistant (LVA) container.
|
Peripheral controller for the [Satellite1 HAT Board](https://futureproofhomes.net/products/satellite1-top-microphone-board) by FutureProofHomes, running alongside the Linux Voice Assistant (LVA) container.
|
||||||
|
|
||||||
The controller runs as a separate Docker container on the same Raspberry Pi. It connects to LVA's peripheral WebSocket API, drives the 12-LED SK6812 ring with animations that mirror the [Home Assistant Voice PE](https://www.home-assistant.io/voice-pe/) LED behaviour, and maps the four hardware buttons to LVA commands with multipress support on the context action button.
|
The controller runs as a separate Docker container on the same Raspberry Pi. It connects to LVA's peripheral WebSocket API, drives the 24-LED WS2812 ring with animations that mirror the [Home Assistant Voice PE](https://www.home-assistant.io/voice-pe/) LED behaviour, and maps the four hardware buttons to LVA commands with multipress support on the context action button.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ The controller runs as a separate Docker container on the same Raspberry Pi. It
|
|||||||
| Component | Details |
|
| Component | Details |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Microphone array | XMOS XVF3800 (USB, far-field, onboard DSP) |
|
| Microphone array | XMOS XVF3800 (USB, far-field, onboard DSP) |
|
||||||
| LED ring | 12 × SK6812 RGBW NeoPixels |
|
| LED ring | 24 × WS2812 RGBW NeoPixels |
|
||||||
| Buttons | 4 × tactile (top, bottom, left, right) |
|
| Buttons | 4 × tactile (top, bottom, left, right) |
|
||||||
| Audio output | I2S line-out (optional) |
|
| Audio output | I2S line-out (optional) |
|
||||||
| Interface | Raspberry Pi 40-pin HAT connector |
|
| Interface | Raspberry Pi 40-pin HAT connector |
|
||||||
@@ -189,6 +189,7 @@ All animations mirror the Home Assistant Voice PE ESPHome firmware exactly. The
|
|||||||
| Error | Red pulse | All LEDs red, pulsing |
|
| Error | Red pulse | All LEDs red, pulsing |
|
||||||
| Timer ticking | Countdown arc | Arc length proportional to `seconds_left / total_seconds`, tinted by your color |
|
| Timer ticking | Countdown arc | Arc length proportional to `seconds_left / total_seconds`, tinted by your color |
|
||||||
| Timer ringing | Pulse + optional red | Full ring pulsing in your color; red at 3 & 9 if muted |
|
| Timer ringing | Pulse + optional red | Full ring pulsing in your color; red at 3 & 9 if muted |
|
||||||
|
| Volume changed | Volume Display arc | Temporary arc (2.5s) showing the current volume level, starting from the AUX jack corner LED (D1, index 0) and sweeping clockwise across all 24 LEDs |
|
||||||
| Volume muted | Solid ring + red indicator | red at the AUX jack corner LEDs positions 11, 0, 1 to indicate the media player volume is zero |
|
| Volume muted | Solid ring + red indicator | red at the AUX jack corner LEDs positions 11, 0, 1 to indicate the media player volume is zero |
|
||||||
|
|
||||||
**Note:** All animations except twinkle, off, and error animations use the color from the HA Light entity. This means you can customize the entire LED experience from Home Assistant by adjusting the light's RGB color and brightness.
|
**Note:** All animations except twinkle, off, and error animations use the color from the HA Light entity. This means you can customize the entire LED experience from Home Assistant by adjusting the light's RGB color and brightness.
|
||||||
@@ -204,7 +205,7 @@ All animations mirror the Home Assistant Voice PE ESPHome firmware exactly. The
|
|||||||
Edit `/boot/firmware/config.txt` (or `/boot/config.txt` on older Raspberry Pi OS):
|
Edit `/boot/firmware/config.txt` (or `/boot/config.txt` on older Raspberry Pi OS):
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
# Enable PWM on GPIO 12 for the SK6812 LED ring
|
# Enable PWM on GPIO 12 for the WS2812 LED ring
|
||||||
dtoverlay=pwm,pin=12,func=4
|
dtoverlay=pwm,pin=12,func=4
|
||||||
|
|
||||||
# Disable onboard audio — it shares PWM0 with GPIO 12
|
# Disable onboard audio — it shares PWM0 with GPIO 12
|
||||||
@@ -301,7 +302,7 @@ LONG_PRESS_MS = 1000 # Duration to detect long press (ms)
|
|||||||
|
|
||||||
|
|
||||||
# LED ring
|
# LED ring
|
||||||
LED_COUNT = 12
|
LED_COUNT = 24
|
||||||
LED_BRIGHTNESS = 168 # 0–255, default is 66 % (168)
|
LED_BRIGHTNESS = 168 # 0–255, default is 66 % (168)
|
||||||
|
|
||||||
# HA Light entity registration
|
# HA Light entity registration
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ hardware buttons to LVA peripheral API commands.
|
|||||||
|
|
||||||
Hardware layout (Satellite 1 HAT on Raspberry Pi)
|
Hardware layout (Satellite 1 HAT on Raspberry Pi)
|
||||||
--------------------------------------------------
|
--------------------------------------------------
|
||||||
LED ring : 12 × SK6812 RGBW NeoPixels → GPIO 12 (PWM0)
|
LED ring : 24 x WS2812 RGBW NeoPixels → GPIO 12 (PWM0)
|
||||||
Right btn : Volume Up → GPIO 17
|
Right btn : Volume Up → GPIO 17
|
||||||
Left btn : Volume Down → GPIO 27
|
Left btn : Volume Down → GPIO 27
|
||||||
Top btn : Mute / Unmute mic → GPIO 22
|
Top btn : Mute / Unmute mic → GPIO 22
|
||||||
@@ -91,21 +91,33 @@ BTN_ACTION = 23 # Bottom button
|
|||||||
BTN_DEBOUNCE_MS = 30 # Milliseconds
|
BTN_DEBOUNCE_MS = 30 # Milliseconds
|
||||||
|
|
||||||
# LED ring
|
# LED ring
|
||||||
LED_COUNT = 12
|
LED_COUNT = 24
|
||||||
LED_FREQ_HZ = 800_000
|
LED_FREQ_HZ = 800_000
|
||||||
LED_DMA = 10
|
LED_DMA = 10
|
||||||
LED_BRIGHTNESS = 168 # 66 % of 255 – matches ESPHome default
|
LED_BRIGHTNESS = 168 # 66 % of 255 – matches ESPHome default
|
||||||
LED_INVERT = False
|
LED_INVERT = False
|
||||||
LED_CHANNEL = 0
|
LED_CHANNEL = 0
|
||||||
|
|
||||||
|
# Four mic positions at the cardinal points (top, right, bottom, left),
|
||||||
|
# spaced LED_COUNT/4 apart. On the 24-LED ring: LEDs 0, 6, 12, 18.
|
||||||
|
MIC_LED_POSITIONS = tuple(i * (LED_COUNT // 4) for i in range(4))
|
||||||
|
|
||||||
# LEDs physically sitting in the AUX (3.5 mm) audio jack corner of the board,
|
# LEDs physically sitting in the AUX (3.5 mm) audio jack corner of the board,
|
||||||
# identified by their silkscreen designators on the Satellite1 HAT (D35, D1,
|
# identified by their silkscreen designators on the Satellite1 HAT (D35, D1,
|
||||||
# D2). These three ring positions light up solid red whenever the media
|
# D2). These three ring positions light up solid red whenever the media
|
||||||
# player volume is at zero (the `volume_muted` peripheral event), independent
|
# player volume is at zero (the `volume_muted` peripheral event), independent
|
||||||
# of whatever pipeline animation is currently running. Verify the mapping
|
# of whatever pipeline animation is currently running. Scaled to the 24-LED
|
||||||
# against your board revision — adjust if your unit's jack corner sits at a
|
# ring to cover the same angular span as the original 12-LED mapping —
|
||||||
# different position on the ring.
|
# verify against your board revision and adjust if it differs.
|
||||||
AUX_JACK_LED_INDICES = (11, 0, 1)
|
AUX_JACK_LED_INDICES = (22, 23, 0, 1, 2)
|
||||||
|
|
||||||
|
# Volume Display effect
|
||||||
|
# Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda, but
|
||||||
|
# rotated so the arc originates at the AUX jack corner LED (D1, index 0)
|
||||||
|
# instead of Voice PE's origin — matching this board's physical AUX jack
|
||||||
|
# placement. Shown as a temporary overlay for this many seconds after
|
||||||
|
# volume_changed.
|
||||||
|
VOLUME_DISPLAY_SECONDS = 2.5
|
||||||
|
|
||||||
# Default ring color (ESPHome default: 9.4 % R, 73.3 % G, 94.9 % B)
|
# Default ring color (ESPHome default: 9.4 % R, 73.3 % G, 94.9 % B)
|
||||||
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
|
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
|
||||||
@@ -246,6 +258,10 @@ class SharedState:
|
|||||||
self.ha_connected: bool = False
|
self.ha_connected: bool = False
|
||||||
self.muted: bool = False # mic mute
|
self.muted: bool = False # mic mute
|
||||||
self.volume: float = 1.0
|
self.volume: float = 1.0
|
||||||
|
# Monotonic deadline until which the Volume Display arc takes over
|
||||||
|
# the ring, set on each volume_changed event. 0 (or in the past)
|
||||||
|
# means the arc is not showing.
|
||||||
|
self.volume_display_until: float = 0.0
|
||||||
self.volume_muted: bool = False # media player volume zero
|
self.volume_muted: bool = False # media player volume zero
|
||||||
self.timer_total_seconds: int = 0
|
self.timer_total_seconds: int = 0
|
||||||
self.timer_seconds_left: int = 0
|
self.timer_seconds_left: int = 0
|
||||||
@@ -276,6 +292,7 @@ class SharedState:
|
|||||||
"ha_connected": self.ha_connected,
|
"ha_connected": self.ha_connected,
|
||||||
"muted": self.muted,
|
"muted": self.muted,
|
||||||
"volume": self.volume,
|
"volume": self.volume,
|
||||||
|
"volume_display_until": self.volume_display_until,
|
||||||
"volume_muted": self.volume_muted,
|
"volume_muted": self.volume_muted,
|
||||||
"timer_total_seconds": self.timer_total_seconds,
|
"timer_total_seconds": self.timer_total_seconds,
|
||||||
"timer_seconds_left": self.timer_seconds_left,
|
"timer_seconds_left": self.timer_seconds_left,
|
||||||
@@ -499,18 +516,12 @@ class LEDRing:
|
|||||||
def _anim_muted(self, color: RGB, muted: bool) -> float:
|
def _anim_muted(self, color: RGB, muted: bool) -> float:
|
||||||
"""
|
"""
|
||||||
Solid ring with red indicators at all 4 mic positions when muted.
|
Solid ring with red indicators at all 4 mic positions when muted.
|
||||||
Satellite 1 HAT has mics at 12, 3, 6 and 9 o'clock → LEDs 0, 3, 6, 9.
|
|
||||||
"""
|
"""
|
||||||
for i in range(LED_COUNT):
|
for i in range(LED_COUNT):
|
||||||
self._set(i, color)
|
self._set(i, color)
|
||||||
|
|
||||||
if muted:
|
if muted:
|
||||||
# 4 mic positions: top (0), right (3), bottom (6), left (9)
|
self._apply_mic_indicators()
|
||||||
# Blank the immediate neighbours so the red indicators stand out
|
|
||||||
self._set(11, BLACK); self._set(0, RED); self._set(1, BLACK)
|
|
||||||
self._set(2, BLACK); self._set(3, RED); self._set(4, BLACK)
|
|
||||||
self._set(5, BLACK); self._set(6, RED); self._set(7, BLACK)
|
|
||||||
self._set(8, BLACK); self._set(9, RED); self._set(10, BLACK)
|
|
||||||
|
|
||||||
self._write()
|
self._write()
|
||||||
return 0.016
|
return 0.016
|
||||||
@@ -567,16 +578,54 @@ class LEDRing:
|
|||||||
self._set(i, BLACK)
|
self._set(i, BLACK)
|
||||||
|
|
||||||
if muted:
|
if muted:
|
||||||
# 4 mic positions: LEDs 0, 3, 6, 9
|
self._apply_mic_indicators()
|
||||||
self._set(11, BLACK); self._set(0, RED); self._set(1, BLACK)
|
|
||||||
self._set(2, BLACK); self._set(3, RED); self._set(4, BLACK)
|
|
||||||
self._set(5, BLACK); self._set(6, RED); self._set(7, BLACK)
|
|
||||||
self._set(8, BLACK); self._set(9, RED); self._set(10, BLACK)
|
|
||||||
|
|
||||||
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
|
self._index = (LED_COUNT + self._index - 1) % LED_COUNT
|
||||||
self._write()
|
self._write()
|
||||||
return 0.1
|
return 0.1
|
||||||
|
|
||||||
|
def _anim_volume_display(self, color: RGB, volume: float) -> float:
|
||||||
|
"""
|
||||||
|
Arc showing the current volume level, originating at the AUX jack
|
||||||
|
corner LED (D1, index 0) and sweeping clockwise around the ring.
|
||||||
|
|
||||||
|
Mirrors the ESPHome Voice PE "Volume Display" addressable_lambda
|
||||||
|
exactly, except the arc's origin is rotated from LED 6 to LED 0 to
|
||||||
|
match this board's AUX jack corner LED placement.
|
||||||
|
"""
|
||||||
|
silenced_color = RED
|
||||||
|
volume_ratio = LED_COUNT * max(0.0, min(1.0, volume))
|
||||||
|
|
||||||
|
for i in range(LED_COUNT):
|
||||||
|
if i <= volume_ratio:
|
||||||
|
brightness = min(volume_ratio - i, 1.0)
|
||||||
|
self._set(i, _scale(color, brightness))
|
||||||
|
else:
|
||||||
|
self._set(i, BLACK)
|
||||||
|
|
||||||
|
if volume <= 0.0:
|
||||||
|
self._set(0, silenced_color)
|
||||||
|
|
||||||
|
self._write()
|
||||||
|
return 0.05 # matches the 50ms update_interval of the ESPHome effect
|
||||||
|
|
||||||
|
def _apply_mic_indicators(self) -> None:
|
||||||
|
"""
|
||||||
|
Mark all 4 mic positions (cardinal points) red on the already
|
||||||
|
rendered frame, blanking immediate neighbours so each indicator
|
||||||
|
reads as a distinct dot. Positions are LED_COUNT/4 apart —
|
||||||
|
LEDs 0, 6, 12, 18 on the 24-LED ring.
|
||||||
|
"""
|
||||||
|
for pos in MIC_LED_POSITIONS:
|
||||||
|
self._set((pos - 1) % LED_COUNT, BLACK)
|
||||||
|
self._set(pos, RED)
|
||||||
|
self._set((pos + 1) % LED_COUNT, BLACK)
|
||||||
|
|
||||||
|
def _apply_mic_indicators_pulsed(self, factor: float) -> None:
|
||||||
|
"""Pulsed version for timer_ring — keeps indicators in sync with ring brightness."""
|
||||||
|
for pos in MIC_LED_POSITIONS:
|
||||||
|
self._set(pos, _scale(RED, factor))
|
||||||
|
|
||||||
def _apply_volume_muted_indicator(self) -> None:
|
def _apply_volume_muted_indicator(self) -> None:
|
||||||
"""
|
"""
|
||||||
Overlay a solid red indicator on the AUX jack corner LEDs
|
Overlay a solid red indicator on the AUX jack corner LEDs
|
||||||
@@ -609,7 +658,12 @@ class LEDRing:
|
|||||||
t_left = snap["timer_seconds_left"]
|
t_left = snap["timer_seconds_left"]
|
||||||
ha_connected = snap["ha_connected"]
|
ha_connected = snap["ha_connected"]
|
||||||
|
|
||||||
if anim == self.ANIM_IDLE:
|
# Volume Display takes over the ring temporarily, regardless of
|
||||||
|
# the current pipeline state, then falls back to the normal
|
||||||
|
# animation once its deadline passes.
|
||||||
|
if snap["volume_display_until"] > time.monotonic():
|
||||||
|
sleep = self._anim_volume_display(color, snap["volume"])
|
||||||
|
elif anim == self.ANIM_IDLE:
|
||||||
sleep = self._anim_idle()
|
sleep = self._anim_idle()
|
||||||
|
|
||||||
elif anim == self.ANIM_OFF:
|
elif anim == self.ANIM_OFF:
|
||||||
@@ -1103,7 +1157,10 @@ class LVAClient:
|
|||||||
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
|
self._state.update(assist_state=AssistState.MEDIA_PLAYING)
|
||||||
|
|
||||||
elif event == "volume_changed":
|
elif event == "volume_changed":
|
||||||
self._state.update(volume=data.get("volume", 1.0))
|
self._state.update(
|
||||||
|
volume=data.get("volume", 1.0),
|
||||||
|
volume_display_until=time.monotonic() + VOLUME_DISPLAY_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
elif event == "volume_muted":
|
elif event == "volume_muted":
|
||||||
# Media player (speaker) mute state, distinct from the
|
# Media player (speaker) mute state, distinct from the
|
||||||
|
|||||||
Reference in new issue
Block a user