Merge pull request #44 from imonlinux/gpio-button-suport
Summary Add support for the ReSpeaker 2-Mic HAT momentary button as a first-class control surface for the Linux Voice Assistant. The button now behaves like: Short press If TTS or music is playing → stop playback (equivalent to the Stop wake word) Otherwise → start a new conversation (equivalent to a wake word trigger) Long press Toggle microphone mute (wired through the existing set_mic_mute event, so MQTT state and LEDs stay in sync) The implementation uses a polling-based GPIO loop (RPi.GPIO) instead of kernel edge-detection to avoid “Failed to add edge detection” issues on some HAT/overlay setups. Button behavior is fully configurable via config.json.
This commit is contained in:
@@ -16,6 +16,7 @@ Want to run the satellite on a Linux desktop using a simple tray client? See [th
|
|||||||
- This fork is from https://github.com/OHF-Voice/linux-voice-assistant Release v1.0.0 which introduces the ability to use both MicroWakeWord and OpenWakeWord detections models.
|
- This fork is from https://github.com/OHF-Voice/linux-voice-assistant Release v1.0.0 which introduces the ability to use both MicroWakeWord and OpenWakeWord detections models.
|
||||||
- Refactor: Sync with Upstream architectural changes, includes the 7 commits since the release of v1.0.0.
|
- Refactor: Sync with Upstream architectural changes, includes the 7 commits since the release of v1.0.0.
|
||||||
- **Core Refactor (Nov 2025):** Major architectural cleanup moving audio logic to a dedicated engine.
|
- **Core Refactor (Nov 2025):** Major architectural cleanup moving audio logic to a dedicated engine.
|
||||||
|
- **Added ReSpeaker 2-Mic Button Support:** The GPIO button (17) can be enabled via the config.json file. See [the tutorial](docs/linux-voice-assistant-2mic-install.md).
|
||||||
- **Changed Alarm to include a Duration Setting:** Added a MQTT control to make the alarm duration adjustable.
|
- **Changed Alarm to include a Duration Setting:** Added a MQTT control to make the alarm duration adjustable.
|
||||||
- **Performance Optimization:** Optimized audio threading for lower CPU usage when muted and improved responsiveness (reduced latency) when unmuted.
|
- **Performance Optimization:** Optimized audio threading for lower CPU usage when muted and improved responsiveness (reduced latency) when unmuted.
|
||||||
- **Non-Blocking Operations:** Wake word model downloads are now handled in background threads, preventing the device (LEDs/MQTT) from freezing during configuration updates.
|
- **Non-Blocking Operations:** Wake word model downloads are now handled in background threads, preventing the device (LEDs/MQTT) from freezing during configuration updates.
|
||||||
|
|||||||
@@ -498,6 +498,82 @@ systemctl --user status linux-voice-assistant --no-pager -l
|
|||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Optional (GPIO Button)</strong></summary>
|
||||||
|
|
||||||
|
Add support for the ReSpeaker 2-Mic HAT momentary button as a first-class control surface for the Linux Voice Assistant. The button now behaves like:
|
||||||
|
|
||||||
|
***Short press***
|
||||||
|
|
||||||
|
If TTS or music is playing → stop playback (equivalent to the Stop wake word)
|
||||||
|
|
||||||
|
Otherwise → start a new conversation (equivalent to a wake word trigger)
|
||||||
|
|
||||||
|
***Long press***
|
||||||
|
|
||||||
|
Toggle microphone mute (wired through the existing set_mic_mute event, so MQTT state and LEDs stay in sync)
|
||||||
|
|
||||||
|
The implementation uses a polling-based GPIO loop (RPi.GPIO) instead of kernel edge-detection to avoid “Failed to add edge detection” issues on some HAT/overlay setups. Button behavior is fully configurable via config.json.
|
||||||
|
|
||||||
|
**Requires a compatible GPIO board** This has been tested on the ReSpeaker 2-Mic Pi Hat:
|
||||||
|
|
||||||
|
**Edit the LVA config.json file:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano ~/linux-voice-assistant/linux_voice_assistant/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enable the GPIO button support:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"button": {
|
||||||
|
"enabled": true,
|
||||||
|
"pin": 17,
|
||||||
|
"long_press_seconds": 1.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (LVA config.json file with MQTT, Grove Port, and GPIO Button enabled** ***Note: The GPIO button can be changed from the default (17) on the 2-Mic hat.***
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "Linux Voice Assistant"
|
||||||
|
},
|
||||||
|
"mqtt": {
|
||||||
|
"host": "192.168.1.2",
|
||||||
|
"port": 1883,
|
||||||
|
"username": "mqtt_username",
|
||||||
|
"password": "mqtt_password"
|
||||||
|
},
|
||||||
|
"led": {
|
||||||
|
"led_type": "dotstar",
|
||||||
|
"interface": "gpio",
|
||||||
|
"clock_pin": 13,
|
||||||
|
"data_pin": 12,
|
||||||
|
"num_leds": 10
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"enabled": true,
|
||||||
|
"pin": 17,
|
||||||
|
"long_press_seconds": 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enable & start:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ git clone https://github.com/imonlinux/linux-voice-assistant.git
|
|||||||
cd linux-voice-assistant/
|
cd linux-voice-assistant/
|
||||||
chmod +x script/tray
|
chmod +x script/tray
|
||||||
script/setup
|
script/setup
|
||||||
|
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e .[tray]
|
||||||
|
deactivate
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Modify config.json File:
|
### 5. Modify config.json File:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
|||||||
from pyopen_wakeword import OpenWakeWord
|
from pyopen_wakeword import OpenWakeWord
|
||||||
|
|
||||||
from .audio_engine import AudioEngine
|
from .audio_engine import AudioEngine
|
||||||
|
from .button_controller import ButtonController
|
||||||
from .config import Config, load_config_from_json
|
from .config import Config, load_config_from_json
|
||||||
from .event_bus import EventBus, EventHandler, subscribe
|
from .event_bus import EventBus, EventHandler, subscribe
|
||||||
from .led_controller import LedController
|
from .led_controller import LedController
|
||||||
@@ -41,6 +42,7 @@ class WakeWordData:
|
|||||||
active: Set[str]
|
active: Set[str]
|
||||||
stop_model: MicroWakeWord
|
stop_model: MicroWakeWord
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MediaPlayers:
|
class MediaPlayers:
|
||||||
"""Dataclass to hold media player instances."""
|
"""Dataclass to hold media player instances."""
|
||||||
@@ -107,7 +109,7 @@ class MicMuteHandler(EventHandler):
|
|||||||
{ "alarm_duration_seconds": <int> }
|
{ "alarm_duration_seconds": <int> }
|
||||||
|
|
||||||
Semantics:
|
Semantics:
|
||||||
0 -> infinite alarm (only stop via Stop wake word / wake word logic)
|
0 -> infinite alarm (only Stop/wake word stops it)
|
||||||
>0 -> auto-stop alarm after N seconds (plus Stop wake word support)
|
>0 -> auto-stop alarm after N seconds (plus Stop wake word support)
|
||||||
"""
|
"""
|
||||||
duration = data.get("alarm_duration_seconds")
|
duration = data.get("alarm_duration_seconds")
|
||||||
@@ -424,6 +426,22 @@ def _init_controllers(
|
|||||||
mqtt_controller=mqtt_controller,
|
mqtt_controller=mqtt_controller,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Hardware button controller (e.g. ReSpeaker 2-Mic HAT)
|
||||||
|
try:
|
||||||
|
if getattr(config, "button", None) is not None and config.button.enabled:
|
||||||
|
button_controller = ButtonController(
|
||||||
|
loop=loop,
|
||||||
|
event_bus=event_bus,
|
||||||
|
state=state,
|
||||||
|
config=config.button,
|
||||||
|
)
|
||||||
|
# Keep a reference on state so it is not garbage-collected.
|
||||||
|
setattr(state, "button_controller", button_controller)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("ButtonController not enabled in config; skipping")
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Failed to initialize ButtonController")
|
||||||
|
|
||||||
async def _run_server(state: ServerState, config: Config):
|
async def _run_server(state: ServerState, config: Config):
|
||||||
"""Starts the ESPHome server and ZeroConf discovery."""
|
"""Starts the ESPHome server and ZeroConf discovery."""
|
||||||
server = await state.loop.create_server(
|
server = await state.loop.create_server(
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Hardware button controller (e.g. ReSpeaker 2-Mic HAT momentary switch).
|
||||||
|
|
||||||
|
This controller:
|
||||||
|
- Short press:
|
||||||
|
* If audio is playing (TTS or music): stop playback (like Stop wake word).
|
||||||
|
* Otherwise: start a new conversation (like a wake word).
|
||||||
|
- Long press:
|
||||||
|
* Toggle microphone mute (via the existing set_mic_mute event).
|
||||||
|
|
||||||
|
Implementation note:
|
||||||
|
- Uses a simple polling loop with RPi.GPIO instead of kernel edge detection.
|
||||||
|
- This avoids "Failed to add edge detection" errors on some HATs/overlays.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .event_bus import EventBus
|
||||||
|
from .models import ServerState
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import RPi.GPIO as GPIO
|
||||||
|
except ImportError: # pragma: no cover - non-RPi hosts
|
||||||
|
GPIO = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ButtonRuntimeConfig:
|
||||||
|
"""Runtime-safe config wrapper for the hardware button."""
|
||||||
|
enabled: bool
|
||||||
|
pin: int
|
||||||
|
long_press_seconds: float
|
||||||
|
poll_interval_seconds: float = 0.01 # 100 Hz polling
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonController:
|
||||||
|
"""
|
||||||
|
Hardware button handler for the ReSpeaker 2-Mic HAT (or any GPIO button).
|
||||||
|
|
||||||
|
This class:
|
||||||
|
- Uses a background polling thread to watch the GPIO level.
|
||||||
|
- Interprets level transitions as press/release.
|
||||||
|
- Schedules actions on the asyncio loop via loop.call_soon_threadsafe().
|
||||||
|
- Uses the EventBus to toggle mic mute.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
loop,
|
||||||
|
event_bus: EventBus,
|
||||||
|
state: ServerState,
|
||||||
|
config,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
:param loop: asyncio event loop (state.loop).
|
||||||
|
:param event_bus: Global EventBus.
|
||||||
|
:param state: Global ServerState.
|
||||||
|
:param config: Config.button (ButtonConfig-like object).
|
||||||
|
"""
|
||||||
|
self.loop = loop
|
||||||
|
self.event_bus = event_bus
|
||||||
|
self.state = state
|
||||||
|
|
||||||
|
self._cfg = ButtonRuntimeConfig(
|
||||||
|
enabled=getattr(config, "enabled", False),
|
||||||
|
pin=getattr(config, "pin", 17),
|
||||||
|
long_press_seconds=float(
|
||||||
|
getattr(config, "long_press_seconds", 1.0)
|
||||||
|
),
|
||||||
|
poll_interval_seconds=float(
|
||||||
|
getattr(config, "poll_interval_seconds", 0.01)
|
||||||
|
if hasattr(config, "poll_interval_seconds")
|
||||||
|
else 0.01
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._press_time: float | None = None
|
||||||
|
self._last_level: int | None = None
|
||||||
|
|
||||||
|
if not self._cfg.enabled:
|
||||||
|
_LOGGER.info("ButtonController disabled in config; not initializing GPIO")
|
||||||
|
return
|
||||||
|
|
||||||
|
if GPIO is None:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"RPi.GPIO not available; hardware button support disabled"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
GPIO.setmode(GPIO.BCM)
|
||||||
|
# Assume button wired as active-low with internal pull-up (common for HATs)
|
||||||
|
GPIO.setup(self._cfg.pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
|
||||||
|
self._last_level = GPIO.input(self._cfg.pin)
|
||||||
|
_LOGGER.info(
|
||||||
|
"ButtonController initialized on GPIO pin %s "
|
||||||
|
"(long_press_seconds=%.2f, poll_interval=%.3fs)",
|
||||||
|
self._cfg.pin,
|
||||||
|
self._cfg.long_press_seconds,
|
||||||
|
self._cfg.poll_interval_seconds,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Failed to configure GPIO pin %s for button", self._cfg.pin)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Start polling thread
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._poll_loop,
|
||||||
|
name="ButtonControllerThread",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Polling loop
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _poll_loop(self) -> None:
|
||||||
|
"""Background loop to poll the GPIO level and detect presses."""
|
||||||
|
_LOGGER.debug("ButtonController polling thread started")
|
||||||
|
|
||||||
|
while not getattr(self.state, "shutdown", False):
|
||||||
|
try:
|
||||||
|
level = GPIO.input(self._cfg.pin)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Error reading GPIO pin %s", self._cfg.pin)
|
||||||
|
break
|
||||||
|
|
||||||
|
if self._last_level is None:
|
||||||
|
self._last_level = level
|
||||||
|
|
||||||
|
# Active-low: 0 = pressed, 1 = released
|
||||||
|
if level != self._last_level:
|
||||||
|
if level == 0:
|
||||||
|
self._on_pressed()
|
||||||
|
else:
|
||||||
|
self._on_released()
|
||||||
|
self._last_level = level
|
||||||
|
|
||||||
|
time.sleep(self._cfg.poll_interval_seconds)
|
||||||
|
|
||||||
|
_LOGGER.debug("ButtonController polling thread exiting")
|
||||||
|
# Do NOT call GPIO.cleanup() here; other components may use GPIO as well.
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# "Interrupt-like" handlers called from polling thread
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_pressed(self) -> None:
|
||||||
|
self._press_time = time.monotonic()
|
||||||
|
_LOGGER.debug("Button pressed (GPIO %s low)", self._cfg.pin)
|
||||||
|
|
||||||
|
def _on_released(self) -> None:
|
||||||
|
if self._press_time is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
duration = time.monotonic() - self._press_time
|
||||||
|
self._press_time = None
|
||||||
|
_LOGGER.debug("Button released after %.3f seconds", duration)
|
||||||
|
|
||||||
|
if duration >= self._cfg.long_press_seconds:
|
||||||
|
self._handle_long_press()
|
||||||
|
else:
|
||||||
|
self._handle_short_press()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# High-level behaviors
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _handle_short_press(self) -> None:
|
||||||
|
"""Short press: wake / stop behavior."""
|
||||||
|
# Snapshot playing state quickly
|
||||||
|
tts_playing = bool(getattr(self.state.tts_player, "is_playing", False))
|
||||||
|
music_playing = bool(getattr(self.state.music_player, "is_playing", False))
|
||||||
|
|
||||||
|
if tts_playing or music_playing:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Button short press while audio playing "
|
||||||
|
"(tts=%s, music=%s) -> stopping playback",
|
||||||
|
tts_playing,
|
||||||
|
music_playing,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stop TTS/timer via the same logic as the Stop wake word.
|
||||||
|
if self.state.satellite is not None:
|
||||||
|
self.loop.call_soon_threadsafe(self.state.satellite.stop)
|
||||||
|
|
||||||
|
# Also stop any music playback controlled by the LVA.
|
||||||
|
self.loop.call_soon_threadsafe(self.state.music_player.stop)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Button short press with no audio playing -> manual wakeup"
|
||||||
|
)
|
||||||
|
if self.state.satellite is not None:
|
||||||
|
# Use a dedicated manual wakeup to avoid faking a wake-word object.
|
||||||
|
self.loop.call_soon_threadsafe(
|
||||||
|
self.state.satellite.manual_wakeup, "button"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_long_press(self) -> None:
|
||||||
|
"""Long press: toggle mic mute."""
|
||||||
|
new_state = not self.state.mic_muted
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Button long press: toggling mic mute -> %s", new_state
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use the standard set_mic_mute event so MicMuteHandler can:
|
||||||
|
# - update ServerState.mic_muted
|
||||||
|
# - control the mic_muted_event
|
||||||
|
# - publish MQTT mute state
|
||||||
|
self.loop.call_soon_threadsafe(
|
||||||
|
self.event_bus.publish,
|
||||||
|
"set_mic_mute",
|
||||||
|
{"state": new_state},
|
||||||
|
)
|
||||||
|
|
||||||
@@ -5,6 +5,9 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import logging
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Configuration Dataclasses
|
# Configuration Dataclasses
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -16,6 +19,7 @@ class AudioConfig:
|
|||||||
input_block_size: int = 1024
|
input_block_size: int = 1024
|
||||||
output_device: Optional[str] = None
|
output_device: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WakeWordConfig:
|
class WakeWordConfig:
|
||||||
"""Settings for wake word detection."""
|
"""Settings for wake word detection."""
|
||||||
@@ -25,12 +29,14 @@ class WakeWordConfig:
|
|||||||
refractory_seconds: float = 2.0
|
refractory_seconds: float = 2.0
|
||||||
download_dir: str = "local"
|
download_dir: str = "local"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ESPHomeConfig:
|
class ESPHomeConfig:
|
||||||
"""Settings for the ESPHome API server."""
|
"""Settings for the ESPHome API server."""
|
||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 6053
|
port: int = 6053
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LedConfig:
|
class LedConfig:
|
||||||
"""Settings for the LED strip."""
|
"""Settings for the LED strip."""
|
||||||
@@ -38,7 +44,8 @@ class LedConfig:
|
|||||||
interface: str = "spi"
|
interface: str = "spi"
|
||||||
clock_pin: int = 13
|
clock_pin: int = 13
|
||||||
data_pin: int = 12
|
data_pin: int = 12
|
||||||
num_leds: int = 3 # Note: This will be overridden by 'preferences.json' if it exists
|
num_leds: int = 3 # Note: overridden by 'preferences.json' if it exists
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MqttConfig:
|
class MqttConfig:
|
||||||
@@ -49,6 +56,17 @@ class MqttConfig:
|
|||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ButtonConfig:
|
||||||
|
"""Settings for a hardware momentary button (e.g. ReSpeaker 2-Mic HAT)."""
|
||||||
|
enabled: bool = False
|
||||||
|
# BCM GPIO pin number for the button input.
|
||||||
|
pin: int = 17
|
||||||
|
# Press duration (in seconds) to be considered a "long press".
|
||||||
|
long_press_seconds: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AppConfig:
|
class AppConfig:
|
||||||
"""General application settings."""
|
"""General application settings."""
|
||||||
@@ -58,6 +76,7 @@ class AppConfig:
|
|||||||
preferences_file: str = "preferences.json"
|
preferences_file: str = "preferences.json"
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
"""Main configuration object."""
|
"""Main configuration object."""
|
||||||
@@ -67,6 +86,7 @@ class Config:
|
|||||||
esphome: ESPHomeConfig = field(default_factory=ESPHomeConfig)
|
esphome: ESPHomeConfig = field(default_factory=ESPHomeConfig)
|
||||||
led: LedConfig = field(default_factory=LedConfig)
|
led: LedConfig = field(default_factory=LedConfig)
|
||||||
mqtt: MqttConfig = field(default_factory=MqttConfig)
|
mqtt: MqttConfig = field(default_factory=MqttConfig)
|
||||||
|
button: ButtonConfig = field(default_factory=ButtonConfig)
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Helper Function
|
# Helper Function
|
||||||
@@ -88,7 +108,9 @@ def load_config_from_json(config_path: Path) -> Config:
|
|||||||
|
|
||||||
# --- Step 2: Create config objects from raw data ---
|
# --- Step 2: Create config objects from raw data ---
|
||||||
if "app" not in raw_data:
|
if "app" not in raw_data:
|
||||||
raise ValueError("Configuration file must contain an 'app' section with a 'name'.")
|
raise ValueError(
|
||||||
|
"Configuration file must contain an 'app' section with a 'name'."
|
||||||
|
)
|
||||||
|
|
||||||
app_config = AppConfig(**raw_data.get("app", {}))
|
app_config = AppConfig(**raw_data.get("app", {}))
|
||||||
audio_config = AudioConfig(**raw_data.get("audio", {}))
|
audio_config = AudioConfig(**raw_data.get("audio", {}))
|
||||||
@@ -96,6 +118,7 @@ def load_config_from_json(config_path: Path) -> Config:
|
|||||||
esphome_config = ESPHomeConfig(**raw_data.get("esphome", {}))
|
esphome_config = ESPHomeConfig(**raw_data.get("esphome", {}))
|
||||||
led_config = LedConfig(**raw_data.get("led", {}))
|
led_config = LedConfig(**raw_data.get("led", {}))
|
||||||
mqtt_config = MqttConfig(**raw_data.get("mqtt", {}))
|
mqtt_config = MqttConfig(**raw_data.get("mqtt", {}))
|
||||||
|
button_config = ButtonConfig(**raw_data.get("button", {}))
|
||||||
|
|
||||||
# --- Step 3: Set MQTT 'enabled' flag ---
|
# --- Step 3: Set MQTT 'enabled' flag ---
|
||||||
if mqtt_config.host:
|
if mqtt_config.host:
|
||||||
@@ -109,7 +132,5 @@ def load_config_from_json(config_path: Path) -> Config:
|
|||||||
esphome=esphome_config,
|
esphome=esphome_config,
|
||||||
led=led_config,
|
led=led_config,
|
||||||
mqtt=mqtt_config,
|
mqtt=mqtt_config,
|
||||||
|
button=button_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
import logging
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|||||||
@@ -478,6 +478,16 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
)
|
)
|
||||||
self._stop_timer_alarm("auto_timeout")
|
self._stop_timer_alarm("auto_timeout")
|
||||||
|
|
||||||
|
def _start_conversation(self, wake_word_phrase: str) -> None:
|
||||||
|
"""Shared helper to start a new conversation run."""
|
||||||
|
_LOGGER.debug("Starting conversation: %s", wake_word_phrase)
|
||||||
|
self.send_messages(
|
||||||
|
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
|
||||||
|
)
|
||||||
|
self._set_state(SatelliteState.LISTENING)
|
||||||
|
self._is_streaming_audio = True
|
||||||
|
self.state.tts_player.play(self.state.wakeup_sound)
|
||||||
|
|
||||||
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
|
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
|
||||||
if self._state not in (SatelliteState.IDLE, SatelliteState.STARTING):
|
if self._state not in (SatelliteState.IDLE, SatelliteState.STARTING):
|
||||||
# Existing behavior: ignore wakeup in other states.
|
# Existing behavior: ignore wakeup in other states.
|
||||||
@@ -491,12 +501,22 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
|
|
||||||
wake_word_phrase = getattr(wake_word, "wake_word", "wake word")
|
wake_word_phrase = getattr(wake_word, "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._start_conversation(wake_word_phrase)
|
||||||
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
|
|
||||||
)
|
def manual_wakeup(self, phrase: str = "button") -> None:
|
||||||
self._set_state(SatelliteState.LISTENING)
|
"""
|
||||||
self._is_streaming_audio = True
|
Manual wakeup entrypoint (e.g. hardware button) that behaves like a
|
||||||
self.state.tts_player.play(self.state.wakeup_sound)
|
wake word, but without requiring a wake-word model instance.
|
||||||
|
"""
|
||||||
|
if self._state not in (SatelliteState.IDLE, SatelliteState.STARTING):
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._timer_finished:
|
||||||
|
self._stop_timer_alarm("button")
|
||||||
|
return
|
||||||
|
|
||||||
|
_LOGGER.debug("Manual wakeup triggered: %s", phrase)
|
||||||
|
self._start_conversation(phrase)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+6
-2
@@ -22,6 +22,7 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.9.0"
|
requires-python = ">=3.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -39,8 +40,6 @@ dependencies = [
|
|||||||
"RPi.GPIO",
|
"RPi.GPIO",
|
||||||
"gpiozero",
|
"gpiozero",
|
||||||
"spidev",
|
"spidev",
|
||||||
"PyQt5",
|
|
||||||
"PyQt6",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -52,6 +51,11 @@ dev = [
|
|||||||
"pytest",
|
"pytest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Optional GUI/tray dependencies. Only needed on desktop installs that use lva_tray_client.
|
||||||
|
tray = [
|
||||||
|
"PyQt5>=5.15",
|
||||||
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
"Source Code" = "https://github.com/imonlinux/linux-voice-assistant"
|
"Source Code" = "https://github.com/imonlinux/linux-voice-assistant"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user