Update __main__.py

Extended Preferences loading to initialize and backfill a new alarm_duration_seconds field (default 0).

Added a set_alarm_duration event handler to MicMuteHandler which:

validates and normalizes the duration value,

updates state.preferences.alarm_duration_seconds,

persists the updated preferences to disk.

Kept the existing “single writer” pattern (MicMuteHandler) for preferences/state updates.
This commit is contained in:
imonlinux
2025-11-29 14:37:18 -08:00
committed by GitHub
parent 5931370c63
commit b43b036198
+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()