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:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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 \
|
||||||
|
|||||||
Reference in New Issue
Block a user