Leds and buttons events (#266)

Use events and commands to control custom onboard leds and buttons. Can be used to communicate with an external Python script via a WebSocket API running in a container alongside LVA.
This commit is contained in:
Omar Amin authored and GitHub committed 2026-07-03 19:58:45 +03:00
1 parent e1770d071c
commit 2d460b6728
51 files changed
+9251 -117

No files matched your search

+137 -15
View File
@@ -20,6 +20,7 @@ from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
from .models import Preferences, ServerState
from .mpv_player import MpvMediaPlayer
from .peripheral_api import LVAEvent, PeripheralAPIServer
from .satellite import VoiceSatelliteProtocol
from .util import (
get_default_interface,
@@ -60,7 +61,6 @@ async def main() -> None:
"--audio-input-block-size",
type=int,
default=1024,
# todo
)
parser.add_argument(
"--audio-output-device",
@@ -119,6 +119,11 @@ async def main() -> None:
default=str(_SOUNDS_DIR / "wake_word_triggered.flac"),
help="Directory and file name for wake sound (when you say the wake word)",
)
parser.add_argument(
"--start-listening-sound",
default=str(_SOUNDS_DIR / "start_listening_button.flac"),
help="Directory and file name and sound for start listening button (when you press button to talk)",
)
parser.add_argument(
"--timer-finished-sound",
default=str(_SOUNDS_DIR / "timer_finished.flac"),
@@ -139,31 +144,80 @@ async def main() -> None:
default=str(_SOUNDS_DIR / "mute_switch_off.flac"),
help="Sound to play when unmuting the assistant",
)
parser.add_argument(
"--button-double-press-sound",
default=str(_SOUNDS_DIR / "button_double_press.flac"),
help="Sound to play for button double press",
)
parser.add_argument(
"--button-triple-press-sound",
default=str(_SOUNDS_DIR / "button_triple_press.flac"),
help="Sound to play for button triple press",
)
parser.add_argument(
"--button-long-press-sound",
default=str(_SOUNDS_DIR / "button_long_press.flac"),
help="Sound to play for button long press",
)
parser.add_argument(
"--preferences-file",
default=_REPO_DIR / "preferences.json",
help="Directory and file name for the file where the preferences are stored in JSON format",
help="Directory and file name for the preferences JSON file",
)
parser.add_argument(
"--host",
help="Optional host IP address to bind to (default: Autodetected by network interface)", # 0.0.0.0 is IPv4, None is all interfaces
help="Optional host IP address to bind to (default: auto-detected by network interface)",
)
parser.add_argument(
"--network-interface",
help="Network interface the application will be listening on (default: will be automatically detected by gateway)",
help="Network interface the application listens on (default: auto-detected by gateway)",
)
# Note that default port is also set in docker-entrypoint.sh
parser.add_argument(
"--port",
type=int,
default=6053,
help="Port the application is listenening on (default: 6053)",
help="Port the application is listening on (default: 6053)",
)
parser.add_argument(
"--enable-thinking-sound",
action="store_true",
help="Enable thinking finish sound, when the assistant is done thinking and needed more time to process",
help="Enable thinking sound on startup",
)
# ------------------------------------------------------------------
# Peripheral API (LEDs, buttons, HAT boards)
# ------------------------------------------------------------------
parser.add_argument(
"--peripheral-host",
default="0.0.0.0",
help="Bind address for the peripheral WebSocket API (default: 0.0.0.0)",
)
parser.add_argument(
"--peripheral-port",
type=int,
default=6055,
help="Port for the peripheral WebSocket API (default: 6055)",
)
parser.add_argument(
"--peripheral-volume-step",
type=float,
default=PeripheralAPIServer.DEFAULT_VOLUME_STEP,
metavar="STEP",
help="Volume change per button press, 0.0–1.0 (default: %(default)s)",
)
parser.add_argument(
"--disable-peripheral-api",
action="store_true",
help="Disable the peripheral WebSocket API entirely",
)
parser.add_argument(
"--peripheral-startup-wait",
type=float,
default=2.0,
metavar="SECONDS",
help="Seconds to wait for peripherals to connect and register their entities before HA enumerates the ESPHome API (default: %(default)s; set 0 to skip).",
)
# ------------------------------------------------------------------
parser.add_argument(
"--timer-max-ring-seconds",
type=float,
@@ -328,10 +382,14 @@ async def main() -> None:
music_player=MpvMediaPlayer(device=args.audio_output_device),
tts_player=MpvMediaPlayer(device=args.audio_output_device),
wakeup_sound=args.wakeup_sound,
start_listening_sound=args.start_listening_sound,
timer_finished_sound=args.timer_finished_sound,
processing_sound=args.processing_sound,
mute_sound=args.mute_sound,
unmute_sound=args.unmute_sound,
button_double_press_sound=args.button_double_press_sound,
button_triple_press_sound=args.button_triple_press_sound,
button_long_press_sound=args.button_long_press_sound,
preferences=preferences,
preferences_path=preferences_path,
refractory_seconds=args.refractory_seconds,
@@ -362,6 +420,22 @@ async def main() -> None:
state.music_player.set_volume(initial_volume_percent)
state.tts_player.set_volume(initial_volume_percent)
# ------------------------------------------------------------------
# Peripheral API (optional – LEDs, buttons, HAT boards)
# ------------------------------------------------------------------
peripheral_api: Optional[PeripheralAPIServer] = None
if not args.disable_peripheral_api:
peripheral_api = PeripheralAPIServer(
host=args.peripheral_host,
port=args.peripheral_port,
volume_step=args.peripheral_volume_step,
)
peripheral_api.set_state(state)
state.peripheral_api = peripheral_api
# ------------------------------------------------------------------
# ESPHome TCP server (with retry on EADDRINUSE)
# ------------------------------------------------------------------
loop = asyncio.get_running_loop()
max_attempts = 15
attempt = 1
@@ -385,20 +459,40 @@ async def main() -> None:
while attempt <= max_attempts:
try:
server = await loop.create_server(lambda: VoiceSatelliteProtocol(state), host=host_ip_address, port=args.port)
break # connect successful, exit the loop
server = await loop.create_server(
lambda: VoiceSatelliteProtocol(state),
host=host_ip_address,
port=args.port,
)
break # connection successful, exit the loop
except OSError as err:
message = err.strerror or str(err)
if err.errno == errno.EADDRINUSE:
message = "address already in use"
if attempt < max_attempts:
_LOGGER.warning("Attempt %d/%d failed to bind on address (%s, %s): %s. Retrying in 1 second...", attempt, max_attempts, host_ip_address, args.port, message)
_LOGGER.warning(
"Attempt %d/%d failed to bind on address (%s, %s): %s. Retrying in 1 second...",
attempt,
max_attempts,
host_ip_address,
args.port,
message,
)
await asyncio.sleep(1)
attempt += 1
else:
_LOGGER.exception("All %d attempts failed to bind on address (%s, %s): %s", max_attempts, host_ip_address, args.port, message)
_LOGGER.exception(
"All %d attempts failed to bind on address (%s, %s): %s",
max_attempts,
host_ip_address,
args.port,
message,
)
sys.exit(1)
# ------------------------------------------------------------------
# Audio processing thread
# ------------------------------------------------------------------
process_audio_thread = threading.Thread(
target=process_audio,
args=(state, mic, args.audio_input_block_size),
@@ -407,18 +501,46 @@ async def main() -> None:
process_audio_thread.start()
# Auto discovery (zeroconf, mDNS)
discovery = HomeAssistantZeroconf(port=args.port, name=state.name, mac_address=state.mac_address, host_ip_address=host_ip_address)
discovery = HomeAssistantZeroconf(
port=args.port,
name=state.name,
mac_address=state.mac_address,
host_ip_address=host_ip_address,
)
await discovery.register_server()
# ------------------------------------------------------------------
# Start peripheral API and signal "getting started" to peripherals
# ------------------------------------------------------------------
if peripheral_api is not None:
await peripheral_api.start()
await peripheral_api.emit_event(LVAEvent.ZEROCONF, {"status": "getting_started"})
# Give peripherals a window to connect and register their Light
# entities before HA enumerates over the ESPHome native API. The
# ESPHome server is bound but not yet serving (serve_forever runs
# below), so any HA connection sits queued in the kernel for the
# duration of this wait. Peripherals that register later still
# work, but the new entities only show up in HA after the
# integration reconnects.
if args.peripheral_startup_wait > 0:
_LOGGER.info(
"Waiting %.1fs for peripherals to register entities…",
args.peripheral_startup_wait,
)
await asyncio.sleep(args.peripheral_startup_wait)
try:
async with server: # type: ignore
async with server: # type: ignore[union-attr]
_LOGGER.info("Server started (host=%s, port=%s)", host_ip_address, args.port)
await server.serve_forever() # type: ignore
await server.serve_forever() # type: ignore[union-attr]
except KeyboardInterrupt:
pass
finally:
state.audio_queue.put_nowait(None)
process_audio_thread.join()
if peripheral_api is not None:
await peripheral_api.stop()
_LOGGER.debug("Server stopped")
@@ -628,9 +750,9 @@ def process_audio(state: ServerState, mic, block_size: int):
if stopped and (state.stop_word.id in state.active_wake_words) and not state.muted:
_LOGGER.debug("Stop word detected")
state.satellite.stop()
except Exception:
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected error handling audio")
except Exception:
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected error processing audio")
sys.exit(1)
+174
View File
@@ -5,6 +5,11 @@ from typing import Callable, List, Optional, Union
# pylint: disable=no-name-in-module
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
EventResponse,
LightCommandRequest,
LightStateResponse,
ListEntitiesEventResponse,
ListEntitiesLightResponse,
ListEntitiesMediaPlayerResponse,
ListEntitiesNumberResponse,
ListEntitiesRequest,
@@ -21,6 +26,7 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
SwitchStateResponse,
)
from aioesphomeapi.model import (
ColorMode,
EntityCategory,
MediaPlayerCommand,
MediaPlayerEntityFeature,
@@ -612,12 +618,180 @@ class StopWordSensitivityNumberEntity(ESPHomeEntity):
yield NumberStateResponse(key=self.key, state=self.value)
class LEDLightEntity(ESPHomeEntity):
"""RGB Light entity for peripheral LEDs.
The peripheral declares its capabilities (effects, RGB, brightness)
via the register_light command. When Home Assistant changes the
entity, on_changed fires so the peripheral API server can broadcast
a light_command event back to the peripheral, which applies the
new state to its hardware.
"""
def __init__(
self,
server: APIServer,
key: int,
name: str,
object_id: str,
effects: Optional[List[str]] = None,
supports_rgb: bool = True,
supports_brightness: bool = True,
on_changed: Optional[Callable[[], None]] = None,
icon: str = "mdi:led-strip-variant",
) -> None:
ESPHomeEntity.__init__(self, server)
self.key = key
self.name = name
self.object_id = object_id
self.icon = icon
self._on_changed = on_changed
self.effects_list: List[str] = list(effects) if effects else []
self._supports_rgb = supports_rgb
self._supports_brightness = supports_brightness
# Off by default, matching the HA Voice PE LED Ring
# (restore_mode RESTORE_DEFAULT_OFF): the resting LEDs stay dark
# until the user turns the light on. Voice animations are driven
# separately by the peripheral and play regardless.
self.is_on: bool = False
# Match the HA Voice PE LED Ring initial state: a light blue at 66%
# brightness (red 9.4%, green 73.3%, blue 94.9%).
self.brightness: float = 0.66
self.red: float = 0.094
self.green: float = 0.733
self.blue: float = 0.949
# Default effect: first declared, or empty if none.
self.effect: str = self.effects_list[0] if self.effects_list else ""
def update_on_changed(self, on_changed: Optional[Callable[[], None]]) -> None:
self._on_changed = on_changed
def _color_mode(self) -> ColorMode:
if self._supports_rgb:
return ColorMode.RGB
if self._supports_brightness:
return ColorMode.BRIGHTNESS
return ColorMode.ON_OFF
def state_dict(self) -> dict:
"""Payload for the light_command event.
Includes object_id so a peripheral that registered more than one
Light can route the event to the right hardware.
"""
return {
"object_id": self.object_id,
"state": self.is_on,
"brightness": self.brightness,
"red": self.red,
"green": self.green,
"blue": self.blue,
"effect": self.effect,
}
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
if isinstance(msg, LightCommandRequest) and msg.key == self.key:
changed = False
if msg.has_state:
self.is_on = bool(msg.state)
changed = True
if msg.has_brightness and self._supports_brightness:
self.brightness = max(0.0, min(1.0, float(msg.brightness)))
changed = True
if msg.has_rgb and self._supports_rgb:
self.red = max(0.0, min(1.0, float(msg.red)))
self.green = max(0.0, min(1.0, float(msg.green)))
self.blue = max(0.0, min(1.0, float(msg.blue)))
changed = True
if msg.has_effect:
requested = str(msg.effect)
if requested in self.effects_list:
self.effect = requested
changed = True
if changed and self._on_changed is not None:
self._on_changed()
yield self._state_response()
elif isinstance(msg, ListEntitiesRequest):
yield ListEntitiesLightResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
supported_color_modes=[int(self._color_mode())],
effects=self.effects_list,
icon=self.icon,
entity_category=EntityCategory.CONFIG,
)
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
yield self._state_response()
def _state_response(self) -> LightStateResponse:
return LightStateResponse(
key=self.key,
state=self.is_on,
brightness=self.brightness,
color_mode=int(self._color_mode()),
color_brightness=self.brightness,
red=self.red,
green=self.green,
blue=self.blue,
effect=self.effect,
)
class ButtonEventSensorEntity(ESPHomeEntity):
def __init__(
self,
server: APIServer,
key: int,
name: str,
object_id: str,
) -> None:
ESPHomeEntity.__init__(self, server)
self.key = key
self.name = name
self.object_id = object_id
self.event_types = ["single_press", "double_press", "triple_press", "long_press"]
self._current_event: Optional[str] = None
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
def update_state(self, event_type: str) -> None:
"""Update the event state with a button press event."""
self._current_event = event_type
self._log.debug("Button event state updated: %s", event_type)
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
if isinstance(msg, ListEntitiesRequest):
yield ListEntitiesEventResponse(
object_id=self.object_id,
key=self.key,
name=self.name,
device_class="button",
event_types=self.event_types,
)
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
# Wait until a press fires: yielding with an empty
# event_type makes HA reject the state and fail the
# whole ESPHome config entry to load.
if self._current_event:
yield self._get_state_message()
def _get_state_message(self) -> EventResponse:
return EventResponse(
key=self.key,
event_type=self._current_event or "",
)
# Backward compatibility export aliases
__all__ = [
"ESPHomeEntity",
"MediaPlayerEntity",
"MuteSwitchEntity",
"ThinkingSoundEntity",
"LEDLightEntity",
"ButtonEventSensorEntity",
"WakeWord1SensitivityNumberEntity",
"WakeWord2SensitivityNumberEntity",
"StopWordSensitivityNumberEntity",
+51 -1
View File
@@ -6,14 +6,16 @@ from dataclasses import asdict, dataclass, field
from enum import Enum
from pathlib import Path
from queue import Queue
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union
if TYPE_CHECKING:
from pymicro_wakeword import MicroWakeWord
from pyopen_wakeword import OpenWakeWord
from .entity import (
ButtonEventSensorEntity,
ESPHomeEntity,
LEDLightEntity,
MediaPlayerEntity,
MicSettingEntity,
MuteSwitchEntity,
@@ -59,6 +61,23 @@ class AvailableWakeWord:
raise ValueError(f"Unexpected wake word type: {self.type}")
@dataclass
class LightRegistration:
"""Capabilities a peripheral declares for one of its Light entities.
The peripheral sends this with the register_light command after
connecting. LVA materialises a matching LEDLightEntity so HA can
control it.
"""
name: str
object_id: str
icon: str = "mdi:led-strip-variant"
effects: List[str] = field(default_factory=list)
supports_rgb: bool = True
supports_brightness: bool = True
@dataclass
class Preferences:
active_wake_words: List[Optional[str]] = field(default_factory=list)
@@ -91,10 +110,14 @@ class ServerState:
music_player: "MpvMediaPlayer"
tts_player: "MpvMediaPlayer"
wakeup_sound: str
start_listening_sound: str
processing_sound: str
timer_finished_sound: str
mute_sound: str
unmute_sound: str
button_double_press_sound: str
button_triple_press_sound: str
button_long_press_sound: str
preferences: Preferences
preferences_path: Path
download_dir: Path
@@ -104,6 +127,26 @@ class ServerState:
satellite: "Optional[VoiceSatelliteProtocol]" = None
mute_switch_entity: "Optional[MuteSwitchEntity]" = None
thinking_sound_entity: "Optional[ThinkingSoundEntity]" = None
button_event_sensor_entity: "Optional[ButtonEventSensorEntity]" = None
# Lights declared by peripherals via register_light. Survives HA
# reconnects so the satellite can rebuild its entities whenever it
# is constructed again.
pending_lights: "List[LightRegistration]" = field(default_factory=list)
# Materialised LightEntities keyed by object_id, so light_command
# events can be routed back to the right peripheral hardware.
led_light_entities: "Dict[str, LEDLightEntity]" = field(default_factory=dict)
# True once a peripheral sends register_button. Gates creation of
# ButtonEventSensorEntity so the HA device page only shows the button
# entity when hardware that actually supports button presses is present.
# Survives HA reconnects so the entity is re-registered automatically.
pending_button: bool = False
# Optional peripheral WebSocket API (LEDs, buttons, HAT boards).
# Assigned in __main__ before the event loop starts.
peripheral_api: "Optional[Any]" = None # PeripheralAPIServer at runtime
sensitivity_1_number_entity: "Optional[WakeWord1SensitivityNumberEntity]" = None
sensitivity_2_number_entity: "Optional[WakeWord2SensitivityNumberEntity]" = None
stop_sensitivity_number_entity: "Optional[StopWordSensitivityNumberEntity]" = None
@@ -161,6 +204,13 @@ class ServerState:
self.save_preferences()
_LOGGER.info("Volume saved successfully")
# Notify peripheral container (thread-safe; may be called from mpv callbacks)
api = self.peripheral_api
if api is not None:
from .peripheral_api import LVAEvent # local import avoids circular dep
api.emit_event_sync(LVAEvent.VOLUME_CHANGED, {"volume": round(clamped_volume, 3)})
def persist_mic_gain(self, gain: float) -> None:
"""Persist the microphone auto gain value."""
gain_int = int(gain)
+644
View File
@@ -0,0 +1,644 @@
"""WebSocket peripheral API.
Bridges LVA state to a separate peripheral container (LEDs, buttons, HAT boards).
Protocol (JSON over WebSocket):
Events (LVA → peripheral): {"event": "<name>", "data": {...}}
Commands (peripheral → LVA): {"command": "<name>", "data": {...}}
Snapshot (on connect): {"event": "snapshot", "data": {...}}
Feedback events emitted by LVA
-------------------------------
wake_word_detected
listening
stt_text data: {"text": str}
Emitted when HA returns the recognised speech transcript.
Use this to display what the user said on a screen or LED ticker.
thinking
tts_text data: {"text": str}
Emitted when HA returns the assistant's response text, just
before TTS audio begins playing.
Use this to display the assistant's reply on a screen.
tts_speaking
tts_finished
pipeline_error data: {"reason": <str>}
Emitted when the voice pipeline reports an error (STT failure,
intent error, etc.). NOT emitted when HA disconnects — that
uses the separate ``disconnected`` event below.
Peripheral containers should show a brief red error animation
(e.g. 3 red flashes then off) and then return to idle.
disconnected
Emitted when the HA TCP connection is lost.
Connected leds with peripheral containers should
show a red twinkle / "no connection" animation and keep retrying
until they see a ``zeroconf`` event with status "connected".
NOTE: if LVA itself is not running the peripheral container will
see a WebSocket connection failure on its end — that is also
a "disconnected" condition to handle with the same animation.
idle
muted data: {"muted": true/false}
timer_ticking data: {"id": str, "name": str, "total_seconds": int, "seconds_left": int}
timer_updated data: {"id": str, "name": str, "total_seconds": int, "seconds_left": int}
timer_ringing data: {"id": str, "name": str, "total_seconds": int, "seconds_left": int}
media_player_playing Emitted when HA sends music/media to the music_player
(non-announcement playback). Not emitted for TTS or
voice pipeline announcements — those use tts_speaking.
volume_changed data: {"volume": 0.0–1.0}
volume_muted data: {"muted": true/false}
zeroconf data: {"status": "getting_started" | "connected"}
light_command data: {"object_id": str, "state": bool, "brightness": float,
"red": float, "green": float, "blue": float, "effect": str}
Fires when HA changes a Light entity that a peripheral
previously registered via register_light. The peripheral
matches on object_id and applies the new state. The
effect names are those the peripheral declared at
registration; e.g. "Voice Assistant" runs the pipeline
animations.
Commands accepted from the peripheral container
------------------------------------------------
start_listening
stop_pipeline Abort the active voice pipeline at any phase — listening,
thinking, speaking or wake word active. Calls satellite.stop() which
cleans up STT streaming, sends VoiceAssistantAnnounceFinished
to HA, unducking music, and emits idle to peripherals.
mute_mic
unmute_mic
volume_up
volume_down
set_volume data: {"volume": 0.0–1.0}
stop_timer_ringing
pause_media_player
resume_media_player
stop_media_player
button_single_press
button_double_press
button_triple_press
button_long_press
register_light data: {"name": str, "object_id": str, "effects": [str],
"supports_rgb": bool, "supports_brightness": bool}
The peripheral declares an LED Light it wants exposed in
HA. LVA creates a matching ESPHome Light entity (visible
as light.<satellite>_<object_id>) and routes HA changes
back to the peripheral as light_command events. Send
once after connecting; duplicate registrations for the
same object_id are ignored.
register_button
The peripheral declares that it has physical buttons and
wants a Button Press event entity exposed in HA. LVA
creates a ButtonEventSensorEntity (visible as
event.<satellite>_button_press_event) that fires
single_press, double_press, triple_press, and long_press
events to Home Assistant when the corresponding
button_* commands are sent. Send once after connecting;
duplicate registrations are ignored.
"""
from __future__ import annotations
import asyncio
import json
import logging
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, Optional, Set
from aioesphomeapi.api_pb2 import MediaPlayerStateResponse # type: ignore[attr-defined] # pylint: disable=no-name-in-module
from aioesphomeapi.model import MediaPlayerState # type: ignore[import]
if TYPE_CHECKING:
from .models import ServerState
_LOGGER = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Public enumerations
# ---------------------------------------------------------------------------
class LVAEvent(str, Enum):
"""Events broadcast from LVA to peripheral clients."""
WAKE_WORD_DETECTED = "wake_word_detected"
LISTENING = "listening"
STT_TEXT = "stt_text"
THINKING = "thinking"
TTS_TEXT = "tts_text"
TTS_SPEAKING = "tts_speaking"
TTS_FINISHED = "tts_finished"
PIPELINE_ERROR = "pipeline_error"
DISCONNECTED = "disconnected"
IDLE = "idle"
MUTED = "muted"
TIMER_TICKING = "timer_ticking"
TIMER_UPDATED = "timer_updated"
TIMER_RINGING = "timer_ringing"
MEDIA_PLAYER_PLAYING = "media_player_playing"
VOLUME_CHANGED = "volume_changed"
VOLUME_MUTED = "volume_muted"
ZEROCONF = "zeroconf"
LIGHT_COMMAND = "light_command"
class LVACommand(str, Enum):
"""Commands accepted from peripheral clients."""
START_LISTENING = "start_listening"
STOP_PIPELINE = "stop_pipeline"
MUTE_MIC = "mute_mic"
UNMUTE_MIC = "unmute_mic"
VOLUME_UP = "volume_up"
VOLUME_DOWN = "volume_down"
SET_VOLUME = "set_volume"
STOP_TIMER_RINGING = "stop_timer_ringing"
STOP_MEDIA_PLAYER = "stop_media_player"
PAUSE_MEDIA_PLAYER = "pause_media_player"
RESUME_MEDIA_PLAYER = "resume_media_player"
BUTTON_SINGLE_PRESS = "button_single_press"
BUTTON_DOUBLE_PRESS = "button_double_press"
BUTTON_TRIPLE_PRESS = "button_triple_press"
BUTTON_LONG_PRESS = "button_long_press"
REGISTER_LIGHT = "register_light"
REGISTER_BUTTON = "register_button"
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
class PeripheralAPIServer:
"""
WebSocket server that bridges LVA state to a peripheral container.
Usage
-----
1. Construct in ``__main__.main()``.
2. ``peripheral_api.set_state(state)`` once ``ServerState`` exists.
3. ``await peripheral_api.start()`` inside the running event loop.
4. Call ``emit_event_sync()`` from any thread (mpv callbacks, audio thread).
"""
DEFAULT_VOLUME_STEP: float = 0.05
def __init__(
self,
host: str = "0.0.0.0",
port: int = 6055,
volume_step: float = DEFAULT_VOLUME_STEP,
) -> None:
self._host = host
self._port = port
self._volume_step = volume_step
self._clients: Set[Any] = set()
self._state: Optional[ServerState] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._server: Any = None
# Last conversation exchange — sent in the snapshot to newly-connecting clients
self._last_stt_text: Optional[str] = None
self._last_tts_text: Optional[str] = None
# Current event state — replayed to newly-connecting clients so they can
# show the correct animation immediately without waiting for the next event.
# Only "state" events are tracked (pipeline, timer, media, muted, idle).
# Transient/informational events (stt_text, tts_text, volume_changed, etc.)
# are not tracked because they carry no ongoing visual state.
self._current_state: Optional[LVAEvent] = None
self._current_state_data: Optional[Dict[str, Any]] = None
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def set_state(self, state: "ServerState") -> None:
"""Attach the shared ``ServerState`` so commands can read/mutate it."""
self._state = state
async def start(self) -> None:
"""Start the WebSocket server inside the running event loop."""
try:
from websockets.server import serve # type: ignore[import]
except ImportError:
_LOGGER.error("websockets package not installed – peripheral API disabled. Install with: pip install websockets")
return
self._loop = asyncio.get_running_loop()
self._server = await serve(self._handle_client, self._host, self._port)
_LOGGER.info("Peripheral API listening at ws://%s:%d", self._host, self._port)
async def stop(self) -> None:
"""Gracefully shut down the server and all client connections."""
if self._server is not None:
self._server.close()
await self._server.wait_closed()
_LOGGER.info("Peripheral API stopped")
# ------------------------------------------------------------------
# Client handling
# ------------------------------------------------------------------
async def _handle_client(self, websocket: Any) -> None:
addr = getattr(websocket, "remote_address", "unknown")
self._clients.add(websocket)
_LOGGER.info("Peripheral client connected: %s", addr)
await self._send_snapshot(websocket)
try:
async for raw in websocket:
await self._dispatch_command(raw)
except Exception as exc: # pylint: disable=broad-except
_LOGGER.debug("Peripheral client %s error: %s", addr, exc)
finally:
self._clients.discard(websocket)
_LOGGER.info("Peripheral client disconnected: %s", addr)
async def _send_snapshot(self, websocket: Any) -> None:
"""Push current LVA state to a newly connected peripheral client."""
state = self._state
if state is None:
return
payload = json.dumps(
{
"event": "snapshot",
"data": {
"muted": state.muted,
"volume": round(state.volume, 3),
"ha_connected": state.connected,
"last_stt_text": self._last_stt_text,
"last_tts_text": self._last_tts_text,
},
}
)
try:
await websocket.send(payload)
except Exception: # pylint: disable=broad-except
return
# Replay the current event state so the client immediately shows the
# right animation — e.g. a timer ticking animation when reconnecting
# mid-timer, or the muted indicator when reconnecting while muted.
if self._current_state is not None:
state_payload: Dict[str, Any] = {"event": self._current_state.value}
if self._current_state_data:
state_payload["data"] = self._current_state_data
try:
await websocket.send(json.dumps(state_payload))
except Exception: # pylint: disable=broad-except
pass
# ------------------------------------------------------------------
# Command dispatch
# ------------------------------------------------------------------
async def _dispatch_command(self, raw: str) -> None:
"""Parse and execute a JSON command from the peripheral container."""
try:
msg: Dict[str, Any] = json.loads(raw)
except json.JSONDecodeError:
_LOGGER.warning("Peripheral: invalid JSON: %.200s", raw)
return
command: str = msg.get("command", "")
if not command:
return
_LOGGER.debug("Peripheral command received: %s", command)
state = self._state
if state is None:
return
satellite = state.satellite
if command == LVACommand.START_LISTENING:
if satellite is None or state.muted:
return
satellite.start_listening() # plays sound, then starts pipeline
elif command == LVACommand.STOP_PIPELINE:
# Stops the voice pipeline at any active phase:
# listening, thinking, speaking or wake word active.
if satellite is not None:
satellite.stop()
elif command == LVACommand.MUTE_MIC:
if satellite is not None and not state.muted:
satellite._set_muted(True) # pylint: disable=protected-access
await self._push_mute_switch(satellite, muted=True)
elif command == LVACommand.UNMUTE_MIC:
if satellite is not None and state.muted:
satellite._set_muted(False) # pylint: disable=protected-access
await self._push_mute_switch(satellite, muted=False)
elif command in (LVACommand.VOLUME_UP, LVACommand.VOLUME_DOWN):
delta = self._volume_step if command == LVACommand.VOLUME_UP else -self._volume_step
new_vol = max(0.0, min(1.0, state.volume + delta))
vol_pct = int(round(new_vol * 100))
state.music_player.set_volume(vol_pct)
state.tts_player.set_volume(vol_pct)
if state.media_player_entity is not None:
state.media_player_entity.volume = new_vol
state.media_player_entity.previous_volume = new_vol
# Push the new volume to HA so its media player entity updates in real time
if satellite is not None:
satellite.send_messages(
[
MediaPlayerStateResponse(
key=state.media_player_entity.key,
state=state.media_player_entity.state,
volume=new_vol,
muted=state.media_player_entity.muted,
)
]
)
# persist_volume also emits VOLUME_CHANGED via models.py
state.persist_volume(new_vol)
elif command == LVACommand.SET_VOLUME:
data = msg.get("data", {})
volume = data.get("volume")
if not isinstance(volume, (int, float)):
_LOGGER.warning("Peripheral: invalid volume in set_volume command: %s", volume)
return
new_vol = max(0.0, min(1.0, float(volume)))
vol_pct = int(round(new_vol * 100))
state.music_player.set_volume(vol_pct)
state.tts_player.set_volume(vol_pct)
if state.media_player_entity is not None:
state.media_player_entity.volume = new_vol
state.media_player_entity.previous_volume = new_vol
# Push the new volume to HA so its media player entity updates in real time
if satellite is not None:
satellite.send_messages(
[
MediaPlayerStateResponse(
key=state.media_player_entity.key,
state=state.media_player_entity.state,
volume=new_vol,
muted=state.media_player_entity.muted,
)
]
)
# persist_volume also emits VOLUME_CHANGED via models.py
state.persist_volume(new_vol)
elif command == LVACommand.STOP_TIMER_RINGING:
if satellite is None:
return
if getattr(satellite, "_timer_finished", False):
satellite._timer_finished = False # pylint: disable=protected-access
state.active_wake_words.discard(state.stop_word.id)
state.tts_player.stop()
satellite.unduck()
await self.emit_event(LVAEvent.IDLE)
elif command == LVACommand.STOP_MEDIA_PLAYER:
state.music_player.stop()
if state.media_player_entity is not None:
state.media_player_entity.state = MediaPlayerState.IDLE
if satellite is not None:
satellite.send_messages([self._create_media_player_response(MediaPlayerState.IDLE)])
elif command == LVACommand.PAUSE_MEDIA_PLAYER:
state.music_player.pause()
if state.media_player_entity is not None:
state.media_player_entity.state = MediaPlayerState.PAUSED
if satellite is not None:
satellite.send_messages([self._create_media_player_response(MediaPlayerState.PAUSED)])
elif command == LVACommand.RESUME_MEDIA_PLAYER:
state.music_player.resume()
if state.media_player_entity is not None:
state.media_player_entity.state = MediaPlayerState.PLAYING
if satellite is not None:
satellite.send_messages([self._create_media_player_response(MediaPlayerState.PLAYING)])
elif command == LVACommand.BUTTON_SINGLE_PRESS:
if state.button_event_sensor_entity is not None:
state.button_event_sensor_entity.update_state("single_press")
if satellite is not None:
satellite.send_messages([state.button_event_sensor_entity._get_state_message()]) # pylint: disable=protected-access
elif command == LVACommand.BUTTON_DOUBLE_PRESS:
state.tts_player.play(state.button_double_press_sound)
if state.button_event_sensor_entity is not None:
state.button_event_sensor_entity.update_state("double_press")
if satellite is not None:
satellite.send_messages([state.button_event_sensor_entity._get_state_message()]) # pylint: disable=protected-access
elif command == LVACommand.BUTTON_TRIPLE_PRESS:
state.tts_player.play(state.button_triple_press_sound)
if state.button_event_sensor_entity is not None:
state.button_event_sensor_entity.update_state("triple_press")
if satellite is not None:
satellite.send_messages([state.button_event_sensor_entity._get_state_message()]) # pylint: disable=protected-access
elif command == LVACommand.BUTTON_LONG_PRESS:
state.tts_player.play(state.button_long_press_sound)
if state.button_event_sensor_entity is not None:
state.button_event_sensor_entity.update_state("long_press")
if satellite is not None:
satellite.send_messages([state.button_event_sensor_entity._get_state_message()]) # pylint: disable=protected-access
elif command == LVACommand.REGISTER_LIGHT:
self._register_light(msg.get("data") or {}, satellite)
elif command == LVACommand.REGISTER_BUTTON:
self._register_button(satellite)
def _register_light(self, data: Dict[str, Any], satellite: Any) -> None:
"""Register a Light declared by a peripheral.
Idempotent on object_id: repeat registrations (e.g. after a
peripheral reconnect) keep the existing entity and its state.
"""
from .models import LightRegistration # local import to avoid a cycle
object_id = str(data.get("object_id", "")).strip()
if not object_id:
_LOGGER.warning("register_light without object_id; ignoring")
return
state = self._state
if state is None:
return
if any(spec.object_id == object_id for spec in state.pending_lights):
# Same light already on file; nothing to do.
return
spec = LightRegistration(
name=str(data.get("name", "LEDs")),
object_id=object_id,
icon=str(data.get("icon", "mdi:led-strip-variant")),
effects=[str(e) for e in data.get("effects", []) if e],
supports_rgb=bool(data.get("supports_rgb", True)),
supports_brightness=bool(data.get("supports_brightness", True)),
)
state.pending_lights.append(spec)
_LOGGER.info("Light registered: %s (effects=%s)", object_id, spec.effects)
# If the satellite is already running, materialise the entity
# now so future messages route correctly. HA only sees it
# after the integration reconnects, but LVA stays consistent.
if satellite is not None:
satellite.register_pending_lights()
def _register_button(self, satellite: Any) -> None:
"""Register button press event support declared by a peripheral.
Idempotent: repeat registrations from a reconnecting peripheral
are a no-op — the existing entity and its accumulated event state
are preserved.
When the satellite is already running, the entity is materialised
immediately so subsequent button_* commands route correctly.
HA only sees the new entity after the integration is reloaded, but
the same startup-wait window that applies to register_light applies
here too (see ``--peripheral-startup-wait``).
"""
state = self._state
if state is None:
return
if state.pending_button:
# Already registered; keep the existing entity.
return
state.pending_button = True
_LOGGER.info("Button event sensor registered by peripheral")
if satellite is not None:
satellite.register_pending_button()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
async def _push_mute_switch(self, satellite: Any, *, muted: bool) -> None:
"""Reflect a peripheral-triggered mute change to Home Assistant."""
state = self._state
if state is None or state.mute_switch_entity is None:
return
entity = state.mute_switch_entity
entity._switch_state = muted # pylint: disable=protected-access
# pylint: disable=no-name-in-module
from aioesphomeapi.api_pb2 import SwitchStateResponse # type: ignore[attr-defined]
satellite.send_messages([SwitchStateResponse(key=entity.key, state=muted)])
# ------------------------------------------------------------------
# Event emission
# ------------------------------------------------------------------
async def emit_event(
self,
event: LVAEvent,
data: Optional[Dict[str, Any]] = None,
) -> None:
"""Broadcast an event to all connected peripheral clients."""
# Cache the last conversation text so newly-connecting clients get it in the snapshot
if event == LVAEvent.STT_TEXT and data:
self._last_stt_text = data.get("text")
elif event == LVAEvent.TTS_TEXT and data:
self._last_tts_text = data.get("text")
# Clear both sides at the start of a new pipeline run
elif event == LVAEvent.LISTENING:
self._last_stt_text = None
self._last_tts_text = None
# ----------------------------------------------------------------
# Track current "state" so newly-connecting clients receive it on
# connect via _send_snapshot. Only persistent/visual states are
# stored — transient informational events are skipped.
# ----------------------------------------------------------------
_STATE_EVENTS = {
LVAEvent.WAKE_WORD_DETECTED,
LVAEvent.LISTENING,
LVAEvent.THINKING,
LVAEvent.TTS_SPEAKING,
LVAEvent.TTS_FINISHED,
LVAEvent.IDLE,
LVAEvent.MUTED,
LVAEvent.TIMER_TICKING,
LVAEvent.TIMER_RINGING,
LVAEvent.MEDIA_PLAYER_PLAYING,
LVAEvent.DISCONNECTED,
LVAEvent.PIPELINE_ERROR,
}
if event in _STATE_EVENTS:
self._current_state = event
self._current_state_data = data or None
elif event == LVAEvent.TIMER_UPDATED and self._current_state == LVAEvent.TIMER_TICKING:
# Keep state as TIMER_TICKING but refresh the countdown data
self._current_state_data = data or None
if not self._clients:
return
payload: Dict[str, Any] = {"event": event.value}
if data:
payload["data"] = data
raw_msg = json.dumps(payload)
dead: Set[Any] = set()
for ws in list(self._clients):
try:
await ws.send(raw_msg)
except Exception: # pylint: disable=broad-except
dead.add(ws)
self._clients -= dead
_LOGGER.debug(
"Peripheral event %-25s → %d client(s)",
event.value,
len(self._clients),
)
def emit_event_sync(
self,
event: LVAEvent,
data: Optional[Dict[str, Any]] = None,
) -> None:
"""
Thread-safe fire-and-forget event emission.
Safe to call from mpv callbacks, the audio processing thread, or any
non-async context while the asyncio event loop is running.
"""
loop = self._loop
if loop is None or loop.is_closed():
return
asyncio.run_coroutine_threadsafe(self.emit_event(event, data), loop)
def _create_media_player_response(self, state: MediaPlayerState) -> MediaPlayerStateResponse:
"""Create a MediaPlayerStateResponse with current entity state."""
assert self._state is not None
media_entity = self._state.media_player_entity
assert media_entity is not None
return MediaPlayerStateResponse(
key=media_entity.key,
state=state,
volume=media_entity.volume,
muted=media_entity.muted,
)
+308 -72
View File
@@ -8,7 +8,8 @@ import shutil
import threading
import time
from collections.abc import Iterable
from typing import Dict, List, Optional, Set, Union
from functools import partial
from typing import Any, Dict, List, Optional, Set, Union
from urllib.parse import urlparse, urlunparse
from urllib.request import urlopen
@@ -17,6 +18,7 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
AuthenticationRequest,
DeviceInfoRequest,
DeviceInfoResponse,
LightCommandRequest,
ListEntitiesDoneResponse,
ListEntitiesRequest,
MediaPlayerCommandRequest,
@@ -48,6 +50,8 @@ from pyopen_wakeword import OpenWakeWord
from .api_server import APIServer
from .entity import (
ButtonEventSensorEntity,
LEDLightEntity,
MediaPlayerEntity,
MicSettingEntity,
MuteSwitchEntity,
@@ -57,6 +61,7 @@ from .entity import (
WakeWord2SensitivityNumberEntity,
)
from .models import AvailableWakeWord, ServerState, WakeWordType
from .peripheral_api import LVAEvent
from .util import call_all
_LOGGER = logging.getLogger(__name__)
@@ -88,19 +93,19 @@ class VoiceSatelliteProtocol(APIServer):
if state.audio_input_channels >= 2:
self.supported_features |= VoiceAssistantFeature.MULTI_CHANNEL_AUDIO # pylint: disable=no-member
existing_mute_switches = [entity for entity in self.state.entities if isinstance(entity, MuteSwitchEntity)]
existing_media_players = [entity for entity in self.state.entities if isinstance(entity, MediaPlayerEntity)]
if existing_media_players:
# Keep the first instance and remove any extras.
self.state.media_player_entity = existing_media_players[0]
for extra in existing_media_players[1:]:
self.state.entities.remove(extra)
for extra_player in existing_media_players[1:]:
self.state.entities.remove(extra_player)
existing_mute_switches = [entity for entity in self.state.entities if isinstance(entity, MuteSwitchEntity)]
if existing_mute_switches:
self.state.mute_switch_entity = existing_mute_switches[0] # type: ignore
for extra in existing_mute_switches[1:]: # type: ignore
self.state.entities.remove(extra)
self.state.mute_switch_entity = existing_mute_switches[0]
for extra_mute in existing_mute_switches[1:]:
self.state.entities.remove(extra_mute)
if self.state.media_player_entity is None:
self.state.media_player_entity = MediaPlayerEntity(
@@ -143,9 +148,9 @@ class VoiceSatelliteProtocol(APIServer):
existing_thinking_sound_switches = [entity for entity in self.state.entities if isinstance(entity, ThinkingSoundEntity)]
if existing_thinking_sound_switches:
self.state.thinking_sound_entity = existing_thinking_sound_switches[0] # type: ignore
for extra in existing_thinking_sound_switches[1:]: # type: ignore
self.state.entities.remove(extra)
self.state.thinking_sound_entity = existing_thinking_sound_switches[0]
for extra_thinking in existing_thinking_sound_switches[1:]:
self.state.entities.remove(extra_thinking)
# Add/update thinking sound entity
thinking_sound_switch = self.state.thinking_sound_entity
@@ -163,7 +168,7 @@ class VoiceSatelliteProtocol(APIServer):
elif thinking_sound_switch not in self.state.entities:
self.state.entities.append(thinking_sound_switch)
# Load thinking sound enabled state from preferences (default to False if not set or unknown)
# Load thinking sound enabled state from preferences
if hasattr(self.state.preferences, "thinking_sound") and self.state.preferences.thinking_sound in (0, 1):
self.state.thinking_sound_enabled = bool(self.state.preferences.thinking_sound)
else:
@@ -317,6 +322,19 @@ class VoiceSatelliteProtocol(APIServer):
self.state.mic_volume_entity.update_get_value(lambda: float(self.state.mic_volume))
self.state.mic_volume_entity.update_set_value(lambda val: self.state.persist_mic_volume(float(val)))
# NOTE: ButtonEventSensorEntity is NOT created here unconditionally.
# It is only materialised when a peripheral sends the register_button
# command (see register_pending_button below), mirroring the same
# opt-in pattern used by register_light for LEDLightEntity.
# Materialise the Light entities peripherals registered before
# this satellite was constructed (or reattach existing ones).
self.register_pending_lights()
# Materialise ButtonEventSensorEntity if a peripheral already registered
# button support before this satellite was constructed (e.g. on an HA
# reconnect while the peripheral container stayed connected to LVA).
self.register_pending_button()
# ---- Instance variables ----
self._is_streaming_audio = False
@@ -330,6 +348,103 @@ class VoiceSatelliteProtocol(APIServer):
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
self._disconnect_event = asyncio.Event()
# ------------------------------------------------------------------
# Peripheral API helper
# ------------------------------------------------------------------
def _emit(
self,
event: LVAEvent,
data: Optional[Dict[str, Any]] = None,
) -> None:
"""
Emit a peripheral LED/button event.
Thread-safe: delegates to ``emit_event_sync`` which uses
``run_coroutine_threadsafe`` when called from outside the event loop.
"""
api = self.state.peripheral_api
if api is not None:
api.emit_event_sync(event, data)
def register_pending_lights(self) -> None:
"""Materialise LightEntities for peripheral registered lights.
Called from __init__ so entities exist by the time HA enumerates,
and again from the peripheral_api dispatcher when a light arrives
after the satellite is already running. HA only sees a late
registration after its next reconnect, but LVA stays consistent.
"""
for spec in self.state.pending_lights:
if spec.object_id in self.state.led_light_entities:
# Already materialised. Reattach the server in case the
# satellite has been reconstructed (HA reconnect).
self.state.led_light_entities[spec.object_id].server = self
if self.state.led_light_entities[spec.object_id] not in self.state.entities:
self.state.entities.append(self.state.led_light_entities[spec.object_id])
continue
object_id = spec.object_id
entity = LEDLightEntity(
server=self,
key=len(self.state.entities),
name=spec.name,
object_id=object_id,
icon=spec.icon,
effects=spec.effects,
supports_rgb=spec.supports_rgb,
supports_brightness=spec.supports_brightness,
on_changed=partial(self._on_led_light_changed, object_id),
)
self.state.entities.append(entity)
self.state.led_light_entities[object_id] = entity
def register_pending_button(self) -> None:
"""Materialise ButtonEventSensorEntity once a peripheral has registered button support.
Called from __init__ (handles HA reconnects where the peripheral container
stayed connected to LVA and pending_button is already True) and from
PeripheralAPIServer._register_button() when the command arrives at runtime.
Safe to call multiple times: idempotent — if the entity already exists
it is only reattached to the current satellite server instance.
"""
if not self.state.pending_button:
return
if self.state.button_event_sensor_entity is not None:
# Already materialised — reattach the server in case the satellite
# has been reconstructed for an HA reconnect.
self.state.button_event_sensor_entity.server = self
if self.state.button_event_sensor_entity not in self.state.entities:
self.state.entities.append(self.state.button_event_sensor_entity)
return
entity = ButtonEventSensorEntity(
server=self,
key=len(self.state.entities),
name="Button Press",
object_id="button_press_event",
)
self.state.entities.append(entity)
self.state.button_event_sensor_entity = entity
_LOGGER.info("Button event sensor entity materialised")
def _on_led_light_changed(self, object_id: str) -> None:
"""Forward an HA Light entity change to peripherals as light_command.
The event carries object_id so a peripheral that registered more
than one light can route it to the correct hardware.
"""
entity = self.state.led_light_entities.get(object_id)
if entity is None:
return
self._emit(LVAEvent.LIGHT_COMMAND, entity.state_dict())
# ------------------------------------------------------------------
# Mute / thinking sound
# ------------------------------------------------------------------
def _set_thinking_sound_enabled(self, new_state: bool) -> None:
self.state.thinking_sound_enabled = bool(new_state)
self.state.preferences.thinking_sound = 1 if self.state.thinking_sound_enabled else 0
@@ -370,6 +485,7 @@ class VoiceSatelliteProtocol(APIServer):
def _set_muted(self, new_state: bool) -> None:
self.state.muted = bool(new_state)
self._emit(LVAEvent.MUTED, {"muted": self.state.muted})
if self.state.muted:
# voice_assistant.stop behavior
@@ -377,14 +493,17 @@ class VoiceSatelliteProtocol(APIServer):
self._is_streaming_audio = False
self.state.tts_player.stop()
# Stop any ongoing voice processing
self.state.stop_word.is_active = False # type: ignore
self.state.stop_word.is_active = False # type: ignore[attr-defined]
self.state.tts_player.play(self.state.mute_sound)
else:
# voice_assistant.start_continuous behavior
_LOGGER.debug("Unmuting voice assistant (voice_assistant.start_continuous)")
self.state.tts_player.play(self.state.unmute_sound)
# Resume normal operation - wake word detection will be active again
pass
self._emit(LVAEvent.IDLE)
# ------------------------------------------------------------------
# Voice pipeline event handler
# ------------------------------------------------------------------
def handle_voice_event(self, event_type: VoiceAssistantEventType, data: Dict[str, str]) -> None:
_LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
@@ -394,30 +513,49 @@ class VoiceSatelliteProtocol(APIServer):
self._tts_played = False
self._continue_conversation = False
self._pipeline_active = True
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START and self.state.thinking_sound_enabled:
# Play short "thinking/processing" sound if configured
processing = getattr(self.state, "processing_sound", None)
if processing:
_LOGGER.debug("Playing processing sound: %s", processing)
self.state.stop_word.is_active = True # type: ignore
self._processing = True
self.duck()
self.state.tts_player.play(self.state.processing_sound)
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START:
self._emit(LVAEvent.THINKING)
# Play optional audible thinking sound
if self.state.thinking_sound_enabled:
processing = getattr(self.state, "processing_sound", None)
if processing:
_LOGGER.debug("Playing processing sound: %s", processing)
self.state.stop_word.is_active = True # type: ignore[attr-defined]
self._processing = True
self.duck()
self.state.tts_player.play(self.state.processing_sound)
elif event_type in (
VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END,
VoiceAssistantEventType.VOICE_ASSISTANT_STT_END,
):
self._is_streaming_audio = False
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
stt_text = data.get("text", "").strip()
if stt_text:
self._emit(LVAEvent.STT_TEXT, {"text": stt_text})
_LOGGER.debug("STT transcript: %s", stt_text)
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS:
if data.get("tts_start_streaming") == "1":
# Start streaming early
self.play_tts()
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
if data.get("continue_conversation") == "1":
self._continue_conversation = True
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
tts_text = data.get("text", "").strip()
if tts_text:
self._emit(LVAEvent.TTS_TEXT, {"text": tts_text})
_LOGGER.debug("TTS response text: %s", tts_text)
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
self._tts_url = data.get("url")
self.play_tts()
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
self._is_streaming_audio = False
if not self._tts_played:
@@ -429,7 +567,12 @@ class VoiceSatelliteProtocol(APIServer):
self._tts_played = False
# TODO: handle error
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
self._emit(LVAEvent.PIPELINE_ERROR)
# ------------------------------------------------------------------
# Timer event handler
# ------------------------------------------------------------------
def handle_timer_event(
self,
@@ -437,15 +580,40 @@ class VoiceSatelliteProtocol(APIServer):
msg: VoiceAssistantTimerEventResponse,
) -> None:
_LOGGER.debug("Timer event: type=%s", event_type.name)
if event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED:
# Build countdown data from the protobuf message fields.
# total_seconds: the original timer duration.
# seconds_left: remaining seconds at the time of this event.
timer_data = {
"id": msg.timer_id,
"name": msg.name,
"total_seconds": msg.total_seconds,
"seconds_left": msg.seconds_left,
}
if event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED:
self._emit(LVAEvent.TIMER_TICKING, timer_data)
elif event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_UPDATED:
self._emit(LVAEvent.TIMER_UPDATED, timer_data)
elif event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_CANCELLED:
self._emit(LVAEvent.IDLE)
elif event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED:
if not self._timer_finished:
self.state.active_wake_words.add(self.state.stop_word.id)
self._timer_finished = True
self._timer_ring_start = time.monotonic()
self.duck()
self._emit(LVAEvent.TIMER_RINGING, timer_data)
self._play_timer_finished()
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
# ------------------------------------------------------------------
# Message routing
# ------------------------------------------------------------------
def handle_message(self, msg: message.Message) -> Iterable[message.Message]: # noqa: C901 (acceptable complexity for a message router)
if isinstance(msg, VoiceAssistantEventResponse):
# Pipeline event
data: Dict[str, str] = {}
@@ -453,7 +621,7 @@ class VoiceSatelliteProtocol(APIServer):
data[arg.name] = arg.value
self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data)
# assist_satellite.announce HERE
elif isinstance(msg, VoiceAssistantAnnounceRequest):
_LOGGER.debug("Announcing: %s", msg.text)
@@ -462,16 +630,18 @@ class VoiceSatelliteProtocol(APIServer):
urls = []
if msg.preannounce_media_id:
urls.append(msg.preannounce_media_id)
urls.append(msg.media_id)
self.state.active_wake_words.add(self.state.stop_word.id)
self._continue_conversation = msg.start_conversation
self.duck()
self._emit(LVAEvent.TTS_SPEAKING)
self.state.tts_player.play(urls, done_callback=self._tts_finished)
elif isinstance(msg, VoiceAssistantTimerEventResponse):
self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg)
elif isinstance(msg, DeviceInfoRequest):
_LOGGER.debug("Device info request")
@@ -487,15 +657,25 @@ class VoiceSatelliteProtocol(APIServer):
model="Linux Voice Assistant",
voice_assistant_feature_flags=self.supported_features,
)
elif isinstance(
msg,
(ListEntitiesRequest, SubscribeHomeAssistantStatesRequest, MediaPlayerCommandRequest, SwitchCommandRequest, NumberCommandRequest, SelectCommandRequest),
(ListEntitiesRequest, SubscribeHomeAssistantStatesRequest, MediaPlayerCommandRequest, SwitchCommandRequest, NumberCommandRequest, SelectCommandRequest, LightCommandRequest),
):
for entity in self.state.entities:
yield from entity.handle_message(msg)
# Emit peripheral event when background music starts playing.
# Announcements (TTS) are explicitly excluded — those are covered
# by TTS_SPEAKING.
if isinstance(msg, MediaPlayerCommandRequest) and msg.has_media_url:
is_announcement = msg.has_announcement and msg.announcement
if not is_announcement:
self._emit(LVAEvent.MEDIA_PLAYER_PLAYING)
if isinstance(msg, ListEntitiesRequest):
yield ListEntitiesDoneResponse()
elif isinstance(msg, VoiceAssistantConfigurationRequest):
_LOGGER.debug("✅ Received VoiceAssistantConfigurationRequest from Home Assistant")
_LOGGER.debug(" -> Request contains %d external wake words", len(msg.external_wake_words))
@@ -612,6 +792,10 @@ class VoiceSatelliteProtocol(APIServer):
self.state.save_preferences()
self.state.wake_words_changed = True
# ------------------------------------------------------------------
# Audio streaming
# ------------------------------------------------------------------
# handle_audio — both channels in ONE message
def handle_audio(self, audio_chunk: bytes, audio_chunk_2: Optional[bytes] = None) -> None:
if not self._is_streaming_audio or self.state.muted:
@@ -621,33 +805,11 @@ class VoiceSatelliteProtocol(APIServer):
else:
self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
# ------------------------------------------------------------------
# Wake word / stop
# ------------------------------------------------------------------
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
if self._timer_finished:
# Stop the ringing timer, then start a normal wake-up after a short
# delay so the transition doesn't feel abrupt.
self._timer_finished = False
self._timer_ring_start = None
self.state.active_wake_words.discard(self.state.stop_word.id)
self.unduck()
self.state.tts_player.stop()
_LOGGER.debug("Stopping timer finished sound; will wake up in 1 s")
wake_word_phrase = wake_word.wake_word # type: ignore
def _delayed_wakeup() -> None:
if self.state.muted or self._pipeline_active:
_LOGGER.debug("Delayed wakeup skipped (muted=%s, pipeline_active=%s)", self.state.muted, self._pipeline_active)
return
_LOGGER.debug("Delayed wakeup: playing wakeup sound for %s", wake_word_phrase)
self._pipeline_active = True
self.duck()
self.state.tts_player.play(
self.state.wakeup_sound,
done_callback=lambda: self._on_wakeup_sound_finished(wake_word_phrase),
)
threading.Timer(0.1, _delayed_wakeup).start()
return
if self.state.muted:
# Don't respond to wake words when muted (voice_assistant.stop behavior)
@@ -657,9 +819,14 @@ class VoiceSatelliteProtocol(APIServer):
_LOGGER.debug("Ignoring wake word - pipeline already active")
return
wake_word_phrase = wake_word.wake_word # type: ignore
wake_word_phrase = wake_word.wake_word # type: ignore[union-attr]
_LOGGER.debug("Detected wake word: %s", wake_word_phrase)
self._timer_finished = False
self._timer_ring_start = None
_LOGGER.debug("Stopping timer finished sound")
self._pipeline_active = True
self._emit(LVAEvent.WAKE_WORD_DETECTED)
self.duck()
self.state.tts_player.play(
self.state.wakeup_sound,
@@ -667,12 +834,48 @@ class VoiceSatelliteProtocol(APIServer):
)
def _on_wakeup_sound_finished(self, wake_word_phrase: str) -> None:
"""Callback invoked when the wakeup sound finishes playing."""
_LOGGER.debug("Wakeup sound finished, starting audio streaming with wake word: %s", wake_word_phrase)
self.send_messages(
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)],
"""Callback invoked when the wakeup chime finishes; begin STT streaming."""
_LOGGER.debug(
"Wakeup sound finished, starting audio streaming for: %s",
wake_word_phrase,
)
self.send_messages([VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)])
self._is_streaming_audio = True
self._emit(LVAEvent.LISTENING)
def start_listening(self) -> None:
"""
Manually start the voice pipeline from a button press.
Plays ``start_listening_sound`` first, then sends
``VoiceAssistantRequest`` and begins streaming audio — identical flow
to ``wakeup()`` but without a wake-word phrase and using the dedicated
button-press sound instead of the wake-word chime. Also stops ringing timer.
"""
if self.state.muted:
return
if self._pipeline_active:
_LOGGER.debug("Ignoring start_listening - pipeline already active")
return
_LOGGER.debug("Button start_listening triggered")
self._timer_finished = False
self._timer_ring_start = None
_LOGGER.debug("Stopping timer finished sound")
self._pipeline_active = True
self.duck()
self.state.tts_player.play(
self.state.start_listening_sound,
done_callback=self._on_start_listening_sound_finished,
)
def _on_start_listening_sound_finished(self) -> None:
"""Callback invoked when the start-listening chime finishes; begin STT streaming."""
_LOGGER.debug("Start-listening sound finished, starting audio streaming")
self.send_messages([VoiceAssistantRequest(start=True, wake_word_phrase="")])
self._is_streaming_audio = True
self._emit(LVAEvent.LISTENING)
def stop(self) -> None:
self.state.active_wake_words.discard(self.state.stop_word.id)
@@ -683,6 +886,7 @@ class VoiceSatelliteProtocol(APIServer):
self._timer_ring_start = None
self.unduck()
self.state.tts_player.stop()
self._emit(LVAEvent.IDLE)
_LOGGER.debug("Stopping timer finished sound")
else:
# tts_player.stop() invokes the done_callback (_tts_finished),
@@ -690,6 +894,10 @@ class VoiceSatelliteProtocol(APIServer):
self.state.tts_player.stop()
_LOGGER.debug("TTS response stopped manually")
# ------------------------------------------------------------------
# TTS
# ------------------------------------------------------------------
def play_tts(self) -> None:
if (not self._tts_url) or self._tts_played:
return
@@ -698,26 +906,21 @@ class VoiceSatelliteProtocol(APIServer):
_LOGGER.debug("Playing TTS response: %s", self._tts_url)
self.state.active_wake_words.add(self.state.stop_word.id)
self._emit(LVAEvent.TTS_SPEAKING)
self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished)
def duck(self) -> None:
_LOGGER.debug("Ducking music")
self.state.music_player.duck()
def unduck(self) -> None:
_LOGGER.debug("Unducking music")
self.state.music_player.unduck()
def _tts_finished(self) -> None:
self._pipeline_active = False
self.state.active_wake_words.discard(self.state.stop_word.id)
self.send_messages([VoiceAssistantAnnounceFinished()])
self._emit(LVAEvent.TTS_FINISHED)
if self._continue_conversation:
self._continue_conversation = False
# Keep pipeline active during the settle delay so the mic stays closed
# and does not capture the tail end of the TTS audio from the speaker.
self._pipeline_active = True
self._emit(LVAEvent.LISTENING)
_LOGGER.debug("Continuing conversation after %.2fs settle delay", self.state.continue_conversation_delay)
def _start_continued_conversation() -> None:
@@ -734,9 +937,26 @@ class VoiceSatelliteProtocol(APIServer):
else:
self._continue_conversation = False
self.unduck()
self._emit(LVAEvent.IDLE)
_LOGGER.debug("TTS response finished")
# ------------------------------------------------------------------
# Ducking
# ------------------------------------------------------------------
def duck(self) -> None:
_LOGGER.debug("Ducking music")
self.state.music_player.duck()
def unduck(self) -> None:
_LOGGER.debug("Unducking music")
self.state.music_player.unduck()
# ------------------------------------------------------------------
# Timer finished loop
# ------------------------------------------------------------------
def _play_timer_finished(self) -> None:
if not self._timer_finished:
_LOGGER.debug("Timer finished sound stopped")
@@ -767,7 +987,11 @@ class VoiceSatelliteProtocol(APIServer):
),
)
def connection_lost(self, exc):
# ------------------------------------------------------------------
# Connection lifecycle
# ------------------------------------------------------------------
def connection_lost(self, exc: Optional[Exception]) -> None:
super().connection_lost(exc)
self._disconnect_event.set()
@@ -781,15 +1005,15 @@ class VoiceSatelliteProtocol(APIServer):
# Stop any ongoing audio playback and wake/stop word processing.
try:
self.state.music_player.stop()
except Exception: # pragma: no cover - defensive safety net
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Failed to stop music player during disconnect")
try:
self.state.tts_player.stop()
except Exception: # pragma: no cover - defensive safety net
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Failed to stop TTS player during disconnect")
self.state.stop_word.is_active = False
self.state.stop_word.is_active = False # type: ignore[attr-defined]
self.state.connected = False
if self.state.satellite is self:
self.state.satellite = None
@@ -806,6 +1030,9 @@ class VoiceSatelliteProtocol(APIServer):
if self.state.mic_volume_entity is not None:
self.state.mic_volume_entity.sync_with_state()
# Notify peripheral container that HA is no longer reachable
self._emit(LVAEvent.DISCONNECTED)
_LOGGER.info("Disconnected from Home Assistant; waiting for reconnection")
def process_packet(self, msg_type: int, packet_data: bytes) -> None:
@@ -814,6 +1041,7 @@ class VoiceSatelliteProtocol(APIServer):
if msg_type == PROTO_TO_MESSAGE_TYPE[AuthenticationRequest]:
self.state.connected = True
_LOGGER.debug("Authentication successful, connected to Home Assistant")
# Send states after connect
states: List[message.Message] = []
_LOGGER.debug("Found %d entities in state", len(self.state.entities))
@@ -821,12 +1049,20 @@ class VoiceSatelliteProtocol(APIServer):
entity_states = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
states.extend(entity_states)
_LOGGER.debug("Entity %d (%s) returned %d state messages", i, type(entity).__name__, len(entity_states))
_LOGGER.debug("Total state messages to send: %d", len(states))
self.send_messages(states)
for i, msg in enumerate(states):
_LOGGER.debug("Sent state message %d: %s", i, type(msg).__name__)
_LOGGER.debug("All entity states sent after connect")
# Notify peripherals that Home Assistant is now connected
self._emit(LVAEvent.ZEROCONF, {"status": "connected"})
# ------------------------------------------------------------------
# External wake word download
# ------------------------------------------------------------------
def _download_external_wake_word(self, external_wake_word: VoiceAssistantExternalWakeWord) -> Optional[AvailableWakeWord]:
eww_dir = self.state.download_dir / "external_wake_words"
eww_dir.mkdir(parents=True, exist_ok=True)