diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index e56abdd..29f93de 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -48,7 +48,7 @@ class MediaPlayers: tts: MpvMediaPlayer # ----------------------------------------------------------------------------- -# Mic Mute Handler +# Mic Mute / Preferences Handler # ----------------------------------------------------------------------------- class MicMuteHandler(EventHandler): @@ -73,18 +73,18 @@ class MicMuteHandler(EventHandler): is_muted = data.get("state", False) if self.state.mic_muted != is_muted: self.state.mic_muted = is_muted - + # Synchronize the threading event for the audio loop if is_muted: - self.state.mic_muted_event.clear() # Pauses audio thread + self.state.mic_muted_event.clear() # Pauses audio thread else: - self.state.mic_muted_event.set() # Resumes audio thread - + self.state.mic_muted_event.set() # Resumes audio thread + _LOGGER.debug("Mic muted = %s", is_muted) - + if self.mqtt_controller: self.mqtt_controller.publish_mute_state(is_muted) - + if is_muted: self.event_bus.publish("mic_muted") else: @@ -98,6 +98,45 @@ class MicMuteHandler(EventHandler): self.state.preferences.num_leds = num_leds self.state.save_preferences() + @subscribe + def set_alarm_duration(self, data: dict): + """ + Event handler to save alarm_duration_seconds to preferences. + + Expected payload from MQTT controller: + { "alarm_duration_seconds": } + + Semantics: + 0 -> infinite alarm (only stop via Stop wake word / wake word logic) + >0 -> auto-stop alarm after N seconds (plus Stop wake word support) + """ + duration = data.get("alarm_duration_seconds") + if duration is None: + return + + try: + duration = int(duration) + except (TypeError, ValueError): + _LOGGER.warning( + "Invalid alarm_duration_seconds value received: %r", duration + ) + return + + if duration < 0: + _LOGGER.warning( + "Negative alarm_duration_seconds (%d) is not allowed; ignoring", + duration, + ) + return + + current = getattr(self.state.preferences, "alarm_duration_seconds", 0) + if current != duration: + _LOGGER.debug( + "Updating alarm_duration_seconds: %s -> %s", current, duration + ) + self.state.preferences.alarm_duration_seconds = duration + self.state.save_preferences() + # ----------------------------------------------------------------------------- # Main Application # ----------------------------------------------------------------------------- @@ -105,13 +144,13 @@ class MicMuteHandler(EventHandler): async def main() -> None: # --- 1. Load Basics --- config, loop, event_bus = _init_basics() - + # --- 2. Load Preferences --- preferences = _load_preferences(config) # --- 3. Find Microphone --- mic = _get_microphone(config) - + # --- 4. Load Wake Words --- wake_word_data = _load_wake_words(config, preferences) @@ -120,17 +159,17 @@ async def main() -> None: # --- 6. Create Server State --- state = _create_server_state( - config, loop, event_bus, preferences, + config, loop, event_bus, preferences, wake_word_data, media_players ) # --- 7. Initialize Controllers --- _init_controllers(loop, event_bus, state, config, preferences) - + # --- 8. Start Audio Engine --- audio_engine = AudioEngine(state, mic, config.audio.input_block_size) audio_engine.start() - + # --- 9. Run Server --- try: await _run_server(state, config) @@ -138,7 +177,7 @@ async def main() -> None: # --- 10. Cleanup --- _LOGGER.debug("Shutting down...") audio_engine.stop() - + if hasattr(state, "mqtt_controller") and state.mqtt_controller: _LOGGER.debug("Stopping MQTT controller...") state.mqtt_controller.stop() @@ -166,7 +205,10 @@ def _init_basics() -> Tuple[Config, asyncio.AbstractEventLoop, EventBus]: for idx, mic in enumerate(sc.all_microphones(include_loopback=False)): print(f"[{idx}]", mic.name) except Exception as e: - _LOGGER.error("Error listing input devices (ensure audio backend is working): %s", e) + _LOGGER.error( + "Error listing input devices (ensure audio backend is working): %s", + e, + ) sys.exit(0) if args.list_output_devices: @@ -182,7 +224,7 @@ def _init_basics() -> Tuple[Config, asyncio.AbstractEventLoop, EventBus]: config_path = args.config if not config_path.is_absolute(): - config_path = _REPO_DIR / config_path + config_path = _REPO_DIR / config_path config = load_config_from_json(config_path) if args.debug: @@ -197,7 +239,7 @@ def _init_basics() -> Tuple[Config, asyncio.AbstractEventLoop, EventBus]: loop = asyncio.get_running_loop() event_bus = EventBus() - + return config, loop, event_bus def _load_preferences(config: Config) -> Preferences: @@ -209,8 +251,14 @@ def _load_preferences(config: Config) -> Preferences: preferences = Preferences(**preferences_dict) else: preferences = Preferences() - - preferences.num_leds = getattr(preferences, 'num_leds', config.led.num_leds) + + # Backwards-compatible defaults / migrations + preferences.num_leds = getattr(preferences, "num_leds", config.led.num_leds) + # New: default alarm_duration_seconds, 0 = infinite until Stop/wake word + preferences.alarm_duration_seconds = getattr( + preferences, "alarm_duration_seconds", 0 + ) + return preferences def _get_microphone(config: Config): @@ -224,11 +272,11 @@ def _get_microphone(config: Config): mic = sc.get_microphone(config.audio.input_device, include_loopback=False) else: mic = sc.default_microphone() - + if mic is None: _LOGGER.critical("No microphone found.") sys.exit(1) - + _LOGGER.info("Using audio input device: %s", mic.name) return mic @@ -236,7 +284,7 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData: """Loads all available and active wake word models.""" if not config.wake_word.directories: config.wake_word.directories = ["wakewords", "wakewords/openWakeWord"] - + download_dir = _REPO_DIR / config.wake_word.download_dir download_dir.mkdir(parents=True, exist_ok=True) @@ -254,7 +302,7 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData: with open(config_path, "r", encoding="utf-8") as f: model_config = json.load(f) model_type = WakeWordType(model_config["type"]) - + wake_word_path = ( config_path.parent / model_config["model"] if model_type == WakeWordType.OPEN_WAKE_WORD @@ -271,13 +319,13 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData: active: Set[str] = set() models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {} - + if preferences.active_wake_words: for ww_id in preferences.active_wake_words: if ww_id in available: models[ww_id] = available[ww_id].load() active.add(ww_id) - + if not models: ww_id = config.wake_word.model if ww_id in available: @@ -294,17 +342,21 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData: return WakeWordData(available, models, active, stop_model) -def _init_media_players(loop: asyncio.AbstractEventLoop, config: Config, preferences: Preferences) -> MediaPlayers: +def _init_media_players( + loop: asyncio.AbstractEventLoop, + config: Config, + preferences: Preferences, +) -> MediaPlayers: """Initializes the music and TTS media players.""" music_player = MpvMediaPlayer( loop=loop, device=config.audio.output_device, - initial_volume=preferences.volume_level + initial_volume=preferences.volume_level, ) tts_player = MpvMediaPlayer( loop=loop, device=config.audio.output_device, - initial_volume=preferences.volume_level + initial_volume=preferences.volume_level, ) return MediaPlayers(music=music_player, tts=tts_player) @@ -375,15 +427,17 @@ def _init_controllers( async def _run_server(state: ServerState, config: Config): """Starts the ESPHome server and ZeroConf discovery.""" server = await state.loop.create_server( - lambda: VoiceSatelliteProtocol(state), - host=config.esphome.host, - port=config.esphome.port + lambda: VoiceSatelliteProtocol(state), + host=config.esphome.host, + port=config.esphome.port, ) discovery = HomeAssistantZeroconf(port=config.esphome.port, name=config.app.name) await discovery.register_server() async with server: - _LOGGER.info("Server started (host=%s, port=%s)", config.esphome.host, config.esphome.port) + _LOGGER.info( + "Server started (host=%s, port=%s)", config.esphome.host, config.esphome.port + ) await server.serve_forever() diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py index 8ae13d6..857485e 100644 --- a/linux_voice_assistant/models.py +++ b/linux_voice_assistant/models.py @@ -51,7 +51,7 @@ class AvailableWakeWord: 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 @@ -64,6 +64,10 @@ class Preferences: active_wake_words: List[str] = field(default_factory=list) volume_level: float = 1.0 num_leds: int = 3 + # New: configurable alarm duration in seconds. + # 0 = infinite alarm (only Stop/wake word stops it) + # >0 = auto-stop alarm after this many seconds + alarm_duration_seconds: int = 0 @dataclass @@ -86,7 +90,7 @@ class ServerState: preferences_path: Path download_dir: Path preferences: Preferences - + # --- Fields WITH default values --- satellite: "Optional[VoiceSatelliteProtocol]" = None wake_words_changed: bool = False @@ -98,7 +102,7 @@ class ServerState: # set() = Mic is ON (Audio processing running) # clear() = Mic is OFF (Audio processing paused) mic_muted_event: threading.Event = field(default_factory=threading.Event) - + def __post_init__(self): """Ensure the threading event matches the boolean state on init.""" if not self.mic_muted: @@ -112,5 +116,8 @@ class ServerState: 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 + asdict(self.preferences), + preferences_file, + ensure_ascii=False, + indent=4, ) diff --git a/linux_voice_assistant/mqtt_controller.py b/linux_voice_assistant/mqtt_controller.py index 19dff7a..1be1c5d 100644 --- a/linux_voice_assistant/mqtt_controller.py +++ b/linux_voice_assistant/mqtt_controller.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) + class MqttController(EventHandler): def __init__( self, @@ -27,22 +28,39 @@ class MqttController(EventHandler): super().__init__(event_bus) self.loop = loop self.preferences = preferences - + self._host = config.host self._port = config.port self._username = config.username self._password = config.password - + self._device_name = app_name self._device_id = app_name.lower().replace(" ", "_") self._topic_prefix = f"lva/{self._device_id}" - - self._is_muted = False # Internal state - self.CONFIGURABLE_STATES: List[str] = ["idle", "listening", "thinking", "responding", "error"] + self._is_muted = False # Internal state + + self.CONFIGURABLE_STATES: List[str] = [ + "idle", + "listening", + "thinking", + "responding", + "error", + ] self.topics = { - "mute": { "command": f"{self._topic_prefix}/mute/set", "state": f"{self._topic_prefix}/mute/state" }, - "num_leds": { "command": f"{self._topic_prefix}/num_leds/set", "state": f"{self._topic_prefix}/num_leds/state" } + "mute": { + "command": f"{self._topic_prefix}/mute/set", + "state": f"{self._topic_prefix}/mute/state", + }, + "num_leds": { + "command": f"{self._topic_prefix}/num_leds/set", + "state": f"{self._topic_prefix}/num_leds/state", + }, + # New: alarm duration + "alarm_duration": { + "command": f"{self._topic_prefix}/alarm_duration/set", + "state": f"{self._topic_prefix}/alarm_duration/state", + }, } for state_name in self.CONFIGURABLE_STATES: self.topics[state_name] = { @@ -51,7 +69,7 @@ class MqttController(EventHandler): "light_command": f"{self._topic_prefix}/{state_name}_light/set", "light_state": f"{self._topic_prefix}/{state_name}_light/state", } - + self._client = mqtt.Client() self._client.on_connect = self._on_connect self._client.on_message = self._on_message @@ -63,14 +81,18 @@ class MqttController(EventHandler): try: if self._username: self._client.username_pw_set(self._username, self._password) - _LOGGER.debug("Connecting to MQTT broker at %s:%s", self._host, self._port) + _LOGGER.debug( + "Connecting to MQTT broker at %s:%s", self._host, self._port + ) self._client.connect(self._host, self._port, 60) self._client.loop_start() except Exception: _LOGGER.exception("Failed to connect to MQTT broker") def stop(self): - self._client.publish(f"{self._topic_prefix}/availability", "offline", retain=True) + self._client.publish( + f"{self._topic_prefix}/availability", "offline", retain=True + ) self._client.loop_stop() self._client.disconnect() _LOGGER.debug("Disconnected from MQTT broker") @@ -79,7 +101,7 @@ class MqttController(EventHandler): if rc == 0: _LOGGER.info("Connected to MQTT broker") client.subscribe(f"{self._topic_prefix}/+/set") - client.subscribe(f"{self._topic_prefix}/+/state") + client.subscribe(f"{self._topic_prefix}/+/state") self._publish_discovery_configs() else: _LOGGER.error("Failed to connect to MQTT, return code %d", rc) @@ -88,8 +110,12 @@ class MqttController(EventHandler): try: topic = msg.topic payload = msg.payload.decode() - _LOGGER.debug("Received MQTT message on topic %s (from MQTT thread)", msg.topic) - self.loop.call_soon_threadsafe(self._handle_message_on_loop, topic, payload) + _LOGGER.debug( + "Received MQTT message on topic %s (from MQTT thread)", msg.topic + ) + self.loop.call_soon_threadsafe( + self._handle_message_on_loop, topic, payload + ) except Exception: _LOGGER.exception("Error in _on_message") @@ -98,101 +124,254 @@ class MqttController(EventHandler): if topic == self.topics["mute"]["command"]: # Publish event for MicMuteHandler to process - self.event_bus.publish("set_mic_mute", {"state": payload.upper() == "ON"}) - + self.event_bus.publish( + "set_mic_mute", {"state": payload.upper() == "ON"} + ) + elif topic == self.topics["num_leds"]["command"]: try: num_leds = int(payload) # Publish event for LedController and MicMuteHandler (to save) self.event_bus.publish("set_num_leds", {"num_leds": num_leds}) self.publish_num_leds_state(num_leds) - except ValueError: pass - + except ValueError: + pass + + elif topic == self.topics["alarm_duration"]["command"]: + try: + duration = int(payload) + if duration < 0: + raise ValueError + # Publish event for MicMuteHandler (to save preferences) + self.event_bus.publish( + "set_alarm_duration", + {"alarm_duration_seconds": duration}, + ) + self.publish_alarm_duration_state(duration) + except ValueError: + _LOGGER.warning( + "Invalid alarm_duration payload received: %r", payload + ) + for state_name in self.CONFIGURABLE_STATES: state_topics = self.topics[state_name] if topic == state_topics["effect_command"]: effect_id = payload.lower().replace(" ", "_") - self.event_bus.publish(f"set_{state_name}_effect", {"effect": effect_id}) - + self.event_bus.publish( + f"set_{state_name}_effect", {"effect": effect_id} + ) + elif topic == state_topics["light_command"]: try: data = json.loads(payload) if "state" in data: if data["state"].upper() == "OFF": - self.event_bus.publish(f"set_{state_name}_effect", {"effect": "off"}) - else: # ON command + self.event_bus.publish( + f"set_{state_name}_effect", {"effect": "off"} + ) + else: # ON command self.event_bus.publish(f"turn_on_{state_name}") - + if "color" in data or "brightness" in data: - self.event_bus.publish(f"set_{state_name}_color", data) - except json.JSONDecodeError: pass - + self.event_bus.publish( + f"set_{state_name}_color", data + ) + except json.JSONDecodeError: + pass + elif topic == state_topics["effect_state"]: effect_id = payload.lower().replace(" ", "_") - self.event_bus.publish(f"set_{state_name}_effect", {"effect": effect_id, "retained": True}) + self.event_bus.publish( + f"set_{state_name}_effect", + {"effect": effect_id, "retained": True}, + ) elif topic == state_topics["light_state"]: try: data = json.loads(payload) data["retained"] = True - self.event_bus.publish(f"set_{state_name}_color", data) - except json.JSONDecodeError: pass + self.event_bus.publish( + f"set_{state_name}_color", data + ) + except json.JSONDecodeError: + pass def _publish_discovery_configs(self): availability_topic = f"{self._topic_prefix}/availability" - device_info = { "identifiers": [self._device_id], "name": self._device_name, "manufacturer": "LVA Project" } - options = ["Off", "Solid", "Slow Pulse", "Medium Pulse", "Fast Pulse", "Slow Blink", "Medium Blink", "Fast Blink", "Spin"] + device_info = { + "identifiers": [self._device_id], + "name": self._device_name, + "manufacturer": "LVA Project", + } + options = [ + "Off", + "Solid", + "Slow Pulse", + "Medium Pulse", + "Fast Pulse", + "Slow Blink", + "Medium Blink", + "Fast Blink", + "Spin", + ] - mute_cfg = { "name": "Mute Microphone", "unique_id": f"{self._device_id}_mute", "command_topic": self.topics["mute"]["command"], "state_topic": self.topics["mute"]["state"], "availability_topic": availability_topic, "icon": "mdi:microphone-off", "device": device_info } - self._client.publish(f"homeassistant/switch/{self._device_id}_mute/config", json.dumps(mute_cfg), retain=True) + mute_cfg = { + "name": "Mute Microphone", + "unique_id": f"{self._device_id}_mute", + "command_topic": self.topics["mute"]["command"], + "state_topic": self.topics["mute"]["state"], + "availability_topic": availability_topic, + "icon": "mdi:microphone-off", + "device": device_info, + } + self._client.publish( + f"homeassistant/switch/{self._device_id}_mute/config", + json.dumps(mute_cfg), + retain=True, + ) - num_leds_cfg = { "name": "Number of LEDs", "unique_id": f"{self._device_id}_num_leds", "command_topic": self.topics["num_leds"]["command"], "state_topic": self.topics["num_leds"]["state"], "availability_topic": availability_topic, "min": 1, "max": 256, "step": 1, "icon": "mdi:counter", "device": device_info, "mode": "box", "entity_category": "config" } - self._client.publish(f"homeassistant/number/{self._device_id}_num_leds/config", json.dumps(num_leds_cfg), retain=True) + num_leds_cfg = { + "name": "Number of LEDs", + "unique_id": f"{self._device_id}_num_leds", + "command_topic": self.topics["num_leds"]["command"], + "state_topic": self.topics["num_leds"]["state"], + "availability_topic": availability_topic, + "min": 1, + "max": 256, + "step": 1, + "icon": "mdi:counter", + "device": device_info, + "mode": "box", + "entity_category": "config", + } + self._client.publish( + f"homeassistant/number/{self._device_id}_num_leds/config", + json.dumps(num_leds_cfg), + retain=True, + ) + + # New: Alarm Duration number (seconds) + alarm_duration_cfg = { + "name": "Alarm Duration", + "unique_id": f"{self._device_id}_alarm_duration", + "command_topic": self.topics["alarm_duration"]["command"], + "state_topic": self.topics["alarm_duration"]["state"], + "availability_topic": availability_topic, + "min": 0, + "max": 3600, + "step": 5, + "unit_of_measurement": "s", + "icon": "mdi:timer", + "device": device_info, + "mode": "box", + "entity_category": "config", + } + self._client.publish( + f"homeassistant/number/{self._device_id}_alarm_duration/config", + json.dumps(alarm_duration_cfg), + retain=True, + ) for state_name in self.CONFIGURABLE_STATES: capital_name = state_name.title() - - select_cfg = { "name": f"{capital_name} Effect", "unique_id": f"{self._device_id}_{state_name}_effect", "command_topic": self.topics[state_name]["effect_command"], "state_topic": self.topics[state_name]["effect_state"], "availability_topic": availability_topic, "options": options, "icon": "mdi:palette-swatch-variant", "device": device_info, "entity_category": "config" } - self._client.publish(f"homeassistant/select/{self._device_id}_{state_name}_effect/config", json.dumps(select_cfg), retain=True) - light_cfg = { "name": f"{capital_name} Color", "unique_id": f"{self._device_id}_{state_name}_color", "schema": "json", "command_topic": self.topics[state_name]["light_command"], "state_topic": self.topics[state_name]["light_state"], "availability_topic": availability_topic, "brightness": True, "color_mode": True, "supported_color_modes": ["rgb"], "device": device_info, "entity_category": "config" } - self._client.publish(f"homeassistant/light/{self._device_id}_{state_name}_color/config", json.dumps(light_cfg), retain=True) + select_cfg = { + "name": f"{capital_name} Effect", + "unique_id": f"{self._device_id}_{state_name}_effect", + "command_topic": self.topics[state_name]["effect_command"], + "state_topic": self.topics[state_name]["effect_state"], + "availability_topic": availability_topic, + "options": options, + "icon": "mdi:palette-swatch-variant", + "device": device_info, + "entity_category": "config", + } + self._client.publish( + f"homeassistant/select/{self._device_id}_{state_name}_effect/config", + json.dumps(select_cfg), + retain=True, + ) + + light_cfg = { + "name": f"{capital_name} Color", + "unique_id": f"{self._device_id}_{state_name}_color", + "schema": "json", + "command_topic": self.topics[state_name]["light_command"], + "state_topic": self.topics[state_name]["light_state"], + "availability_topic": availability_topic, + "brightness": True, + "color_mode": True, + "supported_color_modes": ["rgb"], + "device": device_info, + "entity_category": "config", + } + self._client.publish( + f"homeassistant/light/{self._device_id}_{state_name}_color/config", + json.dumps(light_cfg), + retain=True, + ) _LOGGER.debug("Published all MQTT discovery configs") self._client.publish(availability_topic, "online", retain=True) - + # Publish initial state from internal values self.publish_mute_state(self._is_muted) self.publish_num_leds_state(self.preferences.num_leds) + self.publish_alarm_duration_state( + getattr(self.preferences, "alarm_duration_seconds", 0) + ) def publish_mute_state(self, is_muted: bool): self._is_muted = is_muted - self._client.publish(self.topics["mute"]["state"], "ON" if is_muted else "OFF", retain=True) - + self._client.publish( + self.topics["mute"]["state"], + "ON" if is_muted else "OFF", + retain=True, + ) + def publish_num_leds_state(self, num_leds: int): - self._client.publish(self.topics["num_leds"]["state"], str(num_leds), retain=True) + self._client.publish( + self.topics["num_leds"]["state"], str(num_leds), retain=True + ) + + def publish_alarm_duration_state(self, duration_seconds: int): + self._client.publish( + self.topics["alarm_duration"]["state"], + str(int(duration_seconds)), + retain=True, + ) @subscribe def publish_state_to_mqtt(self, data: dict): state_name = data.get("state_name") if state_name in self.topics: state_topics = self.topics[state_name] - + effect_name = data.get("effect", "off").replace("_", " ").title() - self._client.publish(state_topics["effect_state"], effect_name, retain=True) + self._client.publish( + state_topics["effect_state"], effect_name, retain=True + ) light_state = { "state": "ON" if data.get("effect") != "off" else "OFF", "brightness": int(data.get("brightness", 0.5) * 255), - "color": { "r": data.get("color")[0], "g": data.get("color")[1], "b": data.get("color")[2] } + "color": { + "r": data.get("color")[0], + "g": data.get("color")[1], + "b": data.get("color")[2], + }, } - self._client.publish(state_topics["light_state"], json.dumps(light_state), retain=True) - + self._client.publish( + state_topics["light_state"], + json.dumps(light_state), + retain=True, + ) + # --- Listen for state changes from other components --- - + @subscribe def mic_muted(self, data: dict): self.publish_mute_state(True) - + @subscribe def mic_unmuted(self, data: dict): self.publish_mute_state(False) diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py index 1f0d28a..9ddd34d 100644 --- a/linux_voice_assistant/satellite.py +++ b/linux_voice_assistant/satellite.py @@ -116,6 +116,9 @@ class VoiceSatelliteProtocol(APIServer): # External wake words announced by Home Assistant self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {} + # Timer alarm auto-stop handle + self._timer_auto_stop_handle: Optional[asyncio.TimerHandle] = None + # ------------------------------------------------------------------------- # State machine helpers # ------------------------------------------------------------------------- @@ -225,6 +228,21 @@ class VoiceSatelliteProtocol(APIServer): self.duck() self._play_timer_finished() + # Schedule auto-stop if configured + duration = getattr( + self.state.preferences, "alarm_duration_seconds", 0 + ) + if duration > 0: + if self._timer_auto_stop_handle is not None: + self._timer_auto_stop_handle.cancel() + _LOGGER.debug( + "Scheduling auto-stop for timer alarm after %s seconds", + duration, + ) + self._timer_auto_stop_handle = self.state.loop.call_later( + duration, self._auto_stop_timer_alarm + ) + # ------------------------------------------------------------------------- # Main message handler (called by APIServer) # ------------------------------------------------------------------------- @@ -384,7 +402,9 @@ class VoiceSatelliteProtocol(APIServer): continue # Await the non-blocking download - model_info = await self._download_external_wake_word(external_wake_word) + model_info = await self._download_external_wake_word( + external_wake_word + ) if not model_info: continue @@ -415,14 +435,58 @@ class VoiceSatelliteProtocol(APIServer): if self._is_streaming_audio: self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) - def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None: - if self._state not in (SatelliteState.IDLE, SatelliteState.STARTING): + def _clear_timer_auto_stop(self) -> None: + """Cancel any pending auto-stop for the timer alarm.""" + if self._timer_auto_stop_handle is not None: + try: + self._timer_auto_stop_handle.cancel() + except Exception: + _LOGGER.exception("Failed to cancel timer auto-stop handle") + finally: + self._timer_auto_stop_handle = None + + def _stop_timer_alarm(self, reason: str) -> None: + """ + Stop the repeating timer-finished alarm sound and clean up flags. + + This is used for: + - Stop wake word / explicit stop() + - Any wake word while timer is ringing (existing behavior) + - Auto-stop after alarm_duration_seconds + """ + if not self._timer_finished: return - if self._timer_finished: - self._timer_finished = False + _LOGGER.debug("Stopping timer finished sound (%s)", reason) + self._timer_finished = False + self._clear_timer_auto_stop() + try: self.state.tts_player.stop() - _LOGGER.debug("Stopping timer finished sound") + except Exception: + _LOGGER.exception("Error stopping timer finished TTS player") + # Ensure we unduck and remove the stop word from active set + self.unduck() + self.state.active_wake_words.discard(self.state.stop_word.id) + + def _auto_stop_timer_alarm(self) -> None: + """Auto-stop callback fired after alarm_duration_seconds.""" + if not self._timer_finished: + return + duration = getattr(self.state.preferences, "alarm_duration_seconds", 0) + _LOGGER.debug( + "Auto-stopping timer finished alarm after %s seconds", duration + ) + self._stop_timer_alarm("auto_timeout") + + def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None: + if self._state not in (SatelliteState.IDLE, SatelliteState.STARTING): + # Existing behavior: ignore wakeup in other states. + return + + # If a timer alarm is currently ringing, stop it instead of starting + # a new conversation run. + if self._timer_finished: + self._stop_timer_alarm("wakeup") return wake_word_phrase = getattr(wake_word, "wake_word", "wake word") @@ -435,14 +499,25 @@ class VoiceSatelliteProtocol(APIServer): self.state.tts_player.play(self.state.wakeup_sound) def stop(self) -> None: + """ + Called when the Stop wake word is detected (or equivalent). + + For timer alarms: + - Stop the repeating alarm. + For TTS: + - Stop playback and treat as user-aborted response. + """ self.state.active_wake_words.discard(self.state.stop_word.id) - self.state.tts_player.stop() + + # If the timer alarm is ringing, stop that instead of a TTS run. if self._timer_finished: - self._timer_finished = False - _LOGGER.debug("Stopping timer finished sound") - else: - _LOGGER.debug("TTS response stopped manually") - self._tts_finished() + self._stop_timer_alarm("stop_wake_word") + return + + # Otherwise this is stopping a TTS response. + self.state.tts_player.stop() + _LOGGER.debug("TTS response stopped manually") + self._tts_finished() def play_tts(self) -> None: if not self._tts_url: @@ -487,7 +562,13 @@ class VoiceSatelliteProtocol(APIServer): self._set_state(SatelliteState.THINKING) def _play_timer_finished(self) -> None: + """ + Play the timer-finished sound in a loop until either: + - _timer_finished is cleared (Stop/wakeup/auto-timeout), or + - alarm_duration_seconds == 0 and user explicitly stops it. + """ if not self._timer_finished: + # Alarm has been cleared; restore audio state. self.unduck() return self.state.tts_player.play( @@ -507,7 +588,9 @@ class VoiceSatelliteProtocol(APIServer): """Wrapper to run the blocking download in a thread executor.""" return await self.state.loop.run_in_executor( None, - functools.partial(self._download_external_wake_word_sync, external_wake_word), + functools.partial( + self._download_external_wake_word_sync, external_wake_word + ), ) def _download_external_wake_word_sync( @@ -558,7 +641,9 @@ class VoiceSatelliteProtocol(APIServer): if should_download_model: parsed_url = urlparse(external_wake_word.url) parsed_url = parsed_url._replace( - path=posixpath.join(posixpath.dirname(parsed_url.path), model_path.name) + path=posixpath.join( + posixpath.dirname(parsed_url.path), model_path.name + ) ) model_url = urlunparse(parsed_url)