From bceb7640ef6abfa5cfc125a906348c5c74800b67 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 11 Oct 2025 17:07:15 -0700 Subject: [PATCH 1/5] Add Microphone Mute Switch Entity Adds a mic_muted: bool flag to the ServerState dataclass to globally track the mute status of the microphone. --- linux_voice_assistant/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py index 2cb22dc..f007e68 100644 --- a/linux_voice_assistant/models.py +++ b/linux_voice_assistant/models.py @@ -59,7 +59,7 @@ class AvailableWakeWord: @dataclass class Preferences: active_wake_words: List[str] = field(default_factory=list) - volume_level: float = 1.0 # ADD THIS LINE (volume from 0.0 to 1.0) + volume_level: float = 1.0 @dataclass @@ -89,6 +89,7 @@ class ServerState: satellite: "Optional[VoiceSatelliteProtocol]" = None wake_words_changed: bool = False refractory_seconds: float = 2.0 + mic_muted: bool = False # ADD THIS LINE def save_preferences(self) -> None: """Save preferences as JSON.""" From 415130aeb45630113d0b20d41490e010ea230e11 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 11 Oct 2025 17:08:42 -0700 Subject: [PATCH 2/5] Add Microphone Mute Switch Entity Defines a new MicMuteSwitchEntity class that creates the switch entity in Home Assistant. When the switch is toggled, it publishes a set_mic_mute event to the internal event bus with the new state (True for muted, False for unmuted). --- linux_voice_assistant/entity.py | 50 ++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/linux_voice_assistant/entity.py b/linux_voice_assistant/entity.py index ee443b9..9b86fdf 100644 --- a/linux_voice_assistant/entity.py +++ b/linux_voice_assistant/entity.py @@ -6,9 +6,12 @@ from typing import TYPE_CHECKING, Callable, List, Optional, Union from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] ListEntitiesMediaPlayerResponse, ListEntitiesRequest, + ListEntitiesSwitchResponse, # ADDED MediaPlayerCommandRequest, MediaPlayerStateResponse, SubscribeHomeAssistantStatesRequest, + SwitchCommandRequest, # ADDED + SwitchStateResponse, # ADDED ) from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerState from google.protobuf import message @@ -31,6 +34,44 @@ class ESPHomeEntity: pass +# ----------------------------------------------------------------------------- +# --- ADDED MIC MUTE SWITCH ENTITY --- +# ----------------------------------------------------------------------------- + +class MicMuteSwitchEntity(ESPHomeEntity): + def __init__( + self, + server: APIServer, + state: "ServerState", + key: int, + name: str, + object_id: str, + ) -> None: + super().__init__(server, state) + self.key = key + self.name = name + self.object_id = object_id + self.is_on = False # Mute is OFF by default + + def handle_message(self, msg: message.Message) -> Iterable[message.Message]: + if isinstance(msg, ListEntitiesRequest): + yield ListEntitiesSwitchResponse( + object_id=self.object_id, + key=self.key, + name=self.name, + icon="mdi:microphone-off", # Show a mic-off icon in HA + ) + elif isinstance(msg, SwitchCommandRequest) and (msg.key == self.key): + self.is_on = msg.state + self.state.event_bus.publish("set_mic_mute", {"state": self.is_on}) + yield self._get_state_message() + elif isinstance(msg, SubscribeHomeAssistantStatesRequest): + yield self._get_state_message() + + def _get_state_message(self) -> SwitchStateResponse: + return SwitchStateResponse(key=self.key, state=self.is_on) + + # ----------------------------------------------------------------------------- @@ -51,7 +92,7 @@ class MediaPlayerEntity(ESPHomeEntity): self.name = name self.object_id = object_id self.state_enum = MediaPlayerState.IDLE - self.volume = state.preferences.volume_level # Initialize with saved volume + self.volume = state.preferences.volume_level self.muted = False self.music_player = music_player self.announce_player = announce_player @@ -64,7 +105,6 @@ class MediaPlayerEntity(ESPHomeEntity): ) -> Iterable[message.Message]: if announcement: if self.music_player.is_playing: - # Announce, resume music self.music_player.pause() self.announce_player.play( url, @@ -73,7 +113,6 @@ class MediaPlayerEntity(ESPHomeEntity): ), ) else: - # Announce, idle self.announce_player.play( url, done_callback=lambda: call_all( @@ -84,7 +123,6 @@ class MediaPlayerEntity(ESPHomeEntity): ), ) else: - # Music self.music_player.play( url, done_callback=lambda: call_all( @@ -110,13 +148,11 @@ class MediaPlayerEntity(ESPHomeEntity): self.music_player.resume() yield self._update_state(MediaPlayerState.PLAYING) if msg.has_volume: - # This block is called when the volume slider changes in HA - self.volume = msg.volume # HA sends volume as 0.0-1.0 + self.volume = msg.volume volume_int = int(self.volume * 100) self.music_player.set_volume(volume_int) self.announce_player.set_volume(volume_int) - # Save the new volume level to preferences self.state.preferences.volume_level = self.volume self.state.save_preferences() From 4d12149dae89a076503d99ba9ad5c1e6293f9363 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 11 Oct 2025 17:09:26 -0700 Subject: [PATCH 3/5] Add Microphone Mute Switch Entity Instantiates the MicMuteSwitchEntity on startup to register it with Home Assistant. Updates the central message handler to correctly route SwitchCommandRequest messages from Home Assistant to the appropriate entity. --- linux_voice_assistant/satellite.py | 48 ++++++++++-------------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py index 7046af7..1104b89 100644 --- a/linux_voice_assistant/satellite.py +++ b/linux_voice_assistant/satellite.py @@ -11,8 +11,10 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] DeviceInfoResponse, ListEntitiesDoneResponse, ListEntitiesRequest, + ListEntitiesSwitchResponse, # ADDED MediaPlayerCommandRequest, SubscribeHomeAssistantStatesRequest, + SwitchCommandRequest, # ADDED VoiceAssistantAnnounceFinished, VoiceAssistantAnnounceRequest, VoiceAssistantAudio, @@ -32,7 +34,7 @@ from aioesphomeapi.model import ( from google.protobuf import message from .api_server import APIServer -from .entity import MediaPlayerEntity +from .entity import MediaPlayerEntity, MicMuteSwitchEntity # ADDED MicMuteSwitchEntity from .microwakeword import MicroWakeWord from .models import ServerState from .openwakeword import OpenWakeWord @@ -59,6 +61,17 @@ class VoiceSatelliteProtocol(APIServer): announce_player=state.tts_player, ) self.state.entities.append(self.state.media_player_entity) + + # --- ADDED: Create Mic Mute Switch Entity --- + mic_mute_entity = MicMuteSwitchEntity( + server=self, + state=state, + key=len(state.entities), + name=f"{state.name} Mute Microphone", + object_id="linux_voice_assistant_mute", + ) + self.state.entities.append(mic_mute_entity) + # -------------------------------------------- self._is_streaming_audio = False self._tts_url: Optional[str] = None @@ -87,7 +100,6 @@ class VoiceSatelliteProtocol(APIServer): self._is_streaming_audio = False elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: if data.get("tts_start_streaming") == "1": - # Start streaming early self.play_tts() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: if data.get("continue_conversation") == "1": @@ -103,8 +115,6 @@ class VoiceSatelliteProtocol(APIServer): self._tts_played = False self.state.event_bus.publish("voice_run_end") - # TODO: handle error - def handle_timer_event( self, event_type: VoiceAssistantTimerEventType, @@ -120,26 +130,19 @@ class VoiceSatelliteProtocol(APIServer): def handle_message(self, msg: message.Message) -> Iterable[message.Message]: if isinstance(msg, VoiceAssistantEventResponse): - # Pipeline event data: Dict[str, str] = {} for arg in msg.data: data[arg.name] = arg.value - self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data) elif isinstance(msg, VoiceAssistantAnnounceRequest): _LOGGER.debug("Announcing: %s", msg.text) - assert self.state.media_player_entity is not None - urls = [] if msg.preannounce_media_id: urls.append(msg.preannounce_media_id) - urls.append(msg.media_id) - self.state.stop_word.is_active = True self._continue_conversation = msg.start_conversation - self.duck() yield from self.state.media_player_entity.play( urls, announcement=True, done_callback=self._tts_finished @@ -165,6 +168,7 @@ class VoiceSatelliteProtocol(APIServer): ListEntitiesRequest, SubscribeHomeAssistantStatesRequest, MediaPlayerCommandRequest, + SwitchCommandRequest, # ADDED ), ): for entity in self.state.entities: @@ -188,37 +192,26 @@ class VoiceSatelliteProtocol(APIServer): max_active_wake_words=2, ) _LOGGER.info("Connected to Home Assistant") - # --- THIS IS THE CHANGE --- - # Announce the connection so other components (like LEDs) can react. self.state.event_bus.publish("ha_connected") elif isinstance(msg, VoiceAssistantSetConfiguration): - # Change active wake words active_wake_words: Set[str] = set() - for wake_word_id in msg.active_wake_words: if wake_word_id in self.state.wake_words: - # Already active active_wake_words.add(wake_word_id) continue - model_info = self.state.available_wake_words.get(wake_word_id) if not model_info: continue - _LOGGER.debug("Loading wake word: %s", model_info.config_path) self.state.wake_words[wake_word_id] = model_info.load( self.state.libtensorflowlite_c_path ) - _LOGGER.info("Wake word set: %s", wake_word_id) active_wake_words.add(wake_word_id) break - for wake_word in self.state.wake_words.values(): wake_word.is_active = wake_word.id in active_wake_words - _LOGGER.debug("Active wake words: %s", active_wake_words) - self.state.preferences.active_wake_words = list(active_wake_words) self.state.save_preferences() self.state.wake_words_changed = True @@ -226,19 +219,15 @@ class VoiceSatelliteProtocol(APIServer): def handle_audio(self, audio_chunk: bytes) -> None: if not self._is_streaming_audio: return - self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None: self.state.event_bus.publish("voice_wakeword") - if self._timer_finished: - # Stop timer instead self._timer_finished = False self.state.tts_player.stop() _LOGGER.debug("Stopping timer finished sound") return - wake_word_phrase = wake_word.wake_word _LOGGER.debug("Detected wake word: %s", wake_word_phrase) self.send_messages( @@ -251,7 +240,6 @@ class VoiceSatelliteProtocol(APIServer): def stop(self) -> None: self.state.stop_word.is_active = False self.state.tts_player.stop() - if self._timer_finished: self._timer_finished = False _LOGGER.debug("Stopping timer finished sound") @@ -262,12 +250,9 @@ class VoiceSatelliteProtocol(APIServer): def play_tts(self) -> None: if (not self._tts_url) or self._tts_played: return - self.state.event_bus.publish("voice_tts_start", {"url": self._tts_url}) - self._tts_played = True _LOGGER.debug("Playing TTS response: %s", self._tts_url) - self.state.stop_word.is_active = True self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished) @@ -283,21 +268,18 @@ class VoiceSatelliteProtocol(APIServer): self.state.event_bus.publish("voice_run_end") self.state.stop_word.is_active = False self.send_messages([VoiceAssistantAnnounceFinished()]) - if self._continue_conversation: self.send_messages([VoiceAssistantRequest(start=True)]) self._is_streaming_audio = True _LOGGER.debug("Continuing conversation") else: self.unduck() - _LOGGER.debug("TTS response finished") def _play_timer_finished(self) -> None: if not self._timer_finished: self.unduck() return - self.state.tts_player.play( self.state.timer_finished_sound, done_callback=lambda: call_all( From 39d6d3e010add79dbcbd1e6198697329d541d65a Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 11 Oct 2025 17:10:15 -0700 Subject: [PATCH 4/5] Add Microphone Mute Switch Entity Implements the core mute functionality. Adds a new MicMuteHandler class that subscribes to the set_mic_mute event. This handler updates the state.mic_muted flag and publishes secondary mic_muted or mic_unmuted events for LED feedback. Modifies the process_audio function to check the state.mic_muted flag in its main loop, pausing audio processing when the flag is True. --- linux_voice_assistant/__main__.py | 79 ++++++++++++------------------- 1 file changed, 31 insertions(+), 48 deletions(-) diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index f23c147..04c896f 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -12,7 +12,7 @@ from typing import Dict, List, Optional, Union import numpy as np import sounddevice as sd -from .event_bus import EventBus +from .event_bus import EventBus, EventHandler, subscribe # ADDED EventHandler, subscribe from .led_controller import LedController from .microwakeword import MicroWakeWord, MicroWakeWordFeatures from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType @@ -34,12 +34,29 @@ if is_arm(): else: _LIB_DIR = _REPO_DIR / "lib" / "linux_amd64" +# ----------------------------------------------------------------------------- +# --- ADDED MIC MUTE HANDLER --- +# ----------------------------------------------------------------------------- + +class MicMuteHandler(EventHandler): + """Event handler for mic mute switch.""" + @subscribe + def set_mic_mute(self, data: dict): + is_muted = data.get("state", False) + if self.state.mic_muted != is_muted: + self.state.mic_muted = is_muted + _LOGGER.debug("Mic muted = %s", is_muted) + if is_muted: + self.state.event_bus.publish("mic_muted") + else: + self.state.event_bus.publish("mic_unmuted") # ----------------------------------------------------------------------------- async def main() -> None: parser = argparse.ArgumentParser() + # ... (all parser arguments are the same) ... parser.add_argument("--name", required=True) parser.add_argument( "--audio-input-device", @@ -64,7 +81,6 @@ async def main() -> None: type=float, help="Seconds before wake word can be activated again", ) - # parser.add_argument( "--oww-melspectrogram-model", default=_OWW_DIR / "melspectrogram.tflite", @@ -75,16 +91,13 @@ async def main() -> None: default=_OWW_DIR / "embedding_model.tflite", help="Path to openWakeWord embedding model", ) - # parser.add_argument( "--wakeup-sound", default=str(_SOUNDS_DIR / "wake_word_triggered.flac") ) parser.add_argument( "--timer-finished-sound", default=str(_SOUNDS_DIR / "timer_finished.flac") ) - # parser.add_argument("--preferences-file", default=_REPO_DIR / "preferences.json") - # parser.add_argument( "--host", default="0.0.0.0", @@ -93,7 +106,6 @@ async def main() -> None: parser.add_argument( "--port", type=int, default=6053, help="Port for ESPHome server (default: 6053)" ) - # parser.add_argument( "--led-interface", choices=["spi", "gpio"], @@ -112,7 +124,6 @@ async def main() -> None: default=12, help="GPIO pin for LED data (for Grove)", ) - # parser.add_argument( "--debug", action="store_true", help="Print DEBUG messages to console" ) @@ -121,23 +132,16 @@ async def main() -> None: logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) _LOGGER.debug(args) - # Get the asyncio event loop loop = asyncio.get_running_loop() - - # Create the event bus event_bus = EventBus() - # Load available wake words wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir] available_wake_words: Dict[str, AvailableWakeWord] = {} - for wake_word_dir in wake_word_dirs: for model_config_path in wake_word_dir.glob("*.json"): model_id = model_config_path.stem if model_id == args.stop_model: - # Don't show stop model as an available wake word continue - with open(model_config_path, "r", encoding="utf-8") as model_config_file: model_config = json.load(model_config_file) model_type = model_config["type"] @@ -151,7 +155,6 @@ async def main() -> None: _LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys()))) - # Load preferences preferences_path = Path(args.preferences_file) if preferences_path.exists(): _LOGGER.debug("Loading preferences: %s", preferences_path) @@ -164,34 +167,26 @@ async def main() -> None: libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so" _LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path) - # Load wake/stop models wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {} if preferences.active_wake_words: - # Load preferred models for wake_word_id in preferences.active_wake_words: wake_word = available_wake_words.get(wake_word_id) if wake_word is None: _LOGGER.warning("Unrecognized wake word id: %s", wake_word_id) continue - _LOGGER.debug("Loading wake model: %s", wake_word_id) wake_models[wake_word_id] = wake_word.load(libtensorflowlite_c_path) - if not wake_models: - # Load default model wake_word_id = args.wake_model wake_word = available_wake_words[wake_word_id] - _LOGGER.debug("Loading wake model: %s", wake_word_id) wake_models[wake_word_id] = wake_word.load(libtensorflowlite_c_path) - # TODO: allow openWakeWord for "stop" stop_model: Optional[MicroWakeWord] = None for wake_word_dir in wake_word_dirs: stop_config_path = wake_word_dir / f"{args.stop_model}.json" if not stop_config_path.exists(): continue - _LOGGER.debug("Loading stop model: %s", stop_config_path) stop_model = MicroWakeWord.from_config( stop_config_path, libtensorflowlite_c_path @@ -200,7 +195,6 @@ async def main() -> None: assert stop_model is not None - # Create media players with saved volume music_player = MpvMediaPlayer( device=args.audio_output_device, initial_volume=preferences.volume_level ) @@ -230,13 +224,14 @@ async def main() -> None: refractory_seconds=args.refractory_seconds, ) - # Initialize the LED controller AFTER state is created led_controller = LedController( state, interface=args.led_interface, clock_pin=args.led_clock_pin, data_pin=args.led_data_pin, ) + # --- ADDED: Instantiate Mic Mute Handler --- + mic_mute_handler = MicMuteHandler(state) process_audio_thread = threading.Thread( target=process_audio, args=(state,), daemon=True @@ -250,7 +245,6 @@ async def main() -> None: lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port ) - # Auto discovery (zeroconf, mDNS) discovery = HomeAssistantZeroconf(port=args.port, name=args.name) await discovery.register_server() @@ -281,60 +275,55 @@ async def main() -> None: def process_audio(state: ServerState): """Process audio chunks from the microphone.""" - + # ... (variable initializations are the same) ... wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = [] micro_features: Optional[MicroWakeWordFeatures] = None micro_inputs: List[np.ndarray] = [] - oww_features: Optional[OpenWakeWordFeatures] = None oww_inputs: List[np.ndarray] = [] has_oww = False - last_active: Optional[float] = None try: while True: - audio_chunk = state.audio_queue.get() - if audio_chunk is None: - break - - if state.satellite is None: + # --- THIS IS THE CHANGE --- + # Check for mute status + if state.mic_muted: + time.sleep(0.1) continue + audio_chunk = state.audio_queue.get() + # ... (rest of the function is the same) ... + if audio_chunk is None: + break + if state.satellite is None: + continue if (not wake_words) or (state.wake_words_changed and state.wake_words): - # Update list of wake word models to process state.wake_words_changed = False wake_words = [ww for ww in state.wake_words.values() if ww.is_active] - has_oww = False for wake_word in wake_words: if isinstance(wake_word, OpenWakeWord): has_oww = True - if micro_features is None: micro_features = MicroWakeWordFeatures( libtensorflowlite_c_path=state.libtensorflowlite_c_path, ) - if has_oww and (oww_features is None): oww_features = OpenWakeWordFeatures( melspectrogram_model=state.oww_melspectrogram_path, embedding_model=state.oww_embedding_path, libtensorflowlite_c_path=state.libtensorflowlite_c_path, ) - try: state.satellite.handle_audio(audio_chunk) - assert micro_features is not None micro_inputs.clear() micro_inputs.extend(micro_features.process_streaming(audio_chunk)) - if has_oww: assert oww_features is not None oww_inputs.clear() oww_inputs.extend(oww_features.process_streaming(audio_chunk)) - for wake_word in wake_words: activated = False if isinstance(wake_word, MicroWakeWord): @@ -346,27 +335,21 @@ def process_audio(state: ServerState): for prob in wake_word.process_streaming(oww_input): if prob > 0.5: activated = True - if activated: - # Check refractory now = time.monotonic() if (last_active is None) or ( (now - last_active) > state.refractory_seconds ): state.satellite.wakeup(wake_word) last_active = now - - # Always process to keep state correct stopped = False for micro_input in micro_inputs: if state.stop_word.process_streaming(micro_input): stopped = True - if stopped and state.stop_word.is_active: state.satellite.stop() except Exception: _LOGGER.exception("Unexpected error handling audio") - except Exception: _LOGGER.exception("Unexpected error processing audio") From d90943fe8e2f989aa233b546e56102f76e19b8ab Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 11 Oct 2025 17:11:00 -0700 Subject: [PATCH 5/5] Add Microphone Mute Switch Entity Subscribes to the new mic_muted and mic_unmuted events. Provides visual feedback by setting the LEDs to a solid dim red when muted and calling the idle state handler (turning the LEDs off) when unmuted. --- linux_voice_assistant/led_controller.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/linux_voice_assistant/led_controller.py b/linux_voice_assistant/led_controller.py index 4ad9732..0916c40 100644 --- a/linux_voice_assistant/led_controller.py +++ b/linux_voice_assistant/led_controller.py @@ -15,6 +15,7 @@ _OFF = (0, 0, 0) _BLUE = (0, 0, 255) _YELLOW = (255, 255, 0) _GREEN = (0, 255, 0) +_DIM_RED = (50, 0, 0) # ADDED for Mute class LedController(EventHandler): @@ -33,7 +34,6 @@ class LedController(EventHandler): try: if interface == "gpio": _LOGGER.debug(f"Initializing LEDs on GPIO data={data_pin}, clock={clock_pin}") - # Dynamically get GPIO pin objects from the board library data_pin_obj = getattr(board, f"D{data_pin}") clock_pin_obj = getattr(board, f"D{clock_pin}") self.leds = adafruit_dotstar.DotStar( @@ -47,7 +47,6 @@ class LedController(EventHandler): self._is_ready = True _LOGGER.info(f"LED Controller initialized using {interface} interface.") - # Signal that the system is ready self.run_action("blink", _GREEN, 2) except Exception: @@ -79,12 +78,10 @@ class LedController(EventHandler): try: r, g, b = color while True: - # Fade in for i in range(0, 101, 5): brightness = i / 100.0 await self.color((int(r * brightness), int(g * brightness), int(b * brightness))) await asyncio.sleep(speed) - # Fade out for i in range(100, -1, -5): brightness = i / 100.0 await self.color((int(r * brightness), int(g * brightness), int(b * brightness))) @@ -92,7 +89,8 @@ class LedController(EventHandler): except asyncio.CancelledError: await self.color(_OFF) - # --- THIS IS THE NEWLY ADDED METHOD --- + # --- EVENT HANDLERS --- + @subscribe def ha_connected(self, data: dict): _LOGGER.debug("HA Connected, setting idle LED state.") @@ -111,7 +109,6 @@ class LedController(EventHandler): @subscribe def voice_vad_start(self, data: dict): _LOGGER.debug("LED Event: voice_vad_start (user speaking)") - # Do nothing, allow the blue pulse to continue pass @subscribe @@ -128,3 +125,15 @@ class LedController(EventHandler): def voice_run_end(self, data: dict): _LOGGER.debug("LED Event: voice_run_end") self.run_action("color", _OFF) + + # --- ADDED: Mute State Handlers --- + @subscribe + def mic_muted(self, data: dict): + _LOGGER.debug("LED Event: Mic muted") + self.run_action("color", _DIM_RED) + + @subscribe + def mic_unmuted(self, data: dict): + _LOGGER.debug("LED Event: Mic unmuted") + # Return to the normal idle state (off) + self.voice_run_end(data)