Merge pull request #43 from imonlinux/implement-alarm-play-duration

Added a new user preference: alarm_duration_seconds (0 = infinite / Stop-only, >0 = auto-stop after N seconds).

Exposed that preference via MQTT/HA as a number entity (Alarm Duration) in seconds.

Updated the satellite timer logic so the alarm:

loops as before,

stops on the Stop wake word or wake wake word (existing semantics),

and optionally auto-stops after alarm_duration_seconds if configured.

Ensured all paths that stop the alarm cleanly restore audio state and wake word activation.
This commit is contained in:
imonlinux
2025-11-29 14:40:58 -08:00
committed by GitHub
4 changed files with 424 additions and 99 deletions
+85 -31
View File
@@ -48,7 +48,7 @@ class MediaPlayers:
tts: MpvMediaPlayer tts: MpvMediaPlayer
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Mic Mute Handler # Mic Mute / Preferences Handler
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class MicMuteHandler(EventHandler): class MicMuteHandler(EventHandler):
@@ -73,18 +73,18 @@ class MicMuteHandler(EventHandler):
is_muted = data.get("state", False) is_muted = data.get("state", False)
if self.state.mic_muted != is_muted: if self.state.mic_muted != is_muted:
self.state.mic_muted = is_muted self.state.mic_muted = is_muted
# Synchronize the threading event for the audio loop # Synchronize the threading event for the audio loop
if is_muted: if is_muted:
self.state.mic_muted_event.clear() # Pauses audio thread self.state.mic_muted_event.clear() # Pauses audio thread
else: 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) _LOGGER.debug("Mic muted = %s", is_muted)
if self.mqtt_controller: if self.mqtt_controller:
self.mqtt_controller.publish_mute_state(is_muted) self.mqtt_controller.publish_mute_state(is_muted)
if is_muted: if is_muted:
self.event_bus.publish("mic_muted") self.event_bus.publish("mic_muted")
else: else:
@@ -98,6 +98,45 @@ class MicMuteHandler(EventHandler):
self.state.preferences.num_leds = num_leds self.state.preferences.num_leds = num_leds
self.state.save_preferences() 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": <int> }
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 # Main Application
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -105,13 +144,13 @@ class MicMuteHandler(EventHandler):
async def main() -> None: async def main() -> None:
# --- 1. Load Basics --- # --- 1. Load Basics ---
config, loop, event_bus = _init_basics() config, loop, event_bus = _init_basics()
# --- 2. Load Preferences --- # --- 2. Load Preferences ---
preferences = _load_preferences(config) preferences = _load_preferences(config)
# --- 3. Find Microphone --- # --- 3. Find Microphone ---
mic = _get_microphone(config) mic = _get_microphone(config)
# --- 4. Load Wake Words --- # --- 4. Load Wake Words ---
wake_word_data = _load_wake_words(config, preferences) wake_word_data = _load_wake_words(config, preferences)
@@ -120,17 +159,17 @@ async def main() -> None:
# --- 6. Create Server State --- # --- 6. Create Server State ---
state = _create_server_state( state = _create_server_state(
config, loop, event_bus, preferences, config, loop, event_bus, preferences,
wake_word_data, media_players wake_word_data, media_players
) )
# --- 7. Initialize Controllers --- # --- 7. Initialize Controllers ---
_init_controllers(loop, event_bus, state, config, preferences) _init_controllers(loop, event_bus, state, config, preferences)
# --- 8. Start Audio Engine --- # --- 8. Start Audio Engine ---
audio_engine = AudioEngine(state, mic, config.audio.input_block_size) audio_engine = AudioEngine(state, mic, config.audio.input_block_size)
audio_engine.start() audio_engine.start()
# --- 9. Run Server --- # --- 9. Run Server ---
try: try:
await _run_server(state, config) await _run_server(state, config)
@@ -138,7 +177,7 @@ async def main() -> None:
# --- 10. Cleanup --- # --- 10. Cleanup ---
_LOGGER.debug("Shutting down...") _LOGGER.debug("Shutting down...")
audio_engine.stop() audio_engine.stop()
if hasattr(state, "mqtt_controller") and state.mqtt_controller: if hasattr(state, "mqtt_controller") and state.mqtt_controller:
_LOGGER.debug("Stopping MQTT controller...") _LOGGER.debug("Stopping MQTT controller...")
state.mqtt_controller.stop() 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)): for idx, mic in enumerate(sc.all_microphones(include_loopback=False)):
print(f"[{idx}]", mic.name) print(f"[{idx}]", mic.name)
except Exception as e: 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) sys.exit(0)
if args.list_output_devices: if args.list_output_devices:
@@ -182,7 +224,7 @@ def _init_basics() -> Tuple[Config, asyncio.AbstractEventLoop, EventBus]:
config_path = args.config config_path = args.config
if not config_path.is_absolute(): 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) config = load_config_from_json(config_path)
if args.debug: if args.debug:
@@ -197,7 +239,7 @@ def _init_basics() -> Tuple[Config, asyncio.AbstractEventLoop, EventBus]:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
event_bus = EventBus() event_bus = EventBus()
return config, loop, event_bus return config, loop, event_bus
def _load_preferences(config: Config) -> Preferences: def _load_preferences(config: Config) -> Preferences:
@@ -209,8 +251,14 @@ def _load_preferences(config: Config) -> Preferences:
preferences = Preferences(**preferences_dict) preferences = Preferences(**preferences_dict)
else: else:
preferences = Preferences() 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 return preferences
def _get_microphone(config: Config): 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) mic = sc.get_microphone(config.audio.input_device, include_loopback=False)
else: else:
mic = sc.default_microphone() mic = sc.default_microphone()
if mic is None: if mic is None:
_LOGGER.critical("No microphone found.") _LOGGER.critical("No microphone found.")
sys.exit(1) sys.exit(1)
_LOGGER.info("Using audio input device: %s", mic.name) _LOGGER.info("Using audio input device: %s", mic.name)
return mic return mic
@@ -236,7 +284,7 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData:
"""Loads all available and active wake word models.""" """Loads all available and active wake word models."""
if not config.wake_word.directories: if not config.wake_word.directories:
config.wake_word.directories = ["wakewords", "wakewords/openWakeWord"] config.wake_word.directories = ["wakewords", "wakewords/openWakeWord"]
download_dir = _REPO_DIR / config.wake_word.download_dir download_dir = _REPO_DIR / config.wake_word.download_dir
download_dir.mkdir(parents=True, exist_ok=True) 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: with open(config_path, "r", encoding="utf-8") as f:
model_config = json.load(f) model_config = json.load(f)
model_type = WakeWordType(model_config["type"]) model_type = WakeWordType(model_config["type"])
wake_word_path = ( wake_word_path = (
config_path.parent / model_config["model"] config_path.parent / model_config["model"]
if model_type == WakeWordType.OPEN_WAKE_WORD if model_type == WakeWordType.OPEN_WAKE_WORD
@@ -271,13 +319,13 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData:
active: Set[str] = set() active: Set[str] = set()
models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {} models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
if preferences.active_wake_words: if preferences.active_wake_words:
for ww_id in preferences.active_wake_words: for ww_id in preferences.active_wake_words:
if ww_id in available: if ww_id in available:
models[ww_id] = available[ww_id].load() models[ww_id] = available[ww_id].load()
active.add(ww_id) active.add(ww_id)
if not models: if not models:
ww_id = config.wake_word.model ww_id = config.wake_word.model
if ww_id in available: 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) 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.""" """Initializes the music and TTS media players."""
music_player = MpvMediaPlayer( music_player = MpvMediaPlayer(
loop=loop, loop=loop,
device=config.audio.output_device, device=config.audio.output_device,
initial_volume=preferences.volume_level initial_volume=preferences.volume_level,
) )
tts_player = MpvMediaPlayer( tts_player = MpvMediaPlayer(
loop=loop, loop=loop,
device=config.audio.output_device, device=config.audio.output_device,
initial_volume=preferences.volume_level initial_volume=preferences.volume_level,
) )
return MediaPlayers(music=music_player, tts=tts_player) return MediaPlayers(music=music_player, tts=tts_player)
@@ -375,15 +427,17 @@ def _init_controllers(
async def _run_server(state: ServerState, config: Config): async def _run_server(state: ServerState, config: Config):
"""Starts the ESPHome server and ZeroConf discovery.""" """Starts the ESPHome server and ZeroConf discovery."""
server = await state.loop.create_server( server = await state.loop.create_server(
lambda: VoiceSatelliteProtocol(state), lambda: VoiceSatelliteProtocol(state),
host=config.esphome.host, host=config.esphome.host,
port=config.esphome.port port=config.esphome.port,
) )
discovery = HomeAssistantZeroconf(port=config.esphome.port, name=config.app.name) discovery = HomeAssistantZeroconf(port=config.esphome.port, name=config.app.name)
await discovery.register_server() await discovery.register_server()
async with 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() await server.serve_forever()
+11 -4
View File
@@ -51,7 +51,7 @@ class AvailableWakeWord:
if self.type == WakeWordType.OPEN_WAKE_WORD: if self.type == WakeWordType.OPEN_WAKE_WORD:
from pyopen_wakeword import OpenWakeWord from pyopen_wakeword import OpenWakeWord
oww_model = OpenWakeWord.from_model(model_path=self.wake_word_path) oww_model = OpenWakeWord.from_model(model_path=self.wake_word_path)
setattr(oww_model, "wake_word", self.wake_word) setattr(oww_model, "wake_word", self.wake_word)
return oww_model return oww_model
@@ -64,6 +64,10 @@ class Preferences:
active_wake_words: List[str] = field(default_factory=list) active_wake_words: List[str] = field(default_factory=list)
volume_level: float = 1.0 volume_level: float = 1.0
num_leds: int = 3 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 @dataclass
@@ -86,7 +90,7 @@ class ServerState:
preferences_path: Path preferences_path: Path
download_dir: Path download_dir: Path
preferences: Preferences preferences: Preferences
# --- Fields WITH default values --- # --- Fields WITH default values ---
satellite: "Optional[VoiceSatelliteProtocol]" = None satellite: "Optional[VoiceSatelliteProtocol]" = None
wake_words_changed: bool = False wake_words_changed: bool = False
@@ -98,7 +102,7 @@ class ServerState:
# set() = Mic is ON (Audio processing running) # set() = Mic is ON (Audio processing running)
# clear() = Mic is OFF (Audio processing paused) # clear() = Mic is OFF (Audio processing paused)
mic_muted_event: threading.Event = field(default_factory=threading.Event) mic_muted_event: threading.Event = field(default_factory=threading.Event)
def __post_init__(self): def __post_init__(self):
"""Ensure the threading event matches the boolean state on init.""" """Ensure the threading event matches the boolean state on init."""
if not self.mic_muted: if not self.mic_muted:
@@ -112,5 +116,8 @@ class ServerState:
self.preferences_path.parent.mkdir(parents=True, exist_ok=True) self.preferences_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.preferences_path, "w", encoding="utf-8") as preferences_file: with open(self.preferences_path, "w", encoding="utf-8") as preferences_file:
json.dump( json.dump(
asdict(self.preferences), preferences_file, ensure_ascii=False, indent=4 asdict(self.preferences),
preferences_file,
ensure_ascii=False,
indent=4,
) )
+229 -50
View File
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class MqttController(EventHandler): class MqttController(EventHandler):
def __init__( def __init__(
self, self,
@@ -27,22 +28,39 @@ class MqttController(EventHandler):
super().__init__(event_bus) super().__init__(event_bus)
self.loop = loop self.loop = loop
self.preferences = preferences self.preferences = preferences
self._host = config.host self._host = config.host
self._port = config.port self._port = config.port
self._username = config.username self._username = config.username
self._password = config.password self._password = config.password
self._device_name = app_name self._device_name = app_name
self._device_id = app_name.lower().replace(" ", "_") self._device_id = app_name.lower().replace(" ", "_")
self._topic_prefix = f"lva/{self._device_id}" 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 = { self.topics = {
"mute": { "command": f"{self._topic_prefix}/mute/set", "state": f"{self._topic_prefix}/mute/state" }, "mute": {
"num_leds": { "command": f"{self._topic_prefix}/num_leds/set", "state": f"{self._topic_prefix}/num_leds/state" } "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: for state_name in self.CONFIGURABLE_STATES:
self.topics[state_name] = { self.topics[state_name] = {
@@ -51,7 +69,7 @@ class MqttController(EventHandler):
"light_command": f"{self._topic_prefix}/{state_name}_light/set", "light_command": f"{self._topic_prefix}/{state_name}_light/set",
"light_state": f"{self._topic_prefix}/{state_name}_light/state", "light_state": f"{self._topic_prefix}/{state_name}_light/state",
} }
self._client = mqtt.Client() self._client = mqtt.Client()
self._client.on_connect = self._on_connect self._client.on_connect = self._on_connect
self._client.on_message = self._on_message self._client.on_message = self._on_message
@@ -63,14 +81,18 @@ class MqttController(EventHandler):
try: try:
if self._username: if self._username:
self._client.username_pw_set(self._username, self._password) 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.connect(self._host, self._port, 60)
self._client.loop_start() self._client.loop_start()
except Exception: except Exception:
_LOGGER.exception("Failed to connect to MQTT broker") _LOGGER.exception("Failed to connect to MQTT broker")
def stop(self): 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.loop_stop()
self._client.disconnect() self._client.disconnect()
_LOGGER.debug("Disconnected from MQTT broker") _LOGGER.debug("Disconnected from MQTT broker")
@@ -79,7 +101,7 @@ class MqttController(EventHandler):
if rc == 0: if rc == 0:
_LOGGER.info("Connected to MQTT broker") _LOGGER.info("Connected to MQTT broker")
client.subscribe(f"{self._topic_prefix}/+/set") 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() self._publish_discovery_configs()
else: else:
_LOGGER.error("Failed to connect to MQTT, return code %d", rc) _LOGGER.error("Failed to connect to MQTT, return code %d", rc)
@@ -88,8 +110,12 @@ class MqttController(EventHandler):
try: try:
topic = msg.topic topic = msg.topic
payload = msg.payload.decode() payload = msg.payload.decode()
_LOGGER.debug("Received MQTT message on topic %s (from MQTT thread)", msg.topic) _LOGGER.debug(
self.loop.call_soon_threadsafe(self._handle_message_on_loop, topic, payload) "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: except Exception:
_LOGGER.exception("Error in _on_message") _LOGGER.exception("Error in _on_message")
@@ -98,101 +124,254 @@ class MqttController(EventHandler):
if topic == self.topics["mute"]["command"]: if topic == self.topics["mute"]["command"]:
# Publish event for MicMuteHandler to process # 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"]: elif topic == self.topics["num_leds"]["command"]:
try: try:
num_leds = int(payload) num_leds = int(payload)
# Publish event for LedController and MicMuteHandler (to save) # Publish event for LedController and MicMuteHandler (to save)
self.event_bus.publish("set_num_leds", {"num_leds": num_leds}) self.event_bus.publish("set_num_leds", {"num_leds": num_leds})
self.publish_num_leds_state(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: for state_name in self.CONFIGURABLE_STATES:
state_topics = self.topics[state_name] state_topics = self.topics[state_name]
if topic == state_topics["effect_command"]: if topic == state_topics["effect_command"]:
effect_id = payload.lower().replace(" ", "_") 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"]: elif topic == state_topics["light_command"]:
try: try:
data = json.loads(payload) data = json.loads(payload)
if "state" in data: if "state" in data:
if data["state"].upper() == "OFF": if data["state"].upper() == "OFF":
self.event_bus.publish(f"set_{state_name}_effect", {"effect": "off"}) self.event_bus.publish(
else: # ON command f"set_{state_name}_effect", {"effect": "off"}
)
else: # ON command
self.event_bus.publish(f"turn_on_{state_name}") self.event_bus.publish(f"turn_on_{state_name}")
if "color" in data or "brightness" in data: if "color" in data or "brightness" in data:
self.event_bus.publish(f"set_{state_name}_color", data) self.event_bus.publish(
except json.JSONDecodeError: pass f"set_{state_name}_color", data
)
except json.JSONDecodeError:
pass
elif topic == state_topics["effect_state"]: elif topic == state_topics["effect_state"]:
effect_id = payload.lower().replace(" ", "_") 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"]: elif topic == state_topics["light_state"]:
try: try:
data = json.loads(payload) data = json.loads(payload)
data["retained"] = True data["retained"] = True
self.event_bus.publish(f"set_{state_name}_color", data) self.event_bus.publish(
except json.JSONDecodeError: pass f"set_{state_name}_color", data
)
except json.JSONDecodeError:
pass
def _publish_discovery_configs(self): def _publish_discovery_configs(self):
availability_topic = f"{self._topic_prefix}/availability" availability_topic = f"{self._topic_prefix}/availability"
device_info = { "identifiers": [self._device_id], "name": self._device_name, "manufacturer": "LVA Project" } device_info = {
options = ["Off", "Solid", "Slow Pulse", "Medium Pulse", "Fast Pulse", "Slow Blink", "Medium Blink", "Fast Blink", "Spin"] "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 } mute_cfg = {
self._client.publish(f"homeassistant/switch/{self._device_id}_mute/config", json.dumps(mute_cfg), retain=True) "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" } num_leds_cfg = {
self._client.publish(f"homeassistant/number/{self._device_id}_num_leds/config", json.dumps(num_leds_cfg), retain=True) "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: for state_name in self.CONFIGURABLE_STATES:
capital_name = state_name.title() 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" } select_cfg = {
self._client.publish(f"homeassistant/light/{self._device_id}_{state_name}_color/config", json.dumps(light_cfg), retain=True) "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") _LOGGER.debug("Published all MQTT discovery configs")
self._client.publish(availability_topic, "online", retain=True) self._client.publish(availability_topic, "online", retain=True)
# Publish initial state from internal values # Publish initial state from internal values
self.publish_mute_state(self._is_muted) self.publish_mute_state(self._is_muted)
self.publish_num_leds_state(self.preferences.num_leds) 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): def publish_mute_state(self, is_muted: bool):
self._is_muted = is_muted 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): 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 @subscribe
def publish_state_to_mqtt(self, data: dict): def publish_state_to_mqtt(self, data: dict):
state_name = data.get("state_name") state_name = data.get("state_name")
if state_name in self.topics: if state_name in self.topics:
state_topics = self.topics[state_name] state_topics = self.topics[state_name]
effect_name = data.get("effect", "off").replace("_", " ").title() 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 = { light_state = {
"state": "ON" if data.get("effect") != "off" else "OFF", "state": "ON" if data.get("effect") != "off" else "OFF",
"brightness": int(data.get("brightness", 0.5) * 255), "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 --- # --- Listen for state changes from other components ---
@subscribe @subscribe
def mic_muted(self, data: dict): def mic_muted(self, data: dict):
self.publish_mute_state(True) self.publish_mute_state(True)
@subscribe @subscribe
def mic_unmuted(self, data: dict): def mic_unmuted(self, data: dict):
self.publish_mute_state(False) self.publish_mute_state(False)
+99 -14
View File
@@ -116,6 +116,9 @@ class VoiceSatelliteProtocol(APIServer):
# External wake words announced by Home Assistant # External wake words announced by Home Assistant
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {} self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
# Timer alarm auto-stop handle
self._timer_auto_stop_handle: Optional[asyncio.TimerHandle] = None
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# State machine helpers # State machine helpers
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -225,6 +228,21 @@ class VoiceSatelliteProtocol(APIServer):
self.duck() self.duck()
self._play_timer_finished() 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) # Main message handler (called by APIServer)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -384,7 +402,9 @@ class VoiceSatelliteProtocol(APIServer):
continue continue
# Await the non-blocking download # 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: if not model_info:
continue continue
@@ -415,14 +435,58 @@ class VoiceSatelliteProtocol(APIServer):
if self._is_streaming_audio: if self._is_streaming_audio:
self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None: def _clear_timer_auto_stop(self) -> None:
if self._state not in (SatelliteState.IDLE, SatelliteState.STARTING): """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 return
if self._timer_finished: _LOGGER.debug("Stopping timer finished sound (%s)", reason)
self._timer_finished = False self._timer_finished = False
self._clear_timer_auto_stop()
try:
self.state.tts_player.stop() 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 return
wake_word_phrase = getattr(wake_word, "wake_word", "wake word") 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) self.state.tts_player.play(self.state.wakeup_sound)
def stop(self) -> None: 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.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: if self._timer_finished:
self._timer_finished = False self._stop_timer_alarm("stop_wake_word")
_LOGGER.debug("Stopping timer finished sound") return
else:
_LOGGER.debug("TTS response stopped manually") # Otherwise this is stopping a TTS response.
self._tts_finished() self.state.tts_player.stop()
_LOGGER.debug("TTS response stopped manually")
self._tts_finished()
def play_tts(self) -> None: def play_tts(self) -> None:
if not self._tts_url: if not self._tts_url:
@@ -487,7 +562,13 @@ class VoiceSatelliteProtocol(APIServer):
self._set_state(SatelliteState.THINKING) self._set_state(SatelliteState.THINKING)
def _play_timer_finished(self) -> None: 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: if not self._timer_finished:
# Alarm has been cleared; restore audio state.
self.unduck() self.unduck()
return return
self.state.tts_player.play( self.state.tts_player.play(
@@ -507,7 +588,9 @@ class VoiceSatelliteProtocol(APIServer):
"""Wrapper to run the blocking download in a thread executor.""" """Wrapper to run the blocking download in a thread executor."""
return await self.state.loop.run_in_executor( return await self.state.loop.run_in_executor(
None, 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( def _download_external_wake_word_sync(
@@ -558,7 +641,9 @@ class VoiceSatelliteProtocol(APIServer):
if should_download_model: if should_download_model:
parsed_url = urlparse(external_wake_word.url) parsed_url = urlparse(external_wake_word.url)
parsed_url = parsed_url._replace( 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) model_url = urlunparse(parsed_url)