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:
1 parent
e1770d071c
commit
2d460b6728
51 files changed
+9251
-117
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user