Fix peripheral volume_muted state (#403)

Enable updating the `volume_muted` status when muting/unmuting the media player volume, and improve its tracking.
This commit is contained in:
Omar Amin authored and GitHub committed 2026-08-17 20:23:16 +03:00
1 parent e334aa3fb1
commit 8303a96279
12 files changed
+177 -18

No files matched your search

@@ -361,7 +361,12 @@
volBar.style.width = pct + '%'; volBar.style.width = pct + '%';
} }
if (ev === 'volume_muted' && data) { if (ev === 'volume_muted' && data) {
metaMuted.textContent = data.muted ? 'yes' : 'no'; const isMuted = !!data.muted;
metaMuted.textContent = isMuted ? 'yes' : 'no';
if (isMuted) {
metaVol.textContent = '0%';
volBar.style.width = '0%';
}
} }
if (ev === 'zeroconf' && data) { if (ev === 'zeroconf' && data) {
metaHa.textContent = data.status === 'connected' ? 'yes' : 'connecting…'; metaHa.textContent = data.status === 'connected' ? 'yes' : 'connecting…';
+1
View File
@@ -75,6 +75,7 @@ LED 0 sits above MIC_L and LED 2 sits above MIC_R. When the microphone is muted,
| **Timer ringing** | **Blue flash (repeating)** | **All 3** | | **Timer ringing** | **Blue flash (repeating)** | **All 3** |
| Timer ticking | Dim cyan, brightness ∝ time left | All 3 | | Timer ticking | Dim cyan, brightness ∝ time left | All 3 |
| Media playing | Dim green steady | All 3 | | Media playing | Dim green steady | All 3 |
| **Volume muted** | **Solid red** | **LED 1 only (center 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 (Thinking yellow, Speaking green, Muted red, Timer cyan/blue) 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 (Thinking yellow, Speaking green, Muted red, Timer cyan/blue) are hardcoded so they remain recognisable across user customisation.
@@ -20,7 +20,7 @@ LED behaviours
pipeline_error : red flash on all 3 LEDs pipeline_error : red flash on all 3 LEDs
timer_ringing : blue flash on all 3 LEDs (repeating) timer_ringing : blue flash on all 3 LEDs (repeating)
timer_ticking : all 3 dim cyan, brightness proportional to time left timer_ticking : all 3 dim cyan, brightness proportional to time left
media_playing : dim green steady on all 3 LEDs volume_muted : LED 1 solid red, LED 0 and LED 2 off
not_ready/no_ha : dim red pulse on all 3 LEDs not_ready/no_ha : dim red pulse on all 3 LEDs
On connect the script registers an HA Light entity with LVA via the On connect the script registers an HA Light entity with LVA via the
@@ -130,7 +130,7 @@ RECONNECT_DELAY_S = 3.0
# is used to route incoming light_command events back to this script. # is used to route incoming light_command events back to this script.
LIGHT_OBJECT_ID = "leds" LIGHT_OBJECT_ID = "leds"
LIGHT_NAME = "LEDs" LIGHT_NAME = "LEDs"
LIGHT_ICON = "mdi:circle-outline" LIGHT_ICON = "mdi:led-strip-varient"
# =========================================================================== # ===========================================================================
@@ -157,6 +157,7 @@ class AssistState(str, Enum):
TIMER_TICKING = "timer_ticking" TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing" TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing" MEDIA_PLAYING = "media_player_playing"
VOLUME_MUTED = "volume_muted"
# Effect name. Must match the LEDLightEntity effects list the peripheral # Effect name. Must match the LEDLightEntity effects list the peripheral
@@ -175,8 +176,9 @@ class SharedState:
self._lock = threading.Lock() self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False self.ha_connected: bool = False
self.muted: bool = False self.muted: bool = False # mic mute
self.volume: float = 1.0 self.volume: float = 1.0
self.volume_muted: bool = False # media 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
# Light entity state, driven by HA via light_command events. # Light entity state, driven by HA via light_command events.
@@ -213,6 +215,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_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,
"light_is_on": self.light_is_on, "light_is_on": self.light_is_on,
@@ -379,6 +382,9 @@ class LEDAnimator:
# Left and right LEDs red (mic positions), centre off # Left and right LEDs red (mic positions), centre off
self._task = asyncio.create_task(self._muted()) self._task = asyncio.create_task(self._muted())
elif state == AssistState.VOLUME_MUTED:
self._task = asyncio.create_task(self._volume_muted())
elif state == AssistState.ERROR: elif state == AssistState.ERROR:
self._task = asyncio.create_task( self._task = asyncio.create_task(
self._flash_all(RED, flashes=3, on_ms=150, off_ms=100, then_off=True) self._flash_all(RED, flashes=3, on_ms=150, off_ms=100, then_off=True)
@@ -456,6 +462,13 @@ class LEDAnimator:
self._leds.set(2, RED) self._leds.set(2, RED)
self._leds.show(self._brightness(0.6)) self._leds.show(self._brightness(0.6))
async def _volume_muted(self) -> None:
"""Centre LED red; left and right off."""
self._leds.set(0, OFF)
self._leds.set(1, RED)
self._leds.set(2, OFF)
self._leds.show(self._brightness(0.6))
async def _flash_all( async def _flash_all(
self, self,
color: ColorSource, color: ColorSource,
@@ -922,11 +935,11 @@ class LVAClient:
self._state.update(volume=data.get("volume", 1.0)) self._state.update(volume=data.get("volume", 1.0))
elif event == "volume_muted": elif event == "volume_muted":
muted = data.get("muted", False) volume_muted = data.get("muted", True)
self._state.update(muted=muted) self._state.update(volume_muted=volume_muted)
if muted: if volume_muted:
self._state.update(assist_state=AssistState.MUTED) self._state.update(assist_state=AssistState.VOLUME_MUTED)
elif self._state.assist_state == AssistState.MUTED: elif self._state.assist_state == AssistState.VOLUME_MUTED:
self._state.update(assist_state=AssistState.IDLE) self._state.update(assist_state=AssistState.IDLE)
elif event == "zeroconf": elif event == "zeroconf":
+2 -1
View File
@@ -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 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.
@@ -235,4 +236,4 @@ The seeed-voicecard driver uses I2S — it does **not** use SPI — so there is
1. Confirm LVA is running and port 6055 is open: `nc -zv localhost 6055`. 1. Confirm LVA is running and port 6055 is open: `nc -zv localhost 6055`.
2. With `network_mode: host`, `localhost` resolves to the Pi itself. 2. With `network_mode: host`, `localhost` resolves to the Pi itself.
3. If LVA runs in a separate Docker network, use its container IP or service name as `--host`. 3. If LVA runs in a separate Docker network, use its container IP or service name as `--host`.
@@ -93,6 +93,15 @@ SPI_DEVICE = 1
# 10 ≈ 32 % (gentle default), 31 = maximum. # 10 ≈ 32 % (gentle default), 31 = maximum.
LED_BRIGHTNESS = 10 LED_BRIGHTNESS = 10
# 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
# the bottom-middle position (180°, directly opposite index 0 at the top),
# following the same 0=top / 3=right / 6=bottom / 9=left convention used for
# the mic corner indicators below. Sits between the two bottom mic corners
# (LEDs 4 and 7) so it reads as a distinct "speaker muted" dot rather than
# overlapping a mic indicator.
VOLUME_MUTED_LED_INDEX = 6
# Default ring colour (R, G, B) — matches HA Voice PE default # Default ring colour (R, G, B) — matches HA Voice PE default
DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242 DEFAULT_R, DEFAULT_G, DEFAULT_B = 24, 187, 242
@@ -124,6 +133,7 @@ class AssistState(str, Enum):
TIMER_TICKING = "timer_ticking" TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing" TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing" MEDIA_PLAYING = "media_player_playing"
VOLUME_MUTED = "volume_muted"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -135,8 +145,9 @@ class SharedState:
self._lock = threading.Lock() self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False self.ha_connected: bool = False
self.muted: bool = False self.muted: bool = False # mic mute
self.volume: float = 1.0 self.volume: float = 1.0
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
# Light entity state, driven by HA via light_command events. # Light entity state, driven by HA via light_command events.
@@ -162,6 +173,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_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,
"light_is_on": self.light_is_on, "light_is_on": self.light_is_on,
@@ -317,6 +329,19 @@ class LEDRing:
self._set(7, _scale(RED, factor)) self._set(7, _scale(RED, factor))
self._set(10, _scale(RED, factor)) self._set(10, _scale(RED, factor))
def _apply_volume_muted_indicator(self) -> None:
"""
Overlay a solid red dot on the bottom-middle LED (index
``VOLUME_MUTED_LED_INDEX``) when the media player output is muted.
Applied after the current pipeline animation has already rendered
its frame, so the indicator shows up consistently no matter which
animation is active — mirrors how ``_apply_mic_indicators`` marks
the microphone corners, but for the distinct speaker-mute state.
"""
self._set(VOLUME_MUTED_LED_INDEX, RED)
self._write()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Animation implementations # Animation implementations
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -465,6 +490,7 @@ class LEDRing:
snap = self._state.snapshot snap = self._state.snapshot
color = self._color() color = self._color()
muted = snap["muted"] muted = snap["muted"]
volume_muted = snap["volume_muted"]
t_total = snap["timer_total_seconds"] t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"] t_left = snap["timer_seconds_left"]
@@ -495,6 +521,9 @@ class LEDRing:
else: else:
sleep = self._anim_off() sleep = self._anim_off()
if volume_muted:
self._apply_volume_muted_indicator()
time.sleep(sleep) time.sleep(sleep)
@@ -690,7 +719,17 @@ class LVAClient:
self._state.update(volume=data.get("volume", 1.0)) self._state.update(volume=data.get("volume", 1.0))
elif event == "volume_muted": elif event == "volume_muted":
self._state.update(muted=data.get("muted", False)) # Media player (speaker) mute state, carried as {"muted": bool}
# per the peripheral API — distinct from the microphone `muted`
# event above. Drives the bottom-middle LED indicator rather
# than replacing the whole ring animation, since the assist
# pipeline can still be doing something else at the same time.
volume_muted = data.get("muted", True)
self._state.update(volume_muted=volume_muted)
if volume_muted:
self._state.update(assist_state=AssistState.VOLUME_MUTED)
elif self._state.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", "")
@@ -793,4 +832,4 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() main()
@@ -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 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,13 @@ 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)
# 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
# board's bottom-middle position (DOA 270°), sitting right between the
# micro-USB connector and the 3.5 mm AUX jack on the board edge — the same
# "bottom" position already used by the mic indicator mapping below.
VOLUME_MUTED_LED_INDEX = 9
RECONNECT_DELAY_S = 3.0 RECONNECT_DELAY_S = 3.0
@@ -113,6 +120,7 @@ class AssistState(str, Enum):
TIMER_TICKING = "timer_ticking" TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing" TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing" MEDIA_PLAYING = "media_player_playing"
VOLUME_MUTED = "volume_muted"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -124,8 +132,9 @@ class SharedState:
self._lock = threading.Lock() self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False self.ha_connected: bool = False
self.muted: bool = False self.muted: bool = False # mic mute
self.volume: float = 1.0 self.volume: float = 1.0
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
# Light entity state, driven by HA via light_command events. # Light entity state, driven by HA via light_command events.
@@ -151,6 +160,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_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,
"light_is_on": self.light_is_on, "light_is_on": self.light_is_on,
@@ -407,6 +417,22 @@ 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 _apply_volume_muted_indicator(self) -> None:
"""
Overlay a solid red dot on the bottom-middle LED (index
``VOLUME_MUTED_LED_INDEX``, DOA 270°) when the media player output
is muted at volume 0.
Sits right between the board's micro-USB connector and 3.5 mm AUX
jack. Applied after the current pipeline animation has already
rendered its frame, so the indicator shows up consistently no
matter which animation is active — mirrors how
``_apply_mic_indicators`` marks the microphone cardinal points, but
for the distinct speaker-mute state.
"""
self._leds.set(VOLUME_MUTED_LED_INDEX, RED)
self._leds.show()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Animations — each returns sleep time in seconds # Animations — each returns sleep time in seconds
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -555,6 +581,7 @@ class LEDRing:
snap = self._state.snapshot snap = self._state.snapshot
color = self._color() color = self._color()
muted = snap["muted"] muted = snap["muted"]
volume_muted = snap["volume_muted"]
t_total = snap["timer_total_seconds"] t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"] t_left = snap["timer_seconds_left"]
@@ -585,6 +612,9 @@ class LEDRing:
else: else:
sleep = self._anim_off() sleep = self._anim_off()
if volume_muted:
self._apply_volume_muted_indicator()
time.sleep(sleep) time.sleep(sleep)
@@ -783,7 +813,12 @@ class LVAClient:
self._state.update(volume=data.get("volume", 1.0)) self._state.update(volume=data.get("volume", 1.0))
elif event == "volume_muted": elif event == "volume_muted":
self._state.update(muted=data.get("muted", False)) Volume_muted = data.get("muted", True)
self._state.update(volume_muted=Volume_muted)
if Volume_muted:
self._state.update(assist_state=AssistState.VOLUME_MUTED)
elif self._state.assist_state == AssistState.VOLUME_MUTED:
self._state.update(assist_state=AssistState.VOLUME_MUTED)
elif event == "zeroconf": elif event == "zeroconf":
status = data.get("status", "") status = data.get("status", "")
+2
View File
@@ -68,6 +68,7 @@ In Home Assistant, you can:
- Thinking → Pulse in your color - Thinking → Pulse in your color
- Speaking → Reverse spin in your color - Speaking → Reverse spin in your color
- Muted → Solid ring in your color + red at mic positions - Muted → Solid ring in your color + red at mic positions
- Volume muted → Solid ring in your color + red at AUX jack corner LEDs
- Timer → Countdown arc in your color - Timer → Countdown arc in your color
3. **Set brightness** — scales all animations by the chosen brightness (0–100%). Useful for adapting to ambient lighting. 3. **Set brightness** — scales all animations by the chosen brightness (0–100%). Useful for adapting to ambient lighting.
@@ -188,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 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.
@@ -98,6 +98,15 @@ LED_BRIGHTNESS = 168 # 66 % of 255 – matches ESPHome default
LED_INVERT = False LED_INVERT = False
LED_CHANNEL = 0 LED_CHANNEL = 0
# 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,
# D2). These three ring positions light up solid red whenever the media
# player volume is at zero (the `volume_muted` peripheral event), independent
# of whatever pipeline animation is currently running. Verify the mapping
# against your board revision — adjust if your unit's jack corner sits at a
# different position on the ring.
AUX_JACK_LED_INDICES = (11, 0, 1)
# 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
@@ -223,6 +232,7 @@ class AssistState(str, Enum):
TIMER_TICKING = "timer_ticking" TIMER_TICKING = "timer_ticking"
TIMER_RINGING = "timer_ringing" TIMER_RINGING = "timer_ringing"
MEDIA_PLAYING = "media_player_playing" MEDIA_PLAYING = "media_player_playing"
VOLUME_MUTED = "volume_muted"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -234,8 +244,9 @@ class SharedState:
self._lock = threading.Lock() self._lock = threading.Lock()
self.assist_state: AssistState = AssistState.NOT_READY self.assist_state: AssistState = AssistState.NOT_READY
self.ha_connected: bool = False self.ha_connected: bool = False
self.muted: bool = False self.muted: bool = False # mic mute
self.volume: float = 1.0 self.volume: float = 1.0
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
# Light entity state, driven by HA via light_command events. # Light entity state, driven by HA via light_command events.
@@ -265,6 +276,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_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,
"light_is_on": self.light_is_on, "light_is_on": self.light_is_on,
@@ -565,6 +577,21 @@ class LEDRing:
self._write() self._write()
return 0.1 return 0.1
def _apply_volume_muted_indicator(self) -> None:
"""
Overlay a solid red indicator on the AUX jack corner LEDs
(designators D35, D1, D2 — see ``AUX_JACK_LED_INDICES``) when the
media player output is muted.
Applied as a post-render overlay after the current pipeline
animation has already written its frame, so the indicator shows up
consistently no matter which animation is active, without having to
thread the flag through every individual ``_anim_*`` method.
"""
for i in AUX_JACK_LED_INDICES:
self._set(i, RED)
self._write()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Animation loop # Animation loop
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -577,6 +604,7 @@ class LEDRing:
snap = self._state.snapshot snap = self._state.snapshot
color = self._color() color = self._color()
muted = snap["muted"] muted = snap["muted"]
volume_muted = snap["volume_muted"]
t_total = snap["timer_total_seconds"] t_total = snap["timer_total_seconds"]
t_left = snap["timer_seconds_left"] t_left = snap["timer_seconds_left"]
ha_connected = snap["ha_connected"] ha_connected = snap["ha_connected"]
@@ -620,6 +648,9 @@ class LEDRing:
else: else:
sleep = self._anim_off() sleep = self._anim_off()
if volume_muted:
self._apply_volume_muted_indicator()
time.sleep(sleep) time.sleep(sleep)
@@ -986,6 +1017,9 @@ class LVAClient:
self._state.update( self._state.update(
muted=data.get("muted", False), muted=data.get("muted", False),
volume=data.get("volume", 1.0), volume=data.get("volume", 1.0),
# Not currently sent by LVA's snapshot payload, but read it
# defensively in case a future LVA version adds it.
volume_muted=data.get("volume_muted", False),
ha_connected=data.get("ha_connected", False), ha_connected=data.get("ha_connected", False),
) )
snap = self._state.snapshot snap = self._state.snapshot
@@ -1072,7 +1106,17 @@ class LVAClient:
self._state.update(volume=data.get("volume", 1.0)) self._state.update(volume=data.get("volume", 1.0))
elif event == "volume_muted": elif event == "volume_muted":
self._state.update(muted=data.get("muted", False)) # Media player (speaker) mute state, distinct from the
# microphone `muted` event above. Drives the red indicator on
# the AUX jack corner LEDs (see AUX_JACK_LED_INDICES) rather
# than swapping the whole ring animation, since the assist
# pipeline can still be doing something else at the same time.
volume_muted = data.get("muted", True)
self._state.update(volume_muted=volume_muted)
if volume_muted:
self._state.update(assist_state=AssistState.VOLUME_MUTED)
elif self._state.assist_state == AssistState.VOLUME_MUTED:
self._state.update(assist_state=AssistState.IDLE)
# --- Zeroconf / connection events ---------------------------------- # --- Zeroconf / connection events ----------------------------------
elif event == "zeroconf": elif event == "zeroconf":
+5
View File
@@ -217,6 +217,7 @@ class ServerState:
_LOGGER.debug("Skipping save - volume unchanged") _LOGGER.debug("Skipping save - volume unchanged")
return return
previous_muted = self.volume == 0.0
self.volume = clamped_volume self.volume = clamped_volume
self.preferences.volume = clamped_volume self.preferences.volume = clamped_volume
_LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path) _LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path)
@@ -230,6 +231,10 @@ class ServerState:
api.emit_event_sync(LVAEvent.VOLUME_CHANGED, {"volume": round(clamped_volume, 3)}) api.emit_event_sync(LVAEvent.VOLUME_CHANGED, {"volume": round(clamped_volume, 3)})
new_muted = clamped_volume == 0.0
if previous_muted != new_muted:
api.emit_event_sync(LVAEvent.VOLUME_MUTED, {"muted": new_muted})
def persist_mic_gain(self, gain: float) -> None: def persist_mic_gain(self, gain: float) -> None:
"""Persist the microphone auto gain value.""" """Persist the microphone auto gain value."""
gain_int = int(gain) gain_int = int(gain)
+2
View File
@@ -278,6 +278,7 @@ class PeripheralAPIServer:
"data": { "data": {
"muted": state.muted, "muted": state.muted,
"volume": round(state.volume, 3), "volume": round(state.volume, 3),
"volume_muted": state.volume == 0.0,
"ha_connected": state.connected, "ha_connected": state.connected,
"last_stt_text": self._last_stt_text, "last_stt_text": self._last_stt_text,
"last_tts_text": self._last_tts_text, "last_tts_text": self._last_tts_text,
@@ -659,6 +660,7 @@ class PeripheralAPIServer:
LVAEvent.MEDIA_PLAYER_PLAYING, LVAEvent.MEDIA_PLAYER_PLAYING,
LVAEvent.DISCONNECTED, LVAEvent.DISCONNECTED,
LVAEvent.PIPELINE_ERROR, LVAEvent.PIPELINE_ERROR,
LVAEvent.VOLUME_MUTED,
} }
if event in _STATE_EVENTS: if event in _STATE_EVENTS:
self._current_state = event self._current_state = event
+12 -1
View File
@@ -1,7 +1,7 @@
"""Unit tests for shared models.""" """Unit tests for shared models."""
import json import json
from unittest.mock import patch from unittest.mock import Mock, patch
import pytest import pytest
@@ -192,6 +192,17 @@ class TestPersistVolume:
state.persist_volume(1.0) state.persist_volume(1.0)
assert state.volume == 1.0 assert state.volume == 1.0
def test_emits_volume_muted_event_when_volume_zero(self, tmp_path):
from linux_voice_assistant.peripheral_api import LVAEvent
state = make_server_state(tmp_path)
state.peripheral_api = Mock()
state.persist_volume(0.0)
state.peripheral_api.emit_event_sync.assert_any_call(LVAEvent.VOLUME_CHANGED, {"volume": 0.0})
state.peripheral_api.emit_event_sync.assert_any_call(LVAEvent.VOLUME_MUTED, {"muted": True})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ServerState.persist_mic_gain() # ServerState.persist_mic_gain()