* Restore stop word sensitivity from preferences on startup The Home Assistant number entity persists preferences.stop_word_sensitivity whenever it changes, but nothing ever read it back. main() did not pass stop_word_threshold when constructing ServerState, so it always took the dataclass default of 0.5, and lowering the sensitivity in Home Assistant silently reverted at the next restart. volume and mic_volume are restored at startup, and the two wake word sensitivities are restored when wake models load. Only the stop word was missed, so the block that syncs all three sensitivity entities back to Home Assistant was pushing a default it had never loaded. Restored alongside volume and clamped the same way. The resolution lives in a small helper so it can be covered by tests. * Move the threshold helper to models.py so tests do not import __main__ CI has no PulseAudio daemon. Importing linux_voice_assistant.__main__ pulls in `import soundcard as sc`, and soundcard/pulseaudio.py instantiates _PulseAudio() at module scope, which asserts on a live connection. So the four new tests failed on the runner while passing locally, where a daemon happens to be running. That is also why no other test in the suite imports __main__, and why the existing tests in test_main.py re-implement the logic they check rather than calling it. initial_stop_word_threshold() now lives in models.py, which owns both fields it resolves between: Preferences.stop_word_sensitivity and ServerState.stop_word_threshold. It imports cleanly, test_models.py already imports it, and the reference to the ServerState default becomes local. Behaviour is unchanged. Verified by reproducing the CI environment locally with PULSE_SERVER pointed at a nonexistent socket: 4 failed before, 298 pass after. --------- Co-authored-by: ryan felder <7475484+ryanfelder@users.noreply.github.com> Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com>
279 lines
10 KiB
Python
279 lines
10 KiB
Python
"""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()
|
||
|
||
|
||
def initial_stop_word_threshold(saved_sensitivity: Optional[float]) -> float:
|
||
"""
|
||
Resolve the stop word probability cutoff to start from, clamped to 0.0-1.0.
|
||
:param saved_sensitivity: Value persisted in preferences, or None if it has never been set.
|
||
"""
|
||
if saved_sensitivity is None:
|
||
return ServerState.stop_word_threshold
|
||
|
||
return max(0.0, min(1.0, float(saved_sensitivity)))
|