Merge pull request #25 from imonlinux/MQTT_Controls

## Summary: Feature - Add Full MQTT Control for LEDs and Mute
This pull request 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.

## File Changes
### pyproject.toml (Modified)
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.

### linux_voice_assistant/mqtt_controller.py (New File)
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/led_controller.py (Modified)
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_<state>_effect and set_<state>_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/__main__.py (Modified)
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/satellite.py (Modified)
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.

### models.py, entity.py, api_server.py (Modified)
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.
This commit is contained in:
imonlinux
2025-10-12 17:11:13 -07:00
committed by GitHub
14 changed files with 628 additions and 412 deletions
+18 -1
View File
@@ -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
+205
View File
@@ -159,6 +159,211 @@ systemctl --user status linux-voice-assistant --no-pager -l
```
</details>
<details>
<summary><strong>Optional (MQTT Controls)</strong></summary>
## 🔌 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/<device_id>_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/<device_id>_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/<device_id>_<state_name>_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/<device_id>_<state_name>_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 <IP_Adress> \
--mqtt-port 1883 \
--mqtt-username <user_name> \
--mqtt-password <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
```
</details>
<details>
<summary><strong>Optional (Grove Port LEDs)</strong></summary>
**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
```
</details>
## 6. Connect to Home Assistant
### If HA does not discover the new LVA:
+65 -6
View File
@@ -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,7 +175,6 @@ 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)
@@ -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,8 +251,22 @@ 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
+1 -20
View File
@@ -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]
+147 -82
View File
@@ -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"])
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)
config["state_name"] = state_name
self.state.event_bus.publish("publish_state_to_mqtt", config)
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): 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")
# --- 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 ha_connected(self, data: dict):
_LOGGER.debug("HA Connected, setting idle LED state.")
self.voice_run_end(data)
def set_idle_effect(self, data: dict): self._update_config("idle", data, True)
@subscribe
def set_idle_color(self, data: dict): self._update_config("idle", data, self.configs["idle"]["effect"] != "off")
@subscribe
def set_listening_effect(self, data: dict): self._update_config("listening", data, False)
@subscribe
def set_listening_color(self, data: dict): self._update_config("listening", data, False)
@subscribe
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 voice_wakeword(self, data: dict):
_LOGGER.debug("LED Event: voice_wakeword")
self.run_action("pulse", _BLUE)
@subscribe
def voice_stt_start(self, data: dict):
_LOGGER.debug("LED Event: voice_stt_start (listening)")
self.run_action("pulse", _BLUE)
@subscribe
def voice_vad_start(self, data: dict):
_LOGGER.debug("LED Event: voice_vad_start (user speaking)")
# Do nothing, allow the blue pulse to continue
pass
@subscribe
def voice_stt_end(self, data: dict):
_LOGGER.debug("LED Event: voice_stt_end (processing)")
self.run_action("pulse", _YELLOW)
@subscribe
def voice_tts_start(self, data: dict):
_LOGGER.debug("LED Event: voice_tts_start (assistant speaking)")
self.run_action("pulse", _GREEN)
@subscribe
def voice_run_end(self, data: dict):
_LOGGER.debug("LED Event: voice_run_end")
self.run_action("color", _OFF)
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)
-136
View File
@@ -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
@@ -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
+4 -2
View File
@@ -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."""
+161
View File
@@ -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)
+15 -31
View File
@@ -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,15 +88,22 @@ 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()
@@ -103,7 +111,10 @@ class VoiceSatelliteProtocol(APIServer):
self._tts_played = False
self.state.event_bus.publish("voice_run_end")
# TODO: handle error
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
self.state.event_bus.publish("voice_error", data)
self.state.event_bus.publish("voice_run_end")
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")
@@ -263,11 +251,9 @@ class VoiceSatelliteProtocol(APIServer):
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(
+2 -1
View File
@@ -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]
@@ -8,6 +8,10 @@ Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Voice Assistant' \
--mqtt-host <IP_Adress> \
--mqtt-port 1883 \
--mqtt-username <user_name> \
--mqtt-password <password> \
--wake-word-dir wakewords/openWakeWord \
--wake-word-dir wakewords \
--stop-model stop \
@@ -10,6 +10,10 @@ Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Voice Assistant' \
--mqtt-host <IP_Adress> \
--mqtt-port 1883 \
--mqtt-username <user_name> \
--mqtt-password <password> \
--wake-word-dir wakewords/openWakeWord \
--wake-word-dir wakewords \
--stop-model stop \
@@ -10,6 +10,10 @@ Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Voice Assistant' \
--mqtt-host <IP_Adress> \
--mqtt-port 1883 \
--mqtt-username <user_name> \
--mqtt-password <password> \
--wake-word-dir wakewords/openWakeWord \
--wake-word-dir wakewords \
--stop-model stop \