diff --git a/README.md b/README.md
index 39734f4..b63bf1c 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,23 @@ 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.
+
+ See [the tutorial](docs/linux-voice-assistant-2mic-install.md) to enable MQTT Controls.
+
## Installation
Install system dependencies (`apt-get`):
@@ -53,7 +70,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
diff --git a/docs/linux-voice-assistant-2mic-install.md b/docs/linux-voice-assistant-2mic-install.md
index 432db83..289555a 100644
--- a/docs/linux-voice-assistant-2mic-install.md
+++ b/docs/linux-voice-assistant-2mic-install.md
@@ -159,6 +159,211 @@ 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
+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:
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
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]
diff --git a/linux_voice_assistant/led_controller.py b/linux_voice_assistant/led_controller.py
index 01cd527..fd219bb 100644
--- a/linux_voice_assistant/led_controller.py
+++ b/linux_voice_assistant/led_controller.py
@@ -9,121 +9,186 @@ 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:
+ # Initialize hardware
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 = 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
+
+ new_brightness = data.get("brightness")
+ if new_brightness is not None:
+ new_brightness_float = new_brightness / 255.0
+ # --- 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
+
+ if not changed:
+ return
+
+ is_retained = data.get("retained", False)
+
+ if is_retained:
+ if state_name == "idle":
+ self._apply_state_effect("idle")
+ return
+
+ if apply:
+ self._apply_state_effect(state_name)
+ else:
+ 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)
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
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
diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py
index bbc9ced..7e832ef 100644
--- a/linux_voice_assistant/models.py
+++ b/linux_voice_assistant/models.py
@@ -60,10 +60,12 @@ 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
class ServerState:
+ # --- Fields WITHOUT default values ---
name: str
mac_address: str
audio_queue: "Queue[Optional[bytes]]"
@@ -80,15 +82,15 @@ class ServerState:
libtensorflowlite_c_path: Path
event_bus: EventBus
loop: asyncio.AbstractEventLoop
-
- # openWakeWord
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."""
diff --git a/linux_voice_assistant/mqtt_controller.py b/linux_voice_assistant/mqtt_controller.py
new file mode 100644
index 0000000..8b9ab2f
--- /dev/null
+++ b/linux_voice_assistant/mqtt_controller.py
@@ -0,0 +1,161 @@
+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}"
+ 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 ["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",
+ "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")
+ for entity_type in self.topics.values():
+ for topic_type, topic in entity_type.items():
+ if "command" in topic_type:
+ 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)
+
+ if msg.topic == self.topics["mute"]["command"]:
+ self.state.event_bus.publish("set_mic_mute", {"state": payload_str.upper() == "ON"})
+
+ # --- 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]
+ 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
+
+
+ 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_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)
+
+ 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)
+
+ for state_name in ["idle", "listening", "thinking", "responding", "error"]:
+ capital_name = state_name.title()
+
+ 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_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]
+
+ effect_name = data.get("effect", "off").replace("_", " ").title()
+ self._client.publish(state_topics["effect_state"], effect_name, retain=True)
+
+ 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)
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(
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]
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 \
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 \
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 \