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:
@@ -48,7 +48,7 @@ class MediaPlayers:
|
|||||||
tts: MpvMediaPlayer
|
tts: MpvMediaPlayer
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Mic Mute Handler
|
# Mic Mute / Preferences Handler
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
class MicMuteHandler(EventHandler):
|
class MicMuteHandler(EventHandler):
|
||||||
@@ -76,9 +76,9 @@ class MicMuteHandler(EventHandler):
|
|||||||
|
|
||||||
# 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)
|
||||||
|
|
||||||
@@ -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
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -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:
|
||||||
@@ -210,7 +252,13 @@ def _load_preferences(config: Config) -> Preferences:
|
|||||||
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):
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -377,13 +429,15 @@ async def _run_server(state: ServerState, config: Config):
|
|||||||
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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -37,12 +38,29 @@ class MqttController(EventHandler):
|
|||||||
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._is_muted = False # Internal state
|
||||||
|
|
||||||
self.CONFIGURABLE_STATES: List[str] = ["idle", "listening", "thinking", "responding", "error"]
|
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] = {
|
||||||
@@ -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")
|
||||||
@@ -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,7 +124,9 @@ 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:
|
||||||
@@ -106,56 +134,181 @@ class MqttController(EventHandler):
|
|||||||
# 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" }
|
select_cfg = {
|
||||||
self._client.publish(f"homeassistant/select/{self._device_id}_{state_name}_effect/config", json.dumps(select_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" }
|
light_cfg = {
|
||||||
self._client.publish(f"homeassistant/light/{self._device_id}_{state_name}_color/config", json.dumps(light_cfg), retain=True)
|
"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)
|
||||||
@@ -163,13 +316,29 @@ class MqttController(EventHandler):
|
|||||||
# 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):
|
||||||
@@ -178,14 +347,24 @@ class MqttController(EventHandler):
|
|||||||
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 ---
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user