"""Shared models.""" import json import logging from collections.abc import Iterable from dataclasses import asdict, dataclass, field from enum import Enum from pathlib import Path from queue import Queue from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union if TYPE_CHECKING: from google.protobuf import message from pymicro_wakeword import MicroWakeWord from pyopen_wakeword import OpenWakeWord from .entity import ( ButtonEventSensorEntity, ESPHomeEntity, LEDLightEntity, MediaPlayerEntity, MicSettingEntity, MuteSwitchEntity, StopWordSensitivityNumberEntity, ThinkingSoundEntity, WakeWord1SensitivityNumberEntity, WakeWord2SensitivityNumberEntity, ) from .mpv_player import MpvMediaPlayer from .satellite import VoiceSatelliteProtocol _LOGGER = logging.getLogger(__name__) class WakeWordType(str, Enum): MICRO_WAKE_WORD = "micro" OPEN_WAKE_WORD = "openWakeWord" @dataclass class AvailableWakeWord: id: str type: WakeWordType wake_word: str trained_languages: List[str] wake_word_path: Path probability_cutoff: float = 0.7 def load(self) -> "Union[MicroWakeWord, OpenWakeWord]": if self.type == WakeWordType.MICRO_WAKE_WORD: from pymicro_wakeword import MicroWakeWord return MicroWakeWord.from_config(config_path=self.wake_word_path) if self.type == WakeWordType.OPEN_WAKE_WORD: from pyopen_wakeword import OpenWakeWord oww_model = OpenWakeWord.from_model(model_path=self.wake_word_path) setattr(oww_model, "wake_word", self.wake_word) return oww_model 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) volume: Optional[float] = None thinking_sound: int = 0 # 0 = disabled, 1 = enabled wake_word_1_sensitivity: Optional[float] = None wake_word_2_sensitivity: Optional[float] = None stop_word_sensitivity: Optional[float] = None mic_auto_gain: int = 0 mic_noise_suppression: int = 0 mic_volume: int = 100 # 1–100, default maximum @dataclass class ServerState: name: str friendly_name: str mac_address: str ip_address: str network_interface: str version: str esphome_version: str audio_queue: "Queue[Optional[bytes]]" entities: "List[ESPHomeEntity]" available_wake_words: "Dict[str, AvailableWakeWord]" wake_words: "Dict[str, Union[MicroWakeWord, OpenWakeWord]]" active_wake_words: Set[str] stop_word: "MicroWakeWord" 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 continue_conversation_delay: float = 0.5 # seconds to wait after TTS before opening mic media_player_entity: "Optional[MediaPlayerEntity]" = None satellite: "Optional[VoiceSatelliteProtocol]" = None connections: "List[VoiceSatelliteProtocol]" = field(default_factory=list) 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 mic_gain_entity: "Optional[MicSettingEntity]" = None mic_noise_suppression_entity: "Optional[MicSettingEntity]" = None mic_volume_entity: "Optional[MicSettingEntity]" = None wake_words_changed: bool = False refractory_seconds: float = 2.0 thinking_sound_enabled: bool = False output_only: bool = False muted: bool = False connected: bool = False volume: float = 1.0 oww_probability_cutoff: float = 0.7 # Dynamic threshold for OpenWakeWord oww_second_probability_cutoff: float = 0.7 # Dynamic threshold for second OpenWakeWord oww_stop_probability_cutoff: float = 0.5 # Dynamic threshold for Stop word wake_word_1_threshold: float = 0.7 wake_word_2_threshold: float = 0.7 stop_word_threshold: float = 0.5 mic_auto_gain: int = 0 mic_noise_suppression: int = 0 mic_volume: int = 100 # 1–100, default maximum audio_input_channels: int = 2 # number of mic channels to stream timer_max_ring_seconds: float = 900.0 listen_during_wake_sound: bool = False def broadcast(self, msgs: "Iterable[message.Message]") -> None: """Send messages to every connected API client. Entity state changes that happen asynchronously (not in response to a request) must reach *all* subscribed clients, not just whichever connection happens to be referenced by an entity's ``server``. Without this fan-out a second API client leaves Home Assistant stuck on a stale state (e.g. ``playing`` after playback has finished). """ messages = list(msgs) if not messages: return for connection in list(self.connections): connection.send_messages(messages) def save_preferences(self) -> None: """Save preferences as JSON.""" _LOGGER.debug("Saving preferences: %s", self.preferences_path) self.preferences_path.parent.mkdir(parents=True, exist_ok=True) with open(self.preferences_path, "w", encoding="utf-8") as preferences_file: json.dump( asdict(self.preferences), preferences_file, ensure_ascii=False, indent=4, ) def persist_volume(self, volume: float) -> None: """Persist the normalized media volume (0.0 - 1.0).""" clamped_volume = max(0.0, min(1.0, volume)) _LOGGER.debug( "persist_volume called: new=%s, current=%s, prefs=%s", clamped_volume, self.volume, self.preferences.volume, ) if abs(self.volume - clamped_volume) < 0.0001 and self.preferences.volume is not None and abs(self.preferences.volume - clamped_volume) < 0.0001: _LOGGER.debug("Skipping save - volume unchanged") return previous_muted = self.volume == 0.0 self.volume = clamped_volume self.preferences.volume = clamped_volume _LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path) 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)}) new_muted = clamped_volume == 0.0 if previous_muted != new_muted: api.emit_event_sync(LVAEvent.VOLUME_MUTED, {"muted": new_muted}) def persist_mic_gain(self, gain: float) -> None: """Persist the microphone auto gain value.""" gain_int = int(gain) if self.mic_auto_gain == gain_int and self.preferences.mic_auto_gain == gain_int: return self.mic_auto_gain = gain_int self.preferences.mic_auto_gain = gain_int self.save_preferences() def persist_mic_noise(self, noise: float) -> None: """Persist the microphone noise suppression value.""" noise_int = int(noise) if self.mic_noise_suppression == noise_int and self.preferences.mic_noise_suppression == noise_int: return self.mic_noise_suppression = noise_int self.preferences.mic_noise_suppression = noise_int self.save_preferences() def persist_mic_volume(self, volume: float) -> None: """Persist the microphone input volume (0–100).""" volume_int = max(1, min(100, int(round(volume)))) if self.mic_volume == volume_int and self.preferences.mic_volume == volume_int: return self.mic_volume = volume_int self.preferences.mic_volume = volume_int _LOGGER.info("Saving mic_volume %s to %s", volume_int, self.preferences_path) self.save_preferences()