From bad38a3ece151e5b38d0dfdf58f6f7948e208b31 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 12:54:12 -0700 Subject: [PATCH 01/22] Implement MQTT for Advanced LVA Controls Introduces the MqttController, a new class that manages all communication with the MQTT broker. On startup, it connects to the broker and publishes discovery configurations for all entities, automatically creating them in Home Assistant. Subscribes to command topics to receive instructions from the HA interface. Subscribes to state topics to read retained messages on startup, ensuring all user settings are persistent. Acts as a bridge, translating incoming MQTT messages into internal EventBus events for other components to act upon. --- linux_voice_assistant/mqtt_controller.py | 167 +++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 linux_voice_assistant/mqtt_controller.py diff --git a/linux_voice_assistant/mqtt_controller.py b/linux_voice_assistant/mqtt_controller.py new file mode 100644 index 0000000..388360d --- /dev/null +++ b/linux_voice_assistant/mqtt_controller.py @@ -0,0 +1,167 @@ +import json +import logging +from typing import TYPE_CHECKING, List + +import paho.mqtt.client as mqtt + +from .event_bus import EventHandler, subscribe + +if TYPE_CHECKING: + from .models import ServerState + +_LOGGER = logging.getLogger(__name__) + +class MqttController(EventHandler): + def __init__(self, state: "ServerState", host: str, port: int, username: str, password: str): + super().__init__(state) + self._host = host + self._port = port + self._username = username + self._password = password + + self._device_name = self.state.name + self._device_id = self.state.name.lower().replace(" ", "_") + self._topic_prefix = f"lva/{self._device_id}" + + # A list of all configurable states + self.CONFIGURABLE_STATES: List[str] = ["idle", "listening", "thinking", "responding", "error"] + + # Generate topics for all entities + self.topics = { + "mute": { + "command": f"{self._topic_prefix}/mute/set", + "state": f"{self._topic_prefix}/mute/state", + }, + "num_leds": { + "command": f"{self._topic_prefix}/num_leds/set", + "state": f"{self._topic_prefix}/num_leds/state", + } + } + for state_name in self.CONFIGURABLE_STATES: + self.topics[state_name] = { + "effect_command": f"{self._topic_prefix}/{state_name}_effect/set", + "effect_state": f"{self._topic_prefix}/{state_name}_effect/state", + "light_command": f"{self._topic_prefix}/{state_name}_light/set", + "light_state": f"{self._topic_prefix}/{state_name}_light/state", + } + + self._client = mqtt.Client() + self._client.on_connect = self._on_connect + self._client.on_message = self._on_message + + def start(self): + try: + if self._username: + self._client.username_pw_set(self._username, self._password) + + _LOGGER.debug("Connecting to MQTT broker at %s:%s", self._host, self._port) + self._client.connect(self._host, self._port, 60) + self._client.loop_start() + except Exception: + _LOGGER.exception("Failed to connect to MQTT broker") + + def stop(self): + self._client.publish(f"{self._topic_prefix}/availability", "offline", retain=True) + self._client.loop_stop() + self._client.disconnect() + _LOGGER.debug("Disconnected from MQTT broker") + + def _on_connect(self, client, userdata, flags, rc): + if rc == 0: + _LOGGER.info("Connected to MQTT broker") + # Subscribe to all command and state topics + for entity_type in self.topics.values(): + for topic_type, topic in entity_type.items(): + client.subscribe(topic) + self._publish_discovery_configs() + else: + _LOGGER.error("Failed to connect to MQTT, return code %d", rc) + + def _on_message(self, client, userdata, msg): + payload_str = msg.payload.decode() + _LOGGER.debug("Received MQTT message on topic %s", msg.topic) + + # Mute and Num LEDs + if msg.topic == self.topics["mute"]["command"]: + self.state.event_bus.publish("set_mic_mute", {"state": payload_str.upper() == "ON"}) + elif msg.topic == self.topics["num_leds"]["command"]: + self.state.event_bus.publish("set_num_leds", {"num_leds": int(payload_str)}) + + # State-based controls + for state_name in self.CONFIGURABLE_STATES: + state_topics = self.topics[state_name] + # Handle commands from HA + if msg.topic == state_topics["effect_command"]: + effect_id = payload_str.lower().replace(" ", "_") + self.state.event_bus.publish(f"set_{state_name}_effect", {"effect": effect_id}) + elif msg.topic == state_topics["light_command"]: + try: + data = json.loads(payload_str) + if data.get("state", "ON").upper() == "OFF": + self.state.event_bus.publish(f"set_{state_name}_effect", {"effect": "off"}) + else: + self.state.event_bus.publish(f"set_{state_name}_effect", {"effect": "solid"}) + self.state.event_bus.publish(f"set_{state_name}_color", data) + except json.JSONDecodeError: pass + + # Handle retained state messages on startup + elif msg.topic == state_topics["effect_state"]: + effect_id = payload_str.lower().replace(" ", "_") + self.state.event_bus.publish(f"set_{state_name}_effect", {"effect": effect_id, "retained": True}) + elif msg.topic == state_topics["light_state"]: + try: + data = json.loads(payload_str) + data["retained"] = True + self.state.event_bus.publish(f"set_{state_name}_color", data) + except json.JSONDecodeError: pass + + + def _publish_discovery_configs(self): + availability_topic = f"{self._topic_prefix}/availability" + device_info = { "identifiers": [self._device_id], "name": self._device_name, "manufacturer": "LVA Project" } + options = ["Off", "Solid", "Slow Pulse", "Medium Pulse", "Fast Pulse", "Slow Blink", "Medium Blink", "Fast Blink", "Spin"] + + # Mute Switch + mute_cfg = { "name": "Mute Microphone", "unique_id": f"{self._device_id}_mute", "command_topic": self.topics["mute"]["command"], "state_topic": self.topics["mute"]["state"], "availability_topic": availability_topic, "icon": "mdi:microphone-off", "device": device_info } + self._client.publish(f"homeassistant/switch/{self._device_id}_mute/config", json.dumps(mute_cfg), retain=True) + + # Number of LEDs + num_leds_cfg = { "name": "Number of LEDs", "unique_id": f"{self._device_id}_num_leds", "command_topic": self.topics["num_leds"]["command"], "state_topic": self.topics["num_leds"]["state"], "availability_topic": availability_topic, "min": 1, "max": 256, "step": 1, "icon": "mdi:counter", "device": device_info, "mode": "box", "entity_category": "config" } + self._client.publish(f"homeassistant/number/{self._device_id}_num_leds/config", json.dumps(num_leds_cfg), retain=True) + + # Loop through states to create select and light entities + for state_name in self.CONFIGURABLE_STATES: + capital_name = state_name.title() + + # Select Entity + select_cfg = { "name": f"{capital_name} Effect", "unique_id": f"{self._device_id}_{state_name}_effect", "command_topic": self.topics[state_name]["effect_command"], "state_topic": self.topics[state_name]["effect_state"], "availability_topic": availability_topic, "options": options, "icon": "mdi:palette-swatch-variant", "device": device_info, "entity_category": "config" } + self._client.publish(f"homeassistant/select/{self._device_id}_{state_name}_effect/config", json.dumps(select_cfg), retain=True) + + # Light Entity + light_cfg = { "name": f"{capital_name} Color", "unique_id": f"{self._device_id}_{state_name}_color", "schema": "json", "command_topic": self.topics[state_name]["light_command"], "state_topic": self.topics[state_name]["light_state"], "availability_topic": availability_topic, "brightness": True, "color_mode": True, "supported_color_modes": ["rgb"], "device": device_info, "entity_category": "config" } + self._client.publish(f"homeassistant/light/{self._device_id}_{state_name}_color/config", json.dumps(light_cfg), retain=True) + + _LOGGER.debug("Published all MQTT discovery configs") + self._client.publish(availability_topic, "online", retain=True) + self.publish_mute_state(self.state.mic_muted) + + def publish_mute_state(self, is_muted: bool): + self._client.publish(self.topics["mute"]["state"], "ON" if is_muted else "OFF", retain=True) + + @subscribe + def publish_state_to_mqtt(self, data: dict): + state_name = data.get("state_name") + if state_name in self.topics: + state_topics = self.topics[state_name] + + # Update Select State + effect_name = data.get("effect", "off").replace("_", " ").title() + self._client.publish(state_topics["effect_state"], effect_name, retain=True) + + # Update Light State + light_state = { + "state": "ON" if data.get("effect") != "off" else "OFF", + "brightness": int(data.get("brightness", 0.5) * 255), + "color": { "r": data.get("color")[0], "g": data.get("color")[1], "b": data.get("color")[2] } + } + self._client.publish(state_topics["light_state"], json.dumps(light_state), retain=True) From 824ac111ff30bc3b534f89cd96d53817159afb0e Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 12:55:33 -0700 Subject: [PATCH 02/22] Implement MQTT for Advanced LVA Controls Adds paho-mqtt as a core dependency to enable MQTT communication. Pins aioesphomeapi==41.13.0 to ensure stability with the existing codebase and avoid unresolved library issues on some platforms. --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ca54bd1..4a26c89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,12 +25,13 @@ classifiers = [ ] requires-python = ">=3.9.0" dependencies = [ - "aioesphomeapi==37.2.1", + "aioesphomeapi==41.13.0", "sounddevice<1", "numpy>=2,<3", "pymicro-features==1.0.0", "python-mpv>=1,<2", "zeroconf", + "paho-mqtt", ] [project.optional-dependencies] From 53b8fc4da9bdac3e71f6a1e0270e743cf8721881 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 12:56:26 -0700 Subject: [PATCH 03/22] Implement MQTT for Advanced LVA Controls Refactored for Full Configurability: The controller has been re-architected to remove all hard-coded effects and colors. Central Configuration: It now uses a central configs dictionary to store the effect, color, and brightness settings for each voice assistant state (idle, listening, etc.). Dynamic State Management: Subscribes to new set__effect and set__color events from the event bus to update its configuration in real-time. Applies Custom Effects: The main voice event handlers (voice_stt_start, voice_stt_end, etc.) have been simplified to look up the user's chosen configuration and apply it. New Animations: Implements a full suite of animations (Slow/Medium/Fast Pulse, Slow/Medium/Fast Blink, Spin) to be used by the effect selectors. --- linux_voice_assistant/led_controller.py | 215 +++++++++++++++--------- 1 file changed, 137 insertions(+), 78 deletions(-) diff --git a/linux_voice_assistant/led_controller.py b/linux_voice_assistant/led_controller.py index 01cd527..ccbcfe2 100644 --- a/linux_voice_assistant/led_controller.py +++ b/linux_voice_assistant/led_controller.py @@ -9,121 +9,180 @@ from .event_bus import EventHandler, subscribe _LOGGER = logging.getLogger(__name__) -# Constants for ReSpeaker 2-Mic Hat -NUM_LEDS = 3 +# Default Colors _OFF = (0, 0, 0) _BLUE = (0, 0, 255) _YELLOW = (255, 255, 0) _GREEN = (0, 255, 0) +_DIM_RED = (50, 0, 0) +_ORANGE = (255, 165, 0) +_PURPLE = (128, 0, 255) class LedController(EventHandler): - def __init__( - self, - state, - interface: str = "spi", - clock_pin: int = 13, - data_pin: int = 12, - ): + def __init__(self, state, interface: str = "spi", clock_pin: int = 13, data_pin: int = 12, num_leds: int = 3): super().__init__(state) self.loop = state.loop + self.num_leds = num_leds self.current_task = None self._is_ready = False + + # --- Central Configuration Dictionary --- + self.configs = { + "idle": {"effect": "off", "color": _PURPLE, "brightness": 0.5}, + "listening": {"effect": "medium_pulse", "color": _BLUE, "brightness": 0.5}, + "thinking": {"effect": "spin", "color": _YELLOW, "brightness": 0.8}, + "responding": {"effect": "medium_pulse", "color": _GREEN, "brightness": 0.5}, + "error": {"effect": "fast_blink", "color": _ORANGE, "brightness": 1.0}, + } 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( - clock_pin_obj, data_pin_obj, NUM_LEDS, brightness=0.5, auto_write=False - ) - else: # Default to SPI - _LOGGER.debug("Initializing LEDs on hardware SPI") - self.leds = adafruit_dotstar.DotStar( - board.SCLK, board.MOSI, NUM_LEDS, brightness=0.5, auto_write=False - ) + self.leds = adafruit_dotstar.DotStar(clock_pin_obj, data_pin_obj, self.num_leds, auto_write=False) + else: + self.leds = adafruit_dotstar.DotStar(board.SCLK, board.MOSI, self.num_leds, auto_write=False) 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) + self.leds.brightness = self.configs["idle"]["brightness"] + _LOGGER.info(f"LED Controller initialized for {self.num_leds} LEDs using {interface} interface.") + self.run_action("startup_sequence") except Exception: _LOGGER.exception("Failed to initialize LED controller. LEDs will be disabled.") def run_action(self, action_method_name: str, *args: Any) -> None: - if not self._is_ready: - return - if self.current_task and not self.current_task.done(): - self.current_task.cancel() + if not self._is_ready: return + if self.current_task and not self.current_task.done(): self.current_task.cancel() coro = getattr(self, action_method_name)(*args) self.current_task = asyncio.run_coroutine_threadsafe(coro, self.loop) - async def color(self, rgb: Tuple[int, int, int]): - if not self._is_ready: - return - self.leds.fill(rgb) - self.leds.show() + def _apply_state_effect(self, state_name: str): + config = self.configs.get(state_name, self.configs["idle"]) + _LOGGER.debug(f"Applying effect for state '{state_name}': {config['effect']}") + self.leds.brightness = config["brightness"] + self.run_action(config["effect"], config["color"]) + + config["state_name"] = state_name + self.state.event_bus.publish("publish_state_to_mqtt", config) - async def blink(self, color, count=3): - for _ in range(count): - await self.color(color) - await asyncio.sleep(0.2) - await self.color(_OFF) - await asyncio.sleep(0.2) - - async def pulse(self, color: Tuple[int, int, int], speed: float = 0.015): + # --- Animations --- + async def startup_sequence(self): await self.blink(_GREEN, 1) + async def off(self, color): self.leds.fill(_OFF); self.leds.show() + async def solid(self, color: Tuple[int, int, int]): self.leds.fill(color); self.leds.show() + async def blink(self, color, count=3): await self.medium_blink(color) + async def _base_pulse(self, color: Tuple[int, int, int], speed: float): 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))) - await asyncio.sleep(speed) - except asyncio.CancelledError: - await self.color(_OFF) + for i in range(0, 101, 10): + mod = i / 100.0; self.leds.fill((int(r*mod), int(g*mod), int(b*mod))); self.leds.show(); await asyncio.sleep(speed) + for i in range(100, -1, -10): + mod = i / 100.0; self.leds.fill((int(r*mod), int(g*mod), int(b*mod))); self.leds.show(); await asyncio.sleep(speed) + except asyncio.CancelledError: self.leds.fill(_OFF); self.leds.show() + async def slow_pulse(self, color): await self._base_pulse(color, 0.05) + async def medium_pulse(self, color): await self._base_pulse(color, 0.02) + async def fast_pulse(self, color): await self._base_pulse(color, 0.008) + async def _base_blink(self, color: Tuple[int, int, int], speed: float): + try: + while True: + self.leds.fill(color); self.leds.show(); await asyncio.sleep(speed) + self.leds.fill(_OFF); self.leds.show(); await asyncio.sleep(speed) + except asyncio.CancelledError: self.leds.fill(_OFF); self.leds.show() + async def slow_blink(self, color): await self._base_blink(color, 1.0) + async def medium_blink(self, color): await self._base_blink(color, 0.5) + async def fast_blink(self, color): await self._base_blink(color, 0.1) + async def spin(self, color: Tuple[int, int, int], speed: float = 0.1): + try: + i = 0 + while True: + self.leds.fill(_OFF); self.leds[i % self.num_leds] = color; self.leds.show(); i += 1; await asyncio.sleep(speed) + except asyncio.CancelledError: self.leds.fill(_OFF); self.leds.show() + # --- Voice Event Subscribers --- @subscribe - def ha_connected(self, data: dict): - _LOGGER.debug("HA Connected, setting idle LED state.") - self.voice_run_end(data) + def ha_connected(self, data: dict): self._apply_state_effect("idle") + @subscribe + def voice_wakeword(self, data: dict): self._apply_state_effect("listening") + @subscribe + def voice_stt_start(self, data: dict): self._apply_state_effect("listening") + @subscribe + def voice_vad_start(self, data: dict): pass + @subscribe + def voice_stt_end(self, data: dict): self._apply_state_effect("thinking") + @subscribe + def voice_tts_start(self, data: dict): self._apply_state_effect("responding") + @subscribe + def voice_error(self, data: dict): self._apply_state_effect("error") + @subscribe + def voice_run_end(self, data: dict): self._apply_state_effect("idle") + @subscribe + def mic_muted(self, data: dict): self.run_action("solid", _DIM_RED) + @subscribe + def mic_unmuted(self, data: dict): self._apply_state_effect("idle") - @subscribe - def voice_wakeword(self, data: dict): - _LOGGER.debug("LED Event: voice_wakeword") - self.run_action("pulse", _BLUE) + # --- MQTT Config Subscribers --- + def _update_config(self, state_name: str, data: dict, apply: bool): + config = self.configs[state_name] + changed = False + + new_effect = data.get("effect") + if new_effect is not None and config["effect"] != new_effect: + config["effect"] = new_effect + changed = True + + new_color = data.get("color") + if new_color is not None: + new_color_tuple = (new_color.get("r"), new_color.get("g"), new_color.get("b")) + if config["color"] != new_color_tuple: + config["color"] = new_color_tuple + changed = True + + new_brightness = data.get("brightness") + if new_brightness is not None: + new_brightness_float = new_brightness / 255.0 + if config["brightness"] != new_brightness_float: + config["brightness"] = new_brightness_float + changed = True + + if not changed: + return + + is_retained = data.get("retained", False) + if apply and not is_retained: + self._apply_state_effect(state_name) + elif is_retained: + if state_name == "idle": self._apply_state_effect("idle") + elif not apply: + # Config was updated for a non-active state, just publish it back + config["state_name"] = state_name + self.state.event_bus.publish("publish_state_to_mqtt", config) @subscribe - def voice_stt_start(self, data: dict): - _LOGGER.debug("LED Event: voice_stt_start (listening)") - self.run_action("pulse", _BLUE) - + def set_idle_effect(self, data: dict): self._update_config("idle", data, True) @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 - + def set_idle_color(self, data: dict): self._update_config("idle", data, self.configs["idle"]["effect"] != "off") @subscribe - def voice_stt_end(self, data: dict): - _LOGGER.debug("LED Event: voice_stt_end (processing)") - self.run_action("pulse", _YELLOW) - + def set_listening_effect(self, data: dict): self._update_config("listening", data, False) @subscribe - def voice_tts_start(self, data: dict): - _LOGGER.debug("LED Event: voice_tts_start (assistant speaking)") - self.run_action("pulse", _GREEN) - + def set_listening_color(self, data: dict): self._update_config("listening", data, False) @subscribe - def voice_run_end(self, data: dict): - _LOGGER.debug("LED Event: voice_run_end") - self.run_action("color", _OFF) + def set_thinking_effect(self, data: dict): self._update_config("thinking", data, False) + @subscribe + def set_thinking_color(self, data: dict): self._update_config("thinking", data, False) + @subscribe + def set_responding_effect(self, data: dict): self._update_config("responding", data, False) + @subscribe + def set_responding_color(self, data: dict): self._update_config("responding", data, False) + @subscribe + def set_error_effect(self, data: dict): self._update_config("error", data, False) + @subscribe + def set_error_color(self, data: dict): self._update_config("error", data, False) + + @subscribe + def set_num_leds(self, data: dict): + num_leds = data.get("num_leds") + if (num_leds is not None) and (self.num_leds != num_leds): + _LOGGER.info("Number of LEDs changed to %d. Please restart for change to take effect.", num_leds) From e0bef20398c538a006a1389bfb981711ff989e30 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 12:57:11 -0700 Subject: [PATCH 04/22] Implement MQTT for Advanced LVA Controls Adds command-line arguments (--mqtt-host, --mqtt-port, etc., and --num-leds) to configure the MQTT connection and initial LED setup. Instantiates and starts the MqttController on application startup if an MQTT host is provided. Includes the MicMuteHandler class, which listens for mute commands, updates the global mic_muted state, and clears the audio queue on unmute to prevent processing stale audio. Updates the process_audio loop to respect the mic_muted flag. --- linux_voice_assistant/__main__.py | 73 ++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index cd6692a..ab523be 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -12,8 +12,9 @@ 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 from .led_controller import LedController +from .mqtt_controller import MqttController from .microwakeword import MicroWakeWord, MicroWakeWordFeatures from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType from .mpv_player import MpvMediaPlayer @@ -34,6 +35,34 @@ if is_arm(): else: _LIB_DIR = _REPO_DIR / "lib" / "linux_amd64" +# ----------------------------------------------------------------------------- + +class MicMuteHandler(EventHandler): + """Event handler for mic mute switch.""" + def __init__(self, state, mqtt_controller: Optional[MqttController]): + super().__init__(state) + self.mqtt_controller = mqtt_controller + + @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 self.mqtt_controller: + self.mqtt_controller.publish_mute_state(is_muted) + + if is_muted: + self.state.event_bus.publish("mic_muted") + else: + _LOGGER.debug("Clearing stale audio queue...") + while not self.state.audio_queue.empty(): + try: + self.state.audio_queue.get_nowait() + except Queue.Empty: + break + self.state.event_bus.publish("mic_unmuted") # ----------------------------------------------------------------------------- @@ -107,6 +136,17 @@ async def main() -> None: default=12, help="GPIO pin for LED data (for Grove)", ) + # --- ADDED NUM_LEDS ARGUMENT --- + parser.add_argument( + "--num-leds", + type=int, + default=3, + help="Number of LEDs in the strip", + ) + parser.add_argument("--mqtt-host", help="MQTT broker host") + parser.add_argument("--mqtt-port", type=int, default=1883, help="MQTT broker port") + parser.add_argument("--mqtt-username", help="MQTT broker username") + parser.add_argument("--mqtt-password", help="MQTT broker password") parser.add_argument( "--debug", action="store_true", help="Print DEBUG messages to console" ) @@ -135,9 +175,8 @@ async def main() -> None: trained_languages=model_config.get("trained_languages", []), config_path=model_config_path, ) - _LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys()))) - + preferences_path = Path(args.preferences_file) if preferences_path.exists(): _LOGGER.debug("Loading preferences: %s", preferences_path) @@ -147,9 +186,12 @@ async def main() -> None: else: preferences = Preferences() + # Set number of LEDs from command line default, then override with saved preference + preferences.num_leds = getattr(preferences, 'num_leds', args.num_leds) + + libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so" _LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path) - wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {} if preferences.active_wake_words: for wake_word_id in preferences.active_wake_words: @@ -164,7 +206,6 @@ async def main() -> None: 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) - stop_model: Optional[MicroWakeWord] = None for wake_word_dir in wake_word_dirs: stop_config_path = wake_word_dir / f"{args.stop_model}.json" @@ -175,9 +216,7 @@ async def main() -> None: stop_config_path, libtensorflowlite_c_path ) break - assert stop_model is not None - music_player = MpvMediaPlayer( device=args.audio_output_device, initial_volume=preferences.volume_level ) @@ -212,7 +251,21 @@ async def main() -> None: interface=args.led_interface, clock_pin=args.led_clock_pin, data_pin=args.led_data_pin, + num_leds=state.preferences.num_leds, # Pass number of LEDs ) + + mqtt_controller: Optional[MqttController] = None + if args.mqtt_host: + mqtt_controller = MqttController( + state=state, + host=args.mqtt_host, + port=args.mqtt_port, + username=args.mqtt_username, + password=args.mqtt_password, + ) + mqtt_controller.start() + + mic_mute_handler = MicMuteHandler(state, mqtt_controller) process_audio_thread = threading.Thread( target=process_audio, args=(state,), daemon=True @@ -245,6 +298,8 @@ async def main() -> None: except KeyboardInterrupt: pass finally: + if mqtt_controller: + mqtt_controller.stop() state.audio_queue.put_nowait(None) process_audio_thread.join() @@ -266,6 +321,10 @@ def process_audio(state: ServerState): try: while True: + if state.mic_muted: + time.sleep(0.1) + continue + audio_chunk = state.audio_queue.get() if audio_chunk is None: break From 402a6d18552b8944b52611eb7059eacb41d2caa9 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 12:58:24 -0700 Subject: [PATCH 05/22] Implement MQTT for Advanced LVA Controls Adds a handler for the VOICE_ASSISTANT_ERROR event, publishing a voice_error event to the EventBus so the LedController can provide visual feedback. Fixes a race condition where the "Responding" LED effect was being cancelled prematurely, ensuring it now plays for the full duration of the TTS audio. --- linux_voice_assistant/satellite.py | 48 ++++++++++-------------------- 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py index 7046af7..d5bbf05 100644 --- a/linux_voice_assistant/satellite.py +++ b/linux_voice_assistant/satellite.py @@ -65,6 +65,7 @@ class VoiceSatelliteProtocol(APIServer): self._tts_played = False self._continue_conversation = False self._timer_finished = False + self._is_speaking = False # ADDED: Flag to track TTS state def handle_voice_event( self, event_type: VoiceAssistantEventType, data: Dict[str, str] @@ -87,23 +88,33 @@ 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": self._continue_conversation = True + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: + self.state.event_bus.publish("voice_tts_start", data) elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: self._tts_url = data.get("url") self.play_tts() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: + # --- THIS IS THE FIX --- + # Ignore this event if we are currently playing TTS audio. + # The real end event will be triggered by the _tts_finished callback. + if self._is_speaking: + return + self._is_streaming_audio = False if not self._tts_played: self._tts_finished() self._tts_played = False self.state.event_bus.publish("voice_run_end") + + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: + self.state.event_bus.publish("voice_error", data) + self.state.event_bus.publish("voice_run_end") - # TODO: handle error def handle_timer_event( self, @@ -120,26 +131,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 @@ -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,10 @@ 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._is_speaking = True # ADDED: Set flag before playing 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) @@ -280,24 +266,22 @@ class VoiceSatelliteProtocol(APIServer): self.state.music_player.unduck() def _tts_finished(self) -> None: + self._is_speaking = False # ADDED: Clear flag when done 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 a435cc97e719a438b4c0b1ad28631790c705584e Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 12:59:42 -0700 Subject: [PATCH 06/22] Implement MQTT for Advanced LVA Controls Includes various compatibility fixes, reversions, and clean-up from the previous development cycle to create a stable base for the MQTT implementation. This ensures the application starts reliably with the pinned aioesphomeapi version. --- linux_voice_assistant/models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py index bbc9ced..ddd0ab9 100644 --- a/linux_voice_assistant/models.py +++ b/linux_voice_assistant/models.py @@ -64,6 +64,7 @@ class Preferences: @dataclass class ServerState: + # --- Fields WITHOUT default values --- name: str mac_address: str audio_queue: "Queue[Optional[bytes]]" @@ -80,15 +81,16 @@ class ServerState: libtensorflowlite_c_path: Path event_bus: EventBus loop: asyncio.AbstractEventLoop - - # openWakeWord + # MOVED openWakeWord fields here oww_melspectrogram_path: Path oww_embedding_path: Path + # --- Fields WITH default values --- media_player_entity: "Optional[MediaPlayerEntity]" = None satellite: "Optional[VoiceSatelliteProtocol]" = None wake_words_changed: bool = False refractory_seconds: float = 2.0 + mic_muted: bool = False def save_preferences(self) -> None: """Save preferences as JSON.""" From 226dc7f11dd847b47d1da8fae06b68ef425abd70 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 13:00:18 -0700 Subject: [PATCH 07/22] Implement MQTT for Advanced LVA Controls Includes various compatibility fixes, reversions, and clean-up from the previous development cycle to create a stable base for the MQTT implementation. This ensures the application starts reliably with the pinned aioesphomeapi version. From 1eef4ba917a6f53dff772cb9b01695da5ae50808 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 13:00:52 -0700 Subject: [PATCH 08/22] Implement MQTT for Advanced LVA Controls Includes various compatibility fixes, reversions, and clean-up from the previous development cycle to create a stable base for the MQTT implementation. This ensures the application starts reliably with the pinned aioesphomeapi version. --- linux_voice_assistant/api_server.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/linux_voice_assistant/api_server.py b/linux_voice_assistant/api_server.py index 3d3f58e..37ee2b8 100644 --- a/linux_voice_assistant/api_server.py +++ b/linux_voice_assistant/api_server.py @@ -9,8 +9,6 @@ from typing import TYPE_CHECKING, List, Optional # pylint: disable=no-name-in-module from aioesphomeapi._frame_helper.packets import make_plain_text_packets from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] - ConnectRequest, - ConnectResponse, DisconnectRequest, DisconnectResponse, HelloRequest, @@ -57,9 +55,7 @@ class APIServer(asyncio.Protocol): ) return - if isinstance(msg_inst, ConnectRequest): - self.send_messages([ConnectResponse()]) - elif isinstance(msg_inst, DisconnectRequest): + if isinstance(msg_inst, DisconnectRequest): self.send_messages([DisconnectResponse()]) _LOGGER.debug("Disconnect requested") if self._transport: @@ -99,7 +95,6 @@ class APIServer(asyncio.Protocol): while self._buffer_len >= 3: self._pos = 0 - # Read preamble, which should always 0x00 if (preamble := self._read_varuint()) != 0x00: _LOGGER.error("Incorrect preamble: %s", preamble) return @@ -113,7 +108,6 @@ class APIServer(asyncio.Protocol): return if length == 0: - # Empty message (allowed) self._remove_from_buffer() self.process_packet(msg_type, b"") continue @@ -125,7 +119,6 @@ class APIServer(asyncio.Protocol): self.process_packet(msg_type, packet_data) def _read(self, length: int) -> bytes | None: - """Read exactly length bytes from the buffer or None if all the bytes are not yet available.""" new_pos = self._pos + length if self._buffer_len < new_pos: return None @@ -134,8 +127,6 @@ class APIServer(asyncio.Protocol): if TYPE_CHECKING: assert self._buffer is not None, "Buffer should be set" cstr = self._buffer - # Important: we must keep the bounds check (self._buffer_len < new_pos) - # above to verify we never try to read past the end of the buffer return cstr[original_pos:new_pos] def connection_lost(self, exc): @@ -143,7 +134,6 @@ class APIServer(asyncio.Protocol): self._writelines = None def _read_varuint(self) -> int: - """Read a varuint from the buffer or -1 if the buffer runs out of bytes.""" if not self._buffer: return -1 @@ -160,21 +150,12 @@ class APIServer(asyncio.Protocol): return -1 def _remove_from_buffer(self) -> None: - """Remove data from the buffer.""" end_of_frame_pos = self._pos self._buffer_len -= end_of_frame_pos if self._buffer_len == 0: - # This is the best case scenario, we can just set the buffer to None - # and don't have to copy the data. This is the most common case as well. self._buffer = None return if TYPE_CHECKING: assert self._buffer is not None, "Buffer should be set" - # This is the worst case scenario, we have to copy the data - # and can't just use the buffer directly. This should only happen - # when we read multiple frames at once because the event loop - # is blocked and we cannot pull the data out of the buffer fast enough. cstr = self._buffer - # Important: we must use the explicit length for the slice - # since Cython will stop at any '\0' character if we don't self._buffer = cstr[end_of_frame_pos : self._buffer_len + end_of_frame_pos] From 6958b09f4dcce7383beb482b4e0a0b724ee86aeb Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 13:11:52 -0700 Subject: [PATCH 09/22] Delete linux_voice_assistant/led_controller_hal.py Deprecated. All LED controls are configurable via MQTT. --- linux_voice_assistant/led_controller_hal.py | 136 -------------------- 1 file changed, 136 deletions(-) delete mode 100644 linux_voice_assistant/led_controller_hal.py diff --git a/linux_voice_assistant/led_controller_hal.py b/linux_voice_assistant/led_controller_hal.py deleted file mode 100644 index 52e96e0..0000000 --- a/linux_voice_assistant/led_controller_hal.py +++ /dev/null @@ -1,136 +0,0 @@ -import asyncio -import logging -from typing import Any, Tuple - -import board -import adafruit_dotstar - -from .event_bus import EventHandler, subscribe - -_LOGGER = logging.getLogger(__name__) - -# --- HAL-9000 Theme Constants --- -NUM_LEDS = 1 -_OFF = (0, 0, 0) -_RED = (255, 0, 0) -_GREEN = (0, 255, 0) # Kept for initial startup blink - - -class LedController(EventHandler): - def __init__( - self, - state, - interface: str = "spi", - clock_pin: int = 13, - data_pin: int = 12, - ): - super().__init__(state) - self.loop = state.loop - self.current_task = None - self._is_ready = False - - try: - if interface == "gpio": - _LOGGER.debug(f"Initializing LEDs on GPIO data={data_pin}, clock={clock_pin}") - data_pin_obj = getattr(board, f"D{data_pin}") - clock_pin_obj = getattr(board, f"D{clock_pin}") - self.leds = adafruit_dotstar.DotStar( - clock_pin_obj, data_pin_obj, NUM_LEDS, brightness=0.5, auto_write=False - ) - else: # Default to SPI - _LOGGER.debug("Initializing LEDs on hardware SPI") - self.leds = adafruit_dotstar.DotStar( - board.SCLK, board.MOSI, NUM_LEDS, brightness=0.5, auto_write=False - ) - - 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: - _LOGGER.exception("Failed to initialize LED controller. LEDs will be disabled.") - - def run_action(self, action_method_name: str, *args: Any) -> None: - if not self._is_ready: - return - if self.current_task and not self.current_task.done(): - self.current_task.cancel() - - coro = getattr(self, action_method_name)(*args) - self.current_task = asyncio.run_coroutine_threadsafe(coro, self.loop) - - async def color(self, rgb: Tuple[int, int, int]): - if not self._is_ready: - return - self.leds.fill(rgb) - self.leds.show() - - async def blink(self, color, count=3): - for _ in range(count): - await self.color(color) - await asyncio.sleep(0.2) - await self.color(_OFF) - await asyncio.sleep(0.2) - - async def pulse(self, color: Tuple[int, int, int], speed: float = 0.015): - try: - r, g, b = color - while True: - 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) - for i in range(100, -1, -5): - brightness = i / 100.0 - await self.color((int(r * brightness), int(g * brightness), int(b * brightness))) - await asyncio.sleep(speed) - except asyncio.CancelledError: - await self.color(_OFF) - - async def fast_pulse(self, color: Tuple[int, int, int], speed: float = 0.008): - """Faster version of the pulse animation.""" - try: - r, g, b = color - while True: - for i in range(0, 101, 10): - brightness = i / 100.0 - await self.color((int(r * brightness), int(g * brightness), int(b * brightness))) - await asyncio.sleep(speed) - for i in range(100, -1, -10): - brightness = i / 100.0 - await self.color((int(r * brightness), int(g * brightness), int(b * brightness))) - await asyncio.sleep(speed) - except asyncio.CancelledError: - await self.color(_OFF) - - - # --- EVENT HANDLERS FOR HAL-9000 --- - @subscribe - def ha_connected(self, data: dict): - _LOGGER.debug("HA Connected, setting idle LED state.") - self.voice_run_end(data) - - @subscribe - def voice_wakeword(self, data: dict): - self.run_action("pulse", _RED) - - @subscribe - def voice_stt_start(self, data: dict): - self.run_action("pulse", _RED) - - @subscribe - def voice_vad_start(self, data: dict): - pass # Continues pulsing red - - @subscribe - def voice_stt_end(self, data: dict): - self.run_action("fast_pulse", _RED) - - @subscribe - def voice_tts_start(self, data: dict): - self.run_action("pulse", _RED) - - @subscribe - def voice_run_end(self, data: dict): - self.run_action("color", _RED) # Idle state is solid red From 7d4edbf369c5e166d8a079cd9681e0587cdacc34 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 13:12:36 -0700 Subject: [PATCH 10/22] Delete linux_voice_assistant/led_controller_marvin.py Deprecated. All LED effects are configurable via MQTT controls. --- .../led_controller_marvin.py | 135 ------------------ 1 file changed, 135 deletions(-) delete mode 100644 linux_voice_assistant/led_controller_marvin.py diff --git a/linux_voice_assistant/led_controller_marvin.py b/linux_voice_assistant/led_controller_marvin.py deleted file mode 100644 index 78eac4a..0000000 --- a/linux_voice_assistant/led_controller_marvin.py +++ /dev/null @@ -1,135 +0,0 @@ -import asyncio -import logging -from typing import Any, Tuple - -import board -import adafruit_dotstar - -from .event_bus import EventHandler, subscribe - -_LOGGER = logging.getLogger(__name__) - -# --- Marvin Theme Constants --- -NUM_LEDS = 2 -_OFF = (0, 0, 0) -_GREEN = (0, 255, 0) - - -class LedController(EventHandler): - def __init__( - self, - state, - interface: str = "spi", - clock_pin: int = 13, - data_pin: int = 12, - ): - super().__init__(state) - self.loop = state.loop - self.current_task = None - self._is_ready = False - - try: - if interface == "gpio": - _LOGGER.debug(f"Initializing LEDs on GPIO data={data_pin}, clock={clock_pin}") - data_pin_obj = getattr(board, f"D{data_pin}") - clock_pin_obj = getattr(board, f"D{clock_pin}") - self.leds = adafruit_dotstar.DotStar( - clock_pin_obj, data_pin_obj, NUM_LEDS, brightness=0.5, auto_write=False - ) - else: # Default to SPI - _LOGGER.debug("Initializing LEDs on hardware SPI") - self.leds = adafruit_dotstar.DotStar( - board.SCLK, board.MOSI, NUM_LEDS, brightness=0.5, auto_write=False - ) - - 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: - _LOGGER.exception("Failed to initialize LED controller. LEDs will be disabled.") - - def run_action(self, action_method_name: str, *args: Any) -> None: - if not self._is_ready: - return - if self.current_task and not self.current_task.done(): - self.current_task.cancel() - - coro = getattr(self, action_method_name)(*args) - self.current_task = asyncio.run_coroutine_threadsafe(coro, self.loop) - - async def color(self, rgb: Tuple[int, int, int]): - if not self._is_ready: - return - self.leds.fill(rgb) - self.leds.show() - - async def blink(self, color, count=3): - for _ in range(count): - await self.color(color) - await asyncio.sleep(0.2) - await self.color(_OFF) - await asyncio.sleep(0.2) - - async def pulse(self, color: Tuple[int, int, int], speed: float = 0.015): - try: - r, g, b = color - while True: - 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) - for i in range(100, -1, -5): - brightness = i / 100.0 - await self.color((int(r * brightness), int(g * brightness), int(b * brightness))) - await asyncio.sleep(speed) - except asyncio.CancelledError: - await self.color(_OFF) - - async def fast_pulse(self, color: Tuple[int, int, int], speed: float = 0.008): - """Faster version of the pulse animation.""" - try: - r, g, b = color - while True: - for i in range(0, 101, 10): - brightness = i / 100.0 - await self.color((int(r * brightness), int(g * brightness), int(b * brightness))) - await asyncio.sleep(speed) - for i in range(100, -1, -10): - brightness = i / 100.0 - await self.color((int(r * brightness), int(g * brightness), int(b * brightness))) - await asyncio.sleep(speed) - except asyncio.CancelledError: - await self.color(_OFF) - - - # --- EVENT HANDLERS FOR MARVIN --- - @subscribe - def ha_connected(self, data: dict): - _LOGGER.debug("HA Connected, setting idle LED state.") - self.voice_run_end(data) - - @subscribe - def voice_wakeword(self, data: dict): - self.run_action("pulse", _GREEN) - - @subscribe - def voice_stt_start(self, data: dict): - self.run_action("pulse", _GREEN) - - @subscribe - def voice_vad_start(self, data: dict): - pass # Continues pulsing green - - @subscribe - def voice_stt_end(self, data: dict): - self.run_action("fast_pulse", _GREEN) - - @subscribe - def voice_tts_start(self, data: dict): - self.run_action("pulse", _GREEN) - - @subscribe - def voice_run_end(self, data: dict): - self.run_action("color", _GREEN) # Idle state is solid green From 7e4686b8f6268a063917ed8c88150f0786bf18dd Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 13:33:36 -0700 Subject: [PATCH 11/22] Update user-linux-voice-assistant.service Add config entries for MQTT --- service/user-linux-voice-assistant.service | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/service/user-linux-voice-assistant.service b/service/user-linux-voice-assistant.service index 9c356b2..c9669b1 100644 --- a/service/user-linux-voice-assistant.service +++ b/service/user-linux-voice-assistant.service @@ -8,6 +8,10 @@ Environment=PYTHONUNBUFFERED=1 ExecStart=/home/pi/linux-voice-assistant/script/run \ --name 'Linux Voice Assistant' \ + --mqtt-host \ + --mqtt-port 1883 \ + --mqtt-username \ + --mqtt-password \ --wake-word-dir wakewords/openWakeWord \ --wake-word-dir wakewords \ --stop-model stop \ From a26e91eb70e5c6ccf40bab414ba4d43f22ef174f Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 13:34:12 -0700 Subject: [PATCH 12/22] Update user-pa-linux-voice-assistant.service Add config entries for MQTT --- service/user-pa-linux-voice-assistant.service | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/service/user-pa-linux-voice-assistant.service b/service/user-pa-linux-voice-assistant.service index c8a9c17..1630ff4 100644 --- a/service/user-pa-linux-voice-assistant.service +++ b/service/user-pa-linux-voice-assistant.service @@ -10,6 +10,10 @@ Environment=PYTHONUNBUFFERED=1 ExecStart=/home/pi/linux-voice-assistant/script/run \ --name 'Linux Voice Assistant' \ + --mqtt-host \ + --mqtt-port 1883 \ + --mqtt-username \ + --mqtt-password \ --wake-word-dir wakewords/openWakeWord \ --wake-word-dir wakewords \ --stop-model stop \ From 58be335eb71249fbd2150291a41face06639aa4f Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 13:35:02 -0700 Subject: [PATCH 13/22] Update user-pw-linux-voice-assistant.service Add configs entries for MQTT --- service/user-pw-linux-voice-assistant.service | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/service/user-pw-linux-voice-assistant.service b/service/user-pw-linux-voice-assistant.service index 24a901d..a8586b5 100644 --- a/service/user-pw-linux-voice-assistant.service +++ b/service/user-pw-linux-voice-assistant.service @@ -10,6 +10,10 @@ Environment=PYTHONUNBUFFERED=1 ExecStart=/home/pi/linux-voice-assistant/script/run \ --name 'Linux Voice Assistant' \ + --mqtt-host \ + --mqtt-port 1883 \ + --mqtt-username \ + --mqtt-password \ --wake-word-dir wakewords/openWakeWord \ --wake-word-dir wakewords \ --stop-model stop \ From 939dd8963c946c29452e8d233db539abeefdba6a Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 13:52:22 -0700 Subject: [PATCH 14/22] Update README.md Updated for MQTT Implementation --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 39734f4..7f63f8f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,21 @@ See [the tutorial](docs/linux-voice-assistant-2mic-install.md) to build a satell - The volume control is now persistant between connections and reboots. The volume setting gets stored in prefernces.json and loaded when LVA starts. - ~~Microphone mute button entity added. Changes the LED event to dim red while muted.~~ (The mute switch does not survive a reboot or reconnect because unique_id not supported by aioesphomeapi version 41.13.0) +### Add Full MQTT Control for LEDs and Mute +- This branch introduces a comprehensive MQTT integration to bypass limitations in the pinned aioesphomeapi library and provide full remote control over the voice satellite's features and appearance. + +- It uses MQTT Discovery to automatically create and configure a device and its associated entities within Home Assistant. This allows for real-time control from the HA interface and enables powerful automations. + +#### Key Features: + +- A switch entity to mute and unmute the microphone. + +- A full suite of select and light entities to customize the effect, color, and brightness for each voice assistant state (Idle, Listening, Thinking, Responding, Error). + +- A number entity to configure the number of LEDs in the strip, allowing for use with custom hardware. + +- All settings are persistent, retained by the MQTT broker and re-applied whenever the application restarts. + ## Installation Install system dependencies (`apt-get`): From 37898c8241341dc682e8ceffa36ff182f3ee1ca6 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 14:04:37 -0700 Subject: [PATCH 15/22] Updated for MQTT --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7f63f8f..c9e044c 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ See `--help` for more options. * Implement echo-cancellation filter in PipeWire. (Improves wake word detection when audio is being played) * ~~Merge jianyu-li's PR from source project to add mute switch function in this branch~~ -* Implement MQTT entities to support advanced controls of the LVA. +* ~~Implement MQTT entities to support advanced controls of the LVA.~~ * ~~Configure LVA to advertise on Zeroconf/mDNS via Avahi for HA to auto detect (in progress)~~ (Not needed as Release v1.0.0 implemented in code) * Implement a single LVA systemd unit file that can be addapted using profiles and drop-ins (in progress) * Implement OWW model validation checks and error handling so that a bad model doesn't crash OWW From 26cdd5159b70d1668fae231fb8e14cebe578896e Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 14:11:31 -0700 Subject: [PATCH 16/22] Updated for MQTT Enable Configurations --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index c9e044c..1391892 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,23 @@ See [the tutorial](docs/linux-voice-assistant-2mic-install.md) to build a satell - All settings are persistent, retained by the MQTT broker and re-applied whenever the application restarts. + Enable MQTT by including the following run configuration entries: + + ```bash + script/run \ + --name 'Linux Voice Assistant' \ + --mqtt-host \ + --mqtt-port 1883 \ + --mqtt-username \ + --mqtt-password \ + --wake-word-dir wakewords/openWakeWord \ + --wake-word-dir wakewords \ + --stop-model stop \ + --audio-input-device seeed-2mic-voicecard \ + --audio-output-device alsa/plughw:CARD=seeed2micvoicec \ + --debug + ``` + ## Installation Install system dependencies (`apt-get`): From 798c371943a8581952b5db3a426893061d4f6b5a Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 16:14:21 -0700 Subject: [PATCH 17/22] Bug Identified With GPIO LEDs (Grove) --- linux_voice_assistant/led_controller.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/linux_voice_assistant/led_controller.py b/linux_voice_assistant/led_controller.py index ccbcfe2..fd219bb 100644 --- a/linux_voice_assistant/led_controller.py +++ b/linux_voice_assistant/led_controller.py @@ -37,6 +37,7 @@ class LedController(EventHandler): } try: + # Initialize hardware if interface == "gpio": data_pin_obj = getattr(board, f"D{data_pin}") clock_pin_obj = getattr(board, f"D{clock_pin}") @@ -133,9 +134,9 @@ class LedController(EventHandler): config["effect"] = new_effect changed = True - new_color = data.get("color") - if new_color is not None: - new_color_tuple = (new_color.get("r"), new_color.get("g"), new_color.get("b")) + new_color_data = data.get("color") + if new_color_data is not None: + new_color_tuple = (new_color_data.get("r"), new_color_data.get("g"), new_color_data.get("b")) if config["color"] != new_color_tuple: config["color"] = new_color_tuple changed = True @@ -143,7 +144,9 @@ class LedController(EventHandler): new_brightness = data.get("brightness") if new_brightness is not None: new_brightness_float = new_brightness / 255.0 - if config["brightness"] != new_brightness_float: + # --- THIS IS THE FIX --- + # Compare floats with a tolerance, not for exact equality + if abs(config["brightness"] - new_brightness_float) > 0.001: config["brightness"] = new_brightness_float changed = True @@ -151,12 +154,15 @@ class LedController(EventHandler): return is_retained = data.get("retained", False) - if apply and not is_retained: + + if is_retained: + if state_name == "idle": + self._apply_state_effect("idle") + return + + if apply: self._apply_state_effect(state_name) - elif is_retained: - if state_name == "idle": self._apply_state_effect("idle") - elif not apply: - # Config was updated for a non-active state, just publish it back + else: config["state_name"] = state_name self.state.event_bus.publish("publish_state_to_mqtt", config) From dbc371ce8f6df701a74c5d4e04348b884e72d2dc Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 16:14:52 -0700 Subject: [PATCH 18/22] Bug Identified With GPIO LEDs (Grove) --- linux_voice_assistant/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py index ddd0ab9..7e832ef 100644 --- a/linux_voice_assistant/models.py +++ b/linux_voice_assistant/models.py @@ -60,6 +60,7 @@ class AvailableWakeWord: class Preferences: active_wake_words: List[str] = field(default_factory=list) volume_level: float = 1.0 + num_leds: int = 3 # THIS LINE WAS MISSING @dataclass @@ -81,7 +82,6 @@ class ServerState: libtensorflowlite_c_path: Path event_bus: EventBus loop: asyncio.AbstractEventLoop - # MOVED openWakeWord fields here oww_melspectrogram_path: Path oww_embedding_path: Path From 6077a31baabf9f4dc51e9808a9494272c9a996e1 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 16:15:22 -0700 Subject: [PATCH 19/22] Bug Identified With GPIO LEDs (Grove) --- linux_voice_assistant/mqtt_controller.py | 60 +++++++++++------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/linux_voice_assistant/mqtt_controller.py b/linux_voice_assistant/mqtt_controller.py index 388360d..8b9ab2f 100644 --- a/linux_voice_assistant/mqtt_controller.py +++ b/linux_voice_assistant/mqtt_controller.py @@ -21,12 +21,8 @@ class MqttController(EventHandler): self._device_name = self.state.name self._device_id = self.state.name.lower().replace(" ", "_") + self._topic_prefix = f"lva/{self._device_id}" - - # A list of all configurable states - self.CONFIGURABLE_STATES: List[str] = ["idle", "listening", "thinking", "responding", "error"] - - # Generate topics for all entities self.topics = { "mute": { "command": f"{self._topic_prefix}/mute/set", @@ -37,7 +33,7 @@ class MqttController(EventHandler): "state": f"{self._topic_prefix}/num_leds/state", } } - for state_name in self.CONFIGURABLE_STATES: + for state_name in ["idle", "listening", "thinking", "responding", "error"]: self.topics[state_name] = { "effect_command": f"{self._topic_prefix}/{state_name}_effect/set", "effect_state": f"{self._topic_prefix}/{state_name}_effect/state", @@ -69,10 +65,10 @@ class MqttController(EventHandler): def _on_connect(self, client, userdata, flags, rc): if rc == 0: _LOGGER.info("Connected to MQTT broker") - # Subscribe to all command and state topics for entity_type in self.topics.values(): for topic_type, topic in entity_type.items(): - client.subscribe(topic) + if "command" in topic_type: + client.subscribe(topic) self._publish_discovery_configs() else: _LOGGER.error("Failed to connect to MQTT, return code %d", rc) @@ -81,16 +77,27 @@ class MqttController(EventHandler): payload_str = msg.payload.decode() _LOGGER.debug("Received MQTT message on topic %s", msg.topic) - # Mute and Num LEDs if msg.topic == self.topics["mute"]["command"]: self.state.event_bus.publish("set_mic_mute", {"state": payload_str.upper() == "ON"}) - elif msg.topic == self.topics["num_leds"]["command"]: - self.state.event_bus.publish("set_num_leds", {"num_leds": int(payload_str)}) - # State-based controls - for state_name in self.CONFIGURABLE_STATES: + # --- THIS IS THE FIX --- + # The logic to save the preference was missing. It is now re-added. + elif msg.topic == self.topics["num_leds"]["command"]: + try: + num_leds = int(payload_str) + # First, publish the internal event so the LedController can log the message + self.state.event_bus.publish("set_num_leds", {"num_leds": num_leds}) + # Then, save the setting to the preferences file + if self.state.preferences.num_leds != num_leds: + self.state.preferences.num_leds = num_leds + self.state.save_preferences() + # Finally, publish the state back to MQTT to confirm the change in HA + self.publish_num_leds_state(num_leds) + except ValueError: + _LOGGER.warning("Received invalid value for num_leds: %s", payload_str) + + for state_name in ["idle", "listening", "thinking", "responding", "error"]: state_topics = self.topics[state_name] - # Handle commands from HA if msg.topic == state_topics["effect_command"]: effect_id = payload_str.lower().replace(" ", "_") self.state.event_bus.publish(f"set_{state_name}_effect", {"effect": effect_id}) @@ -103,17 +110,6 @@ class MqttController(EventHandler): self.state.event_bus.publish(f"set_{state_name}_effect", {"effect": "solid"}) self.state.event_bus.publish(f"set_{state_name}_color", data) except json.JSONDecodeError: pass - - # Handle retained state messages on startup - elif msg.topic == state_topics["effect_state"]: - effect_id = payload_str.lower().replace(" ", "_") - self.state.event_bus.publish(f"set_{state_name}_effect", {"effect": effect_id, "retained": True}) - elif msg.topic == state_topics["light_state"]: - try: - data = json.loads(payload_str) - data["retained"] = True - self.state.event_bus.publish(f"set_{state_name}_color", data) - except json.JSONDecodeError: pass def _publish_discovery_configs(self): @@ -121,44 +117,42 @@ class MqttController(EventHandler): device_info = { "identifiers": [self._device_id], "name": self._device_name, "manufacturer": "LVA Project" } options = ["Off", "Solid", "Slow Pulse", "Medium Pulse", "Fast Pulse", "Slow Blink", "Medium Blink", "Fast Blink", "Spin"] - # Mute Switch mute_cfg = { "name": "Mute Microphone", "unique_id": f"{self._device_id}_mute", "command_topic": self.topics["mute"]["command"], "state_topic": self.topics["mute"]["state"], "availability_topic": availability_topic, "icon": "mdi:microphone-off", "device": device_info } self._client.publish(f"homeassistant/switch/{self._device_id}_mute/config", json.dumps(mute_cfg), retain=True) - # Number of LEDs num_leds_cfg = { "name": "Number of LEDs", "unique_id": f"{self._device_id}_num_leds", "command_topic": self.topics["num_leds"]["command"], "state_topic": self.topics["num_leds"]["state"], "availability_topic": availability_topic, "min": 1, "max": 256, "step": 1, "icon": "mdi:counter", "device": device_info, "mode": "box", "entity_category": "config" } self._client.publish(f"homeassistant/number/{self._device_id}_num_leds/config", json.dumps(num_leds_cfg), retain=True) - # Loop through states to create select and light entities - for state_name in self.CONFIGURABLE_STATES: + for state_name in ["idle", "listening", "thinking", "responding", "error"]: capital_name = state_name.title() - # Select Entity select_cfg = { "name": f"{capital_name} Effect", "unique_id": f"{self._device_id}_{state_name}_effect", "command_topic": self.topics[state_name]["effect_command"], "state_topic": self.topics[state_name]["effect_state"], "availability_topic": availability_topic, "options": options, "icon": "mdi:palette-swatch-variant", "device": device_info, "entity_category": "config" } self._client.publish(f"homeassistant/select/{self._device_id}_{state_name}_effect/config", json.dumps(select_cfg), retain=True) - # Light Entity light_cfg = { "name": f"{capital_name} Color", "unique_id": f"{self._device_id}_{state_name}_color", "schema": "json", "command_topic": self.topics[state_name]["light_command"], "state_topic": self.topics[state_name]["light_state"], "availability_topic": availability_topic, "brightness": True, "color_mode": True, "supported_color_modes": ["rgb"], "device": device_info, "entity_category": "config" } self._client.publish(f"homeassistant/light/{self._device_id}_{state_name}_color/config", json.dumps(light_cfg), retain=True) _LOGGER.debug("Published all MQTT discovery configs") self._client.publish(availability_topic, "online", retain=True) + self.publish_mute_state(self.state.mic_muted) + self.publish_num_leds_state(self.state.preferences.num_leds) def publish_mute_state(self, is_muted: bool): self._client.publish(self.topics["mute"]["state"], "ON" if is_muted else "OFF", retain=True) + def publish_num_leds_state(self, num_leds: int): + self._client.publish(self.topics["num_leds"]["state"], str(num_leds), retain=True) + @subscribe def publish_state_to_mqtt(self, data: dict): state_name = data.get("state_name") if state_name in self.topics: state_topics = self.topics[state_name] - # Update Select State effect_name = data.get("effect", "off").replace("_", " ").title() self._client.publish(state_topics["effect_state"], effect_name, retain=True) - # Update Light State light_state = { "state": "ON" if data.get("effect") != "off" else "OFF", "brightness": int(data.get("brightness", 0.5) * 255), From f707f3232a2ca989032108e740ee0a8921af543e Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 16:33:07 -0700 Subject: [PATCH 20/22] Updated with Instructions for MQTT and Grove LEDs --- docs/linux-voice-assistant-2mic-install.md | 128 +++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/docs/linux-voice-assistant-2mic-install.md b/docs/linux-voice-assistant-2mic-install.md index 432db83..bcd897c 100644 --- a/docs/linux-voice-assistant-2mic-install.md +++ b/docs/linux-voice-assistant-2mic-install.md @@ -159,6 +159,134 @@ systemctl --user status linux-voice-assistant --no-pager -l ``` +
+Optional (MQTT Controls) + +**Edit LVA user-mode service:** + +```bash +systemctl --user edit --force --full linux-voice-assistant +``` + +**Add MQTT configuration entries to LVA user-mode service:** + +```bash + --mqtt-host \ + --mqtt-port 1883 \ + --mqtt-username \ + --mqtt-password \ +``` + +**Example (Alsa LVA User Mode Service File)** +***Note: Change the MQTT values to match your system!*** + +```bash +[Unit] +Description=Linux Voice Assistant + +[Service] +Type=simple +WorkingDirectory=/home/pi/linux-voice-assistant +Environment=PYTHONUNBUFFERED=1 + +ExecStart=/home/pi/linux-voice-assistant/script/run \ + --name 'Linux Voice Assistant' \ + --mqtt-host 192.168.1.2 \ + --mqtt-port 1883 \ + --mqtt-username mqtt \ + --mqtt-password Ch@ngeMe_1st \ + --wake-word-dir wakewords/openWakeWord \ + --wake-word-dir wakewords \ + --stop-model stop \ + --audio-input-device seeed-2mic-voicecard \ + --audio-output-device alsa/plughw:CARD=seeed2micvoicec \ + --debug + +Restart=always +RestartSec=2 + +[Install] +WantedBy=default.target +``` + + +**Enable & start:** +```bash +systemctl --user daemon-reload +systemctl --user restart linux-voice-assistant.service +``` + +**Verify:** +```bash +systemctl --user status linux-voice-assistant --no-pager -l +``` +
+ +
+Optional (Grove Port LEDs) + +**Edit LVA user-mode service:** + +```bash +systemctl --user edit --force --full linux-voice-assistant +``` + +**Add MQTT configuration entries to LVA user-mode service:** + +```bash + --led-interface gpio \ + --led-data-pin 12 \ + --led-clock-pin 13 \ +``` + +**Example (Alsa LVA User Mode Service File with MQTT Enabled)** +***Note: Change the GPIO values to match your system!*** + +```bash +[Unit] +Description=Linux Voice Assistant + +[Service] +Type=simple +WorkingDirectory=/home/pi/linux-voice-assistant +Environment=PYTHONUNBUFFERED=1 + +ExecStart=/home/pi/linux-voice-assistant/script/run \ + --name 'Linux Voice Assistant' \ + --mqtt-host 192.168.1.2 \ + --mqtt-port 1883 \ + --mqtt-username mqtt \ + --mqtt-password Ch@ngeMe_1st \ + --led-interface gpio \ + --led-data-pin 12 \ + --led-clock-pin 13 \ + --wake-word-dir wakewords/openWakeWord \ + --wake-word-dir wakewords \ + --stop-model stop \ + --audio-input-device seeed-2mic-voicecard \ + --audio-output-device alsa/plughw:CARD=seeed2micvoicec \ + --debug + +Restart=always +RestartSec=2 + +[Install] +WantedBy=default.target +``` + + +**Enable & start:** +```bash +systemctl --user daemon-reload +systemctl --user restart linux-voice-assistant.service +``` + +**Verify:** +```bash +systemctl --user status linux-voice-assistant --no-pager -l +``` +
+ ## 6. Connect to Home Assistant ### If HA does not discover the new LVA: From 5c14c6c782cb1c80bcc8e94f888e81a8b240476a Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 17:06:44 -0700 Subject: [PATCH 21/22] Improved the MQTT Controls Information --- docs/linux-voice-assistant-2mic-install.md | 77 ++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/docs/linux-voice-assistant-2mic-install.md b/docs/linux-voice-assistant-2mic-install.md index bcd897c..289555a 100644 --- a/docs/linux-voice-assistant-2mic-install.md +++ b/docs/linux-voice-assistant-2mic-install.md @@ -162,6 +162,83 @@ systemctl --user status linux-voice-assistant --no-pager -l
Optional (MQTT Controls) + +## 🔌 MQTT Controls Overview + +The Linux Voice Assistant (LVA) creates several MQTT entities under the Home Assistant MQTT Discovery protocol. Each entity is prefixed by your LVA's unique device ID defined with the --name field in the LVA User-Mode Serviced Unit (e.g., `linux_voice_assistant`). + +--- + +### 🎤 Microphone Mute (`switch` entity) + +Controls the microphone mute state. + +* **MQTT Discovery Topic:** `homeassistant/switch/_mute/config` +* **Name:** `[LVA Name] Mute Microphone` +* **Icon:** `mdi:microphone-off` +* **Functionality:** Toggles the microphone mute. When muted, the LEDs will display a dim red `solid` effect. + +--- + +## 💡 LED Control for Linux Voice Assistant +These are MQTT entities created by the Linux Voice Assistant for controlling its integrated LEDs (e.g., DotStar, NeoPixel). These entities integrate seamlessly with Home Assistant via MQTT Discovery, allowing you to manage LED effects, colors, brightness, and the number of connected LEDs directly from your Home Assistant interface. + +--- + +### 🔢 Number of LEDs (`number` entity) + +This entity allows you to specify the physical number of addressable LEDs connected to your device. + + +* **MQTT Discovery Topic:** `homeassistant/number/_num_leds/config` +* **Name:** `[LVA Name] Number of LEDs` +* **Functionality:** Sets the total number of LEDs. +* **Important:** This setting requires a **restart of the LVA service file** to take effect, as the LED hardware driver needs to be re-initialized. The value is persisted in `preferences.json`. + +--- + +### ✨ State-Based LED Controls (`select` and `light` entities) + +For each distinct LVA state (Idle, Listening, Thinking, Responding, Error), a `select` entity for choosing an effect and a `light` entity for controlling color and brightness are created. + +* **States:** + * `idle` (e.g., `[LVA Name] Idle Effect`, `[LVA Name] Idle Color`) + * `listening` (e.g., `[LVA Name] Listening Effect`, `[LVA Name] Listening Color`) + * `thinking` (e.g., `[LVA Name] Thinking Effect`, `[LVA Name] Thinking Color`) + * `responding` (e.g., `[LVA Name] Responding Effect`, `[LVA Name] Responding Color`) + * `error` (e.g., `[LVA Name] Error Effect`, `[LVA Name] Error Color`) + +#### `select` Entities (Effect Selector) + +* **MQTT Discovery Topic:** `homeassistant/select/__effect/config` +* **Name:** `[LVA Name] [State Name] Effect` +* **Icon:** `mdi:palette-swatch-variant` +* **Functionality:** Allows selection of an animation/effect for the specific LVA state. + +#### `light` Entities (Color & Brightness Control) + +* **MQTT Discovery Topic:** `homeassistant/light/__color/config` +* **Name:** `[LVA Name] [State Name] Color` +* **Functionality:** Controls the color and brightness for the specific LVA state when the selected effect uses color. Supports RGB color mode and brightness. + +--- + +## 🎨 Available LED Effects + +The following effects can be selected via the `[State Name] Effect` (select) entities: + +| Effect Name | Description | +| :--- | :--- | +| **Off** | All LEDs are turned off. | +| **Solid** | All LEDs display a single, constant color. | +| **Slow Pulse** | LEDs slowly fade in and out. | +| **Medium Pulse** | LEDs fade in and out at a moderate speed. | +| **Fast Pulse** | LEDs rapidly fade in and out. | +| **Slow Blink** | LEDs turn on and off slowly. | +| **Medium Blink** | LEDs turn on and off at a moderate speed. | +| **Fast Blink** | LEDs rapidly turn on and off. | +| **Spin** | A single LED "spins" around the strip. | + **Edit LVA user-mode service:** ```bash From 8b8de94c7672a32227a9c64ec3516a052073bbc5 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 12 Oct 2025 17:08:41 -0700 Subject: [PATCH 22/22] Cleaned up MQTT Controls Info --- README.md | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/README.md b/README.md index 1391892..b63bf1c 100644 --- a/README.md +++ b/README.md @@ -31,22 +31,7 @@ See [the tutorial](docs/linux-voice-assistant-2mic-install.md) to build a satell - All settings are persistent, retained by the MQTT broker and re-applied whenever the application restarts. - Enable MQTT by including the following run configuration entries: - - ```bash - script/run \ - --name 'Linux Voice Assistant' \ - --mqtt-host \ - --mqtt-port 1883 \ - --mqtt-username \ - --mqtt-password \ - --wake-word-dir wakewords/openWakeWord \ - --wake-word-dir wakewords \ - --stop-model stop \ - --audio-input-device seeed-2mic-voicecard \ - --audio-output-device alsa/plughw:CARD=seeed2micvoicec \ - --debug - ``` + See [the tutorial](docs/linux-voice-assistant-2mic-install.md) to enable MQTT Controls. ## Installation