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. - 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) - ~~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 ## Installation
Install system dependencies (`apt-get`): 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) * 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~~ * ~~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) * ~~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 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 * 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>
<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 ## 6. Connect to Home Assistant
### If HA does not discover the new LVA: ### 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 numpy as np
import sounddevice as sd import sounddevice as sd
from .event_bus import EventBus from .event_bus import EventBus, EventHandler, subscribe
from .led_controller import LedController from .led_controller import LedController
from .mqtt_controller import MqttController
from .microwakeword import MicroWakeWord, MicroWakeWordFeatures from .microwakeword import MicroWakeWord, MicroWakeWordFeatures
from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
from .mpv_player import MpvMediaPlayer from .mpv_player import MpvMediaPlayer
@@ -34,6 +35,34 @@ if is_arm():
else: else:
_LIB_DIR = _REPO_DIR / "lib" / "linux_amd64" _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, default=12,
help="GPIO pin for LED data (for Grove)", 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( parser.add_argument(
"--debug", action="store_true", help="Print DEBUG messages to console" "--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", []), trained_languages=model_config.get("trained_languages", []),
config_path=model_config_path, config_path=model_config_path,
) )
_LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys()))) _LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
preferences_path = Path(args.preferences_file) preferences_path = Path(args.preferences_file)
@@ -147,9 +186,12 @@ async def main() -> None:
else: else:
preferences = Preferences() 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" libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
_LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path) _LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path)
wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {} wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
if preferences.active_wake_words: if preferences.active_wake_words:
for wake_word_id in 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] wake_word = available_wake_words[wake_word_id]
_LOGGER.debug("Loading wake model: %s", wake_word_id) _LOGGER.debug("Loading wake model: %s", wake_word_id)
wake_models[wake_word_id] = wake_word.load(libtensorflowlite_c_path) wake_models[wake_word_id] = wake_word.load(libtensorflowlite_c_path)
stop_model: Optional[MicroWakeWord] = None stop_model: Optional[MicroWakeWord] = None
for wake_word_dir in wake_word_dirs: for wake_word_dir in wake_word_dirs:
stop_config_path = wake_word_dir / f"{args.stop_model}.json" 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 stop_config_path, libtensorflowlite_c_path
) )
break break
assert stop_model is not None assert stop_model is not None
music_player = MpvMediaPlayer( music_player = MpvMediaPlayer(
device=args.audio_output_device, initial_volume=preferences.volume_level device=args.audio_output_device, initial_volume=preferences.volume_level
) )
@@ -212,8 +251,22 @@ async def main() -> None:
interface=args.led_interface, interface=args.led_interface,
clock_pin=args.led_clock_pin, clock_pin=args.led_clock_pin,
data_pin=args.led_data_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( process_audio_thread = threading.Thread(
target=process_audio, args=(state,), daemon=True target=process_audio, args=(state,), daemon=True
) )
@@ -245,6 +298,8 @@ async def main() -> None:
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally:
if mqtt_controller:
mqtt_controller.stop()
state.audio_queue.put_nowait(None) state.audio_queue.put_nowait(None)
process_audio_thread.join() process_audio_thread.join()
@@ -266,6 +321,10 @@ def process_audio(state: ServerState):
try: try:
while True: while True:
if state.mic_muted:
time.sleep(0.1)
continue
audio_chunk = state.audio_queue.get() audio_chunk = state.audio_queue.get()
if audio_chunk is None: if audio_chunk is None:
break break
+1 -20
View File
@@ -9,8 +9,6 @@ from typing import TYPE_CHECKING, List, Optional
# pylint: disable=no-name-in-module # pylint: disable=no-name-in-module
from aioesphomeapi._frame_helper.packets import make_plain_text_packets from aioesphomeapi._frame_helper.packets import make_plain_text_packets
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
ConnectRequest,
ConnectResponse,
DisconnectRequest, DisconnectRequest,
DisconnectResponse, DisconnectResponse,
HelloRequest, HelloRequest,
@@ -57,9 +55,7 @@ class APIServer(asyncio.Protocol):
) )
return return
if isinstance(msg_inst, ConnectRequest): if isinstance(msg_inst, DisconnectRequest):
self.send_messages([ConnectResponse()])
elif isinstance(msg_inst, DisconnectRequest):
self.send_messages([DisconnectResponse()]) self.send_messages([DisconnectResponse()])
_LOGGER.debug("Disconnect requested") _LOGGER.debug("Disconnect requested")
if self._transport: if self._transport:
@@ -99,7 +95,6 @@ class APIServer(asyncio.Protocol):
while self._buffer_len >= 3: while self._buffer_len >= 3:
self._pos = 0 self._pos = 0
# Read preamble, which should always 0x00
if (preamble := self._read_varuint()) != 0x00: if (preamble := self._read_varuint()) != 0x00:
_LOGGER.error("Incorrect preamble: %s", preamble) _LOGGER.error("Incorrect preamble: %s", preamble)
return return
@@ -113,7 +108,6 @@ class APIServer(asyncio.Protocol):
return return
if length == 0: if length == 0:
# Empty message (allowed)
self._remove_from_buffer() self._remove_from_buffer()
self.process_packet(msg_type, b"") self.process_packet(msg_type, b"")
continue continue
@@ -125,7 +119,6 @@ class APIServer(asyncio.Protocol):
self.process_packet(msg_type, packet_data) self.process_packet(msg_type, packet_data)
def _read(self, length: int) -> bytes | None: 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 new_pos = self._pos + length
if self._buffer_len < new_pos: if self._buffer_len < new_pos:
return None return None
@@ -134,8 +127,6 @@ class APIServer(asyncio.Protocol):
if TYPE_CHECKING: if TYPE_CHECKING:
assert self._buffer is not None, "Buffer should be set" assert self._buffer is not None, "Buffer should be set"
cstr = self._buffer 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] return cstr[original_pos:new_pos]
def connection_lost(self, exc): def connection_lost(self, exc):
@@ -143,7 +134,6 @@ class APIServer(asyncio.Protocol):
self._writelines = None self._writelines = None
def _read_varuint(self) -> int: def _read_varuint(self) -> int:
"""Read a varuint from the buffer or -1 if the buffer runs out of bytes."""
if not self._buffer: if not self._buffer:
return -1 return -1
@@ -160,21 +150,12 @@ class APIServer(asyncio.Protocol):
return -1 return -1
def _remove_from_buffer(self) -> None: def _remove_from_buffer(self) -> None:
"""Remove data from the buffer."""
end_of_frame_pos = self._pos end_of_frame_pos = self._pos
self._buffer_len -= end_of_frame_pos self._buffer_len -= end_of_frame_pos
if self._buffer_len == 0: 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 self._buffer = None
return return
if TYPE_CHECKING: if TYPE_CHECKING:
assert self._buffer is not None, "Buffer should be set" 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 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] 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__) _LOGGER = logging.getLogger(__name__)
# Constants for ReSpeaker 2-Mic Hat # Default Colors
NUM_LEDS = 3
_OFF = (0, 0, 0) _OFF = (0, 0, 0)
_BLUE = (0, 0, 255) _BLUE = (0, 0, 255)
_YELLOW = (255, 255, 0) _YELLOW = (255, 255, 0)
_GREEN = (0, 255, 0) _GREEN = (0, 255, 0)
_DIM_RED = (50, 0, 0)
_ORANGE = (255, 165, 0)
_PURPLE = (128, 0, 255)
class LedController(EventHandler): class LedController(EventHandler):
def __init__( def __init__(self, state, interface: str = "spi", clock_pin: int = 13, data_pin: int = 12, num_leds: int = 3):
self,
state,
interface: str = "spi",
clock_pin: int = 13,
data_pin: int = 12,
):
super().__init__(state) super().__init__(state)
self.loop = state.loop self.loop = state.loop
self.num_leds = num_leds
self.current_task = None self.current_task = None
self._is_ready = False 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: try:
# Initialize hardware
if interface == "gpio": 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}") data_pin_obj = getattr(board, f"D{data_pin}")
clock_pin_obj = getattr(board, f"D{clock_pin}") clock_pin_obj = getattr(board, f"D{clock_pin}")
self.leds = adafruit_dotstar.DotStar( self.leds = adafruit_dotstar.DotStar(clock_pin_obj, data_pin_obj, self.num_leds, auto_write=False)
clock_pin_obj, data_pin_obj, NUM_LEDS, brightness=0.5, auto_write=False else:
) self.leds = adafruit_dotstar.DotStar(board.SCLK, board.MOSI, self.num_leds, 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 self._is_ready = True
_LOGGER.info(f"LED Controller initialized using {interface} interface.") self.leds.brightness = self.configs["idle"]["brightness"]
# Signal that the system is ready _LOGGER.info(f"LED Controller initialized for {self.num_leds} LEDs using {interface} interface.")
self.run_action("blink", _GREEN, 2) self.run_action("startup_sequence")
except Exception: except Exception:
_LOGGER.exception("Failed to initialize LED controller. LEDs will be disabled.") _LOGGER.exception("Failed to initialize LED controller. LEDs will be disabled.")
def run_action(self, action_method_name: str, *args: Any) -> None: def run_action(self, action_method_name: str, *args: Any) -> None:
if not self._is_ready: if not self._is_ready: return
return if self.current_task and not self.current_task.done(): self.current_task.cancel()
if self.current_task and not self.current_task.done():
self.current_task.cancel()
coro = getattr(self, action_method_name)(*args) coro = getattr(self, action_method_name)(*args)
self.current_task = asyncio.run_coroutine_threadsafe(coro, self.loop) self.current_task = asyncio.run_coroutine_threadsafe(coro, self.loop)
async def color(self, rgb: Tuple[int, int, int]): def _apply_state_effect(self, state_name: str):
if not self._is_ready: config = self.configs.get(state_name, self.configs["idle"])
return _LOGGER.debug(f"Applying effect for state '{state_name}': {config['effect']}")
self.leds.fill(rgb) self.leds.brightness = config["brightness"]
self.leds.show() self.run_action(config["effect"], config["color"])
async def blink(self, color, count=3): config["state_name"] = state_name
for _ in range(count): self.state.event_bus.publish("publish_state_to_mqtt", config)
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: try:
r, g, b = color r, g, b = color
while True: while True:
# Fade in for i in range(0, 101, 10):
for i in range(0, 101, 5): mod = i / 100.0; self.leds.fill((int(r*mod), int(g*mod), int(b*mod))); self.leds.show(); await asyncio.sleep(speed)
brightness = i / 100.0 for i in range(100, -1, -10):
await self.color((int(r * brightness), int(g * brightness), int(b * brightness))) mod = i / 100.0; self.leds.fill((int(r*mod), int(g*mod), int(b*mod))); self.leds.show(); await asyncio.sleep(speed)
await asyncio.sleep(speed) except asyncio.CancelledError: self.leds.fill(_OFF); self.leds.show()
# Fade out async def slow_pulse(self, color): await self._base_pulse(color, 0.05)
for i in range(100, -1, -5): async def medium_pulse(self, color): await self._base_pulse(color, 0.02)
brightness = i / 100.0 async def fast_pulse(self, color): await self._base_pulse(color, 0.008)
await self.color((int(r * brightness), int(g * brightness), int(b * brightness))) async def _base_blink(self, color: Tuple[int, int, int], speed: float):
await asyncio.sleep(speed) try:
except asyncio.CancelledError: while True:
await self.color(_OFF) 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 @subscribe
def ha_connected(self, data: dict): def set_idle_effect(self, data: dict): self._update_config("idle", data, True)
_LOGGER.debug("HA Connected, setting idle LED state.") @subscribe
self.voice_run_end(data) 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 @subscribe
def voice_wakeword(self, data: dict): def set_num_leds(self, data: dict):
_LOGGER.debug("LED Event: voice_wakeword") num_leds = data.get("num_leds")
self.run_action("pulse", _BLUE) 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)
@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)
-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: class Preferences:
active_wake_words: List[str] = field(default_factory=list) active_wake_words: List[str] = field(default_factory=list)
volume_level: float = 1.0 volume_level: float = 1.0
num_leds: int = 3 # THIS LINE WAS MISSING
@dataclass @dataclass
class ServerState: class ServerState:
# --- Fields WITHOUT default values ---
name: str name: str
mac_address: str mac_address: str
audio_queue: "Queue[Optional[bytes]]" audio_queue: "Queue[Optional[bytes]]"
@@ -80,15 +82,15 @@ class ServerState:
libtensorflowlite_c_path: Path libtensorflowlite_c_path: Path
event_bus: EventBus event_bus: EventBus
loop: asyncio.AbstractEventLoop loop: asyncio.AbstractEventLoop
# openWakeWord
oww_melspectrogram_path: Path oww_melspectrogram_path: Path
oww_embedding_path: Path oww_embedding_path: Path
# --- Fields WITH default values ---
media_player_entity: "Optional[MediaPlayerEntity]" = None media_player_entity: "Optional[MediaPlayerEntity]" = None
satellite: "Optional[VoiceSatelliteProtocol]" = None satellite: "Optional[VoiceSatelliteProtocol]" = None
wake_words_changed: bool = False wake_words_changed: bool = False
refractory_seconds: float = 2.0 refractory_seconds: float = 2.0
mic_muted: bool = False
def save_preferences(self) -> None: def save_preferences(self) -> None:
"""Save preferences as JSON.""" """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._tts_played = False
self._continue_conversation = False self._continue_conversation = False
self._timer_finished = False self._timer_finished = False
self._is_speaking = False # ADDED: Flag to track TTS state
def handle_voice_event( def handle_voice_event(
self, event_type: VoiceAssistantEventType, data: Dict[str, str] self, event_type: VoiceAssistantEventType, data: Dict[str, str]
@@ -87,15 +88,22 @@ class VoiceSatelliteProtocol(APIServer):
self._is_streaming_audio = False self._is_streaming_audio = False
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS:
if data.get("tts_start_streaming") == "1": if data.get("tts_start_streaming") == "1":
# Start streaming early
self.play_tts() self.play_tts()
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
if data.get("continue_conversation") == "1": if data.get("continue_conversation") == "1":
self._continue_conversation = True 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: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
self._tts_url = data.get("url") self._tts_url = data.get("url")
self.play_tts() self.play_tts()
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: 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 self._is_streaming_audio = False
if not self._tts_played: if not self._tts_played:
self._tts_finished() self._tts_finished()
@@ -103,7 +111,10 @@ class VoiceSatelliteProtocol(APIServer):
self._tts_played = False self._tts_played = False
self.state.event_bus.publish("voice_run_end") 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( def handle_timer_event(
self, self,
@@ -120,26 +131,19 @@ class VoiceSatelliteProtocol(APIServer):
def handle_message(self, msg: message.Message) -> Iterable[message.Message]: def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
if isinstance(msg, VoiceAssistantEventResponse): if isinstance(msg, VoiceAssistantEventResponse):
# Pipeline event
data: Dict[str, str] = {} data: Dict[str, str] = {}
for arg in msg.data: for arg in msg.data:
data[arg.name] = arg.value data[arg.name] = arg.value
self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data) self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data)
elif isinstance(msg, VoiceAssistantAnnounceRequest): elif isinstance(msg, VoiceAssistantAnnounceRequest):
_LOGGER.debug("Announcing: %s", msg.text) _LOGGER.debug("Announcing: %s", msg.text)
assert self.state.media_player_entity is not None assert self.state.media_player_entity is not None
urls = [] urls = []
if msg.preannounce_media_id: if msg.preannounce_media_id:
urls.append(msg.preannounce_media_id) urls.append(msg.preannounce_media_id)
urls.append(msg.media_id) urls.append(msg.media_id)
self.state.stop_word.is_active = True self.state.stop_word.is_active = True
self._continue_conversation = msg.start_conversation self._continue_conversation = msg.start_conversation
self.duck() self.duck()
yield from self.state.media_player_entity.play( yield from self.state.media_player_entity.play(
urls, announcement=True, done_callback=self._tts_finished urls, announcement=True, done_callback=self._tts_finished
@@ -188,37 +192,26 @@ class VoiceSatelliteProtocol(APIServer):
max_active_wake_words=2, max_active_wake_words=2,
) )
_LOGGER.info("Connected to Home Assistant") _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") self.state.event_bus.publish("ha_connected")
elif isinstance(msg, VoiceAssistantSetConfiguration): elif isinstance(msg, VoiceAssistantSetConfiguration):
# Change active wake words
active_wake_words: Set[str] = set() active_wake_words: Set[str] = set()
for wake_word_id in msg.active_wake_words: for wake_word_id in msg.active_wake_words:
if wake_word_id in self.state.wake_words: if wake_word_id in self.state.wake_words:
# Already active
active_wake_words.add(wake_word_id) active_wake_words.add(wake_word_id)
continue continue
model_info = self.state.available_wake_words.get(wake_word_id) model_info = self.state.available_wake_words.get(wake_word_id)
if not model_info: if not model_info:
continue continue
_LOGGER.debug("Loading wake word: %s", model_info.config_path) _LOGGER.debug("Loading wake word: %s", model_info.config_path)
self.state.wake_words[wake_word_id] = model_info.load( self.state.wake_words[wake_word_id] = model_info.load(
self.state.libtensorflowlite_c_path self.state.libtensorflowlite_c_path
) )
_LOGGER.info("Wake word set: %s", wake_word_id) _LOGGER.info("Wake word set: %s", wake_word_id)
active_wake_words.add(wake_word_id) active_wake_words.add(wake_word_id)
break break
for wake_word in self.state.wake_words.values(): for wake_word in self.state.wake_words.values():
wake_word.is_active = wake_word.id in active_wake_words wake_word.is_active = wake_word.id in active_wake_words
_LOGGER.debug("Active wake words: %s", active_wake_words) _LOGGER.debug("Active wake words: %s", active_wake_words)
self.state.preferences.active_wake_words = list(active_wake_words) self.state.preferences.active_wake_words = list(active_wake_words)
self.state.save_preferences() self.state.save_preferences()
self.state.wake_words_changed = True self.state.wake_words_changed = True
@@ -226,19 +219,15 @@ class VoiceSatelliteProtocol(APIServer):
def handle_audio(self, audio_chunk: bytes) -> None: def handle_audio(self, audio_chunk: bytes) -> None:
if not self._is_streaming_audio: if not self._is_streaming_audio:
return return
self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None: def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
self.state.event_bus.publish("voice_wakeword") self.state.event_bus.publish("voice_wakeword")
if self._timer_finished: if self._timer_finished:
# Stop timer instead
self._timer_finished = False self._timer_finished = False
self.state.tts_player.stop() self.state.tts_player.stop()
_LOGGER.debug("Stopping timer finished sound") _LOGGER.debug("Stopping timer finished sound")
return return
wake_word_phrase = wake_word.wake_word wake_word_phrase = wake_word.wake_word
_LOGGER.debug("Detected wake word: %s", wake_word_phrase) _LOGGER.debug("Detected wake word: %s", wake_word_phrase)
self.send_messages( self.send_messages(
@@ -251,7 +240,6 @@ class VoiceSatelliteProtocol(APIServer):
def stop(self) -> None: def stop(self) -> None:
self.state.stop_word.is_active = False self.state.stop_word.is_active = False
self.state.tts_player.stop() self.state.tts_player.stop()
if self._timer_finished: if self._timer_finished:
self._timer_finished = False self._timer_finished = False
_LOGGER.debug("Stopping timer finished sound") _LOGGER.debug("Stopping timer finished sound")
@@ -263,11 +251,9 @@ class VoiceSatelliteProtocol(APIServer):
if (not self._tts_url) or self._tts_played: if (not self._tts_url) or self._tts_played:
return 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 self._tts_played = True
_LOGGER.debug("Playing TTS response: %s", self._tts_url) _LOGGER.debug("Playing TTS response: %s", self._tts_url)
self.state.stop_word.is_active = True self.state.stop_word.is_active = True
self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished) 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() self.state.music_player.unduck()
def _tts_finished(self) -> None: def _tts_finished(self) -> None:
self._is_speaking = False # ADDED: Clear flag when done
self.state.event_bus.publish("voice_run_end") self.state.event_bus.publish("voice_run_end")
self.state.stop_word.is_active = False self.state.stop_word.is_active = False
self.send_messages([VoiceAssistantAnnounceFinished()]) self.send_messages([VoiceAssistantAnnounceFinished()])
if self._continue_conversation: if self._continue_conversation:
self.send_messages([VoiceAssistantRequest(start=True)]) self.send_messages([VoiceAssistantRequest(start=True)])
self._is_streaming_audio = True self._is_streaming_audio = True
_LOGGER.debug("Continuing conversation") _LOGGER.debug("Continuing conversation")
else: else:
self.unduck() self.unduck()
_LOGGER.debug("TTS response finished") _LOGGER.debug("TTS response finished")
def _play_timer_finished(self) -> None: def _play_timer_finished(self) -> None:
if not self._timer_finished: if not self._timer_finished:
self.unduck() self.unduck()
return return
self.state.tts_player.play( self.state.tts_player.play(
self.state.timer_finished_sound, self.state.timer_finished_sound,
done_callback=lambda: call_all( done_callback=lambda: call_all(
+2 -1
View File
@@ -25,12 +25,13 @@ classifiers = [
] ]
requires-python = ">=3.9.0" requires-python = ">=3.9.0"
dependencies = [ dependencies = [
"aioesphomeapi==37.2.1", "aioesphomeapi==41.13.0",
"sounddevice<1", "sounddevice<1",
"numpy>=2,<3", "numpy>=2,<3",
"pymicro-features==1.0.0", "pymicro-features==1.0.0",
"python-mpv>=1,<2", "python-mpv>=1,<2",
"zeroconf", "zeroconf",
"paho-mqtt",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -8,6 +8,10 @@ Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \ ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Voice Assistant' \ --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/openWakeWord \
--wake-word-dir wakewords \ --wake-word-dir wakewords \
--stop-model stop \ --stop-model stop \
@@ -10,6 +10,10 @@ Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \ ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Voice Assistant' \ --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/openWakeWord \
--wake-word-dir wakewords \ --wake-word-dir wakewords \
--stop-model stop \ --stop-model stop \
@@ -10,6 +10,10 @@ Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \ ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Voice Assistant' \ --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/openWakeWord \
--wake-word-dir wakewords \ --wake-word-dir wakewords \
--stop-model stop \ --stop-model stop \