oremi-device 1.0.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,842 @@
1
+ # Copyright 2026 Sébastien Demanou. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # ==============================================================================
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ import signal
19
+ import sys
20
+
21
+ import sounddevice as sd
22
+
23
+ from .components import COMPONENTS
24
+ from .components.base import AudioStreamComponent
25
+ from .core.args import parse_arguments
26
+ from .core.audio import Audio
27
+ from .core.audio import AudioDeviceError
28
+ from .core.models import DeviceConfig
29
+ from .core.models import DeviceContext
30
+ from .core.mqtt import MQTTClient
31
+ from .core.package import APP_NAME
32
+ from .core.package import APP_VERSION
33
+ from .core.speaker import Speaker
34
+ from .core.speaker import SpeakerDeviceError
35
+ from .core.speaker import SpeakerPlaybackError
36
+ from .utils.logger import logger
37
+
38
+
39
+ async def start() -> None:
40
+ args = parse_arguments()
41
+
42
+ loop = asyncio.get_running_loop()
43
+
44
+ # Log initial configuration
45
+ logger.info(f"Starting {APP_NAME}/{APP_VERSION}")
46
+ logger.info(f"Log level: {logging.getLevelName(logger.getEffectiveLevel())}")
47
+ logger.info(f"Configuration Node ID: {args.node_id}")
48
+ logger.info(f"Configuration Ohunerin Host: {args.ohunerin_host}")
49
+ logger.info(f"Configuration Ohunerin Port: {args.ohunerin_port}")
50
+ logger.info(f"Configuration Andika Host: {args.andika_host}")
51
+ logger.info(f"Configuration Andika Port: {args.andika_port}")
52
+ logger.info(f"Configuration Secure Connection: {'Enabled' if args.cert_file else 'Disabled'}")
53
+ microphone_enabled = args.microphone_enabled
54
+ speaker_enabled = args.speaker_enabled
55
+ logger.info(f"Configuration Microphone: {'Enabled' if microphone_enabled else 'Disabled'}")
56
+ logger.info(f"Configuration Speaker: {'Enabled' if speaker_enabled else 'Disabled'}")
57
+
58
+ if args.cert_file:
59
+ logger.info(f"Configuration Certificate File: {args.cert_file}")
60
+
61
+ config_path = args.config
62
+ logger.info(f"Device Configuration Path: {config_path or 'None'}")
63
+
64
+ context = DeviceContext(
65
+ node_id=args.node_id,
66
+ language=args.language,
67
+ delay=args.delay,
68
+ conversation_timeout=args.conversation_timeout,
69
+ ohunerin_host=args.ohunerin_host,
70
+ ohunerin_port=args.ohunerin_port,
71
+ andika_host=args.andika_host,
72
+ andika_port=args.andika_port,
73
+ cert_file=args.cert_file,
74
+ )
75
+
76
+ device_config: DeviceConfig | None = None
77
+
78
+ if config_path:
79
+ try:
80
+ with open(config_path, "r", encoding="utf-8") as file:
81
+ device_config = DeviceConfig.model_validate(json.load(file))
82
+ except Exception as error:
83
+ logger.error(f"Failed to load configuration file '{config_path}': {error}")
84
+ sys.exit(1)
85
+ else:
86
+ device_config = DeviceConfig()
87
+
88
+ logger.info(f"Configuration Language: {context.language}")
89
+
90
+ # ── Resolve active components ────────────────────────────────────────────────
91
+ active_components: list[tuple] = []
92
+
93
+ for comp_name, (load_config, create_component) in COMPONENTS.items():
94
+ comp_config = getattr(device_config.components, comp_name, None)
95
+ comp_config = load_config.model_validate(comp_config) if comp_config else load_config() # type: ignore
96
+
97
+ component = create_component(context) # pylint: disable=abstract-class-instantiated
98
+
99
+ active_components.append((component, comp_config))
100
+
101
+ # ── Audio stream (only for AudioStreamComponents) ────────────────────────────
102
+ # Check whether any active component actually needs the microphone so that
103
+ # HTTP-only components (e.g. Injini) can run without any audio hardware.
104
+ needs_audio = microphone_enabled and any(isinstance(c, AudioStreamComponent) for c, _ in active_components)
105
+
106
+ audio_stream: Audio | None = None
107
+
108
+ if needs_audio:
109
+ try:
110
+ audio_stream = Audio(device_index=args.device_index, device_name=args.device)
111
+ except AudioDeviceError as error:
112
+ logger.error(str(error))
113
+ sys.exit(1)
114
+
115
+ logger.info(f"Active Microphone Device: {audio_stream.device_name}")
116
+ compatible_devices = Audio.get_compatible_input_devices(current_device=audio_stream.device_name)
117
+ logger.info(f"Compatible Input Devices: {', '.join(compatible_devices) if compatible_devices else 'None'}")
118
+ elif microphone_enabled:
119
+ logger.info("No AudioStreamComponent active — skipping audio initialisation.")
120
+ else:
121
+ logger.info("Microphone disabled — skipping audio initialisation.")
122
+
123
+ active_stream_container: dict = {"stream": audio_stream}
124
+
125
+ speaker: Speaker | None = None
126
+
127
+ if speaker_enabled:
128
+ try:
129
+ speaker = Speaker()
130
+ logger.info(f"Active Speaker Device: {speaker.device_name}")
131
+ except SpeakerDeviceError as error:
132
+ logger.warning(str(error))
133
+ else:
134
+ logger.info("Speaker disabled — skipping speaker initialisation.")
135
+
136
+ # ── MQTT client ──────────────────────────────────────────────────────────────
137
+ mqtt_client = MQTTClient(
138
+ broker=args.mqtt_broker,
139
+ port=args.mqtt_port,
140
+ node_id=args.node_id,
141
+ topic=args.mqtt_topic,
142
+ username=args.mqtt_username,
143
+ password=args.mqtt_password,
144
+ discovery_prefix=args.mqtt_discovery_prefix if args.mqtt_discovery else None,
145
+ )
146
+
147
+ # MQTT callbacks run on the client thread, so command state must exist
148
+ # before connecting and subscribing to retained command topics.
149
+ device_change_queue: asyncio.Queue = asyncio.Queue()
150
+ device_enable_queue: asyncio.Queue = asyncio.Queue()
151
+ microphone_mute_queue: asyncio.Queue = asyncio.Queue()
152
+ speaker_change_queue: asyncio.Queue = asyncio.Queue()
153
+ speaker_mute_queue: asyncio.Queue = asyncio.Queue()
154
+ speaker_volume_queue: asyncio.Queue = asyncio.Queue()
155
+ player_play_queue: asyncio.Queue = asyncio.Queue()
156
+ player_stop_queue: asyncio.Queue = asyncio.Queue()
157
+ language_change_queue: asyncio.Queue = asyncio.Queue()
158
+ wakeword_event = asyncio.Event()
159
+ device_state: dict = {"enabled": True, "microphone_muted": False}
160
+ speaker_state: dict = {
161
+ "device": speaker.device_name if speaker else "",
162
+ "muted": speaker.muted if speaker else False,
163
+ "volume": speaker.volume if speaker else 1.0,
164
+ }
165
+ language_state: dict = {"language": args.language}
166
+ playback_task: asyncio.Task | None = None
167
+
168
+ async def play_media(media_url: str) -> None:
169
+ if speaker is None:
170
+ logger.error("Cannot play media because no compatible speaker is available")
171
+ return
172
+
173
+ mqtt_client.set_player_state("playing")
174
+
175
+ try:
176
+ await asyncio.to_thread(speaker.play_url, media_url)
177
+ except SpeakerPlaybackError as error:
178
+ logger.error(f"Media playback failed: {error}")
179
+ finally:
180
+ mqtt_client.set_player_state("idle")
181
+
182
+ @mqtt_client.client.connect_callback()
183
+ def mqtt_on_connect(client, userdata, flags, reason_code, properties) -> None:
184
+ logger.info(f"Connected to MQTT broker with result code {reason_code}")
185
+
186
+ if needs_audio:
187
+ # Subscribe to the instance-scoped microphone-select command topic.
188
+ # Using node_id in the topic prevents cross-instance interference when
189
+ # multiple oremi-device instances share the same base MQTT topic.
190
+ client.subscribe(mqtt_client.get_microphone_command_topic())
191
+ client.subscribe(mqtt_client.get_mute_command_topic())
192
+
193
+ if speaker_enabled:
194
+ client.subscribe(mqtt_client.get_speaker_command_topic())
195
+ client.subscribe(mqtt_client.get_speaker_mute_cmd_topic())
196
+ client.subscribe(mqtt_client.get_speaker_volume_cmd_topic())
197
+ client.subscribe(mqtt_client.get_player_play_command_topic())
198
+ client.subscribe(mqtt_client.get_player_stop_command_topic())
199
+
200
+ # Subscribe to the instance-scoped enable/disable switch command topic.
201
+ client.subscribe(mqtt_client.get_switch_command_topic())
202
+
203
+ # Subscribe to the language-select command topic.
204
+ client.subscribe(mqtt_client.get_language_command_topic())
205
+
206
+ if mqtt_client.discovery_prefix:
207
+ try:
208
+ curr_stream = active_stream_container["stream"]
209
+ curr_name = curr_stream.device_name if curr_stream else ""
210
+ node_id = mqtt_client.get_node_id()
211
+ device = mqtt_client.get_device_descriptor()
212
+
213
+ ha_entities = [
214
+ entity for component, _ in active_components for entity in component.ha_entities(node_id, mqtt_client.topic, device)
215
+ ]
216
+
217
+ mqtt_client.publish_discovery_configs(
218
+ input_devices=Audio.get_compatible_input_devices(current_device=curr_name) if needs_audio else [],
219
+ current_device=curr_name,
220
+ entities=ha_entities,
221
+ enabled=device_state["enabled"],
222
+ microphone_muted=device_state["microphone_muted"],
223
+ output_devices=Speaker.get_compatible_output_devices() if speaker_enabled else [],
224
+ current_output=speaker_state["device"],
225
+ speaker_muted=speaker_state["muted"],
226
+ speaker_volume=speaker_state["volume"],
227
+ current_language=language_state["language"],
228
+ microphone_enabled=microphone_enabled,
229
+ speaker_enabled=speaker_enabled,
230
+ )
231
+ except Exception as error:
232
+ logger.error(f"Failed to publish MQTT discovery configs: {error}")
233
+
234
+ @mqtt_client.client.disconnect_callback()
235
+ def mqtt_on_disconnect(client, userdata, flags, reason_code, properties) -> None:
236
+ curr_stream = active_stream_container["stream"]
237
+
238
+ if curr_stream and curr_stream.is_active():
239
+ logger.warning(f"Unexpected disconnection from MQTT broker with result code {reason_code}. Reconnecting...")
240
+ mqtt_client.connect()
241
+ else:
242
+ logger.info(f"Disconnected from MQTT broker with result code {reason_code}")
243
+
244
+ mqtt_client.connect()
245
+ mqtt_client.set_availability(False)
246
+
247
+ @mqtt_client.client.message_callback()
248
+ def mqtt_on_message(client, userdata, message) -> None:
249
+ topic = message.topic
250
+
251
+ if needs_audio and topic == mqtt_client.get_microphone_command_topic():
252
+ try:
253
+ new_device = message.payload.decode("utf-8")
254
+ logger.info(f"Received microphone set command: {new_device}")
255
+ loop.call_soon_threadsafe(device_change_queue.put_nowait, new_device)
256
+ except Exception as error:
257
+ logger.error(f"Error decoding or handling MQTT microphone command: {error}")
258
+
259
+ elif topic == mqtt_client.get_switch_command_topic():
260
+ try:
261
+ payload = message.payload.decode("utf-8").strip().upper()
262
+ logger.info(f"Received device switch command: {payload}")
263
+
264
+ if payload in ("ON", "OFF"):
265
+ loop.call_soon_threadsafe(device_enable_queue.put_nowait, payload == "ON")
266
+ except Exception as error:
267
+ logger.error(f"Error decoding or handling MQTT switch command: {error}")
268
+
269
+ elif needs_audio and topic == mqtt_client.get_mute_command_topic():
270
+ try:
271
+ payload = message.payload.decode("utf-8").strip().upper()
272
+ logger.info(f"Received microphone mute command: {payload}")
273
+
274
+ if payload in ("ON", "OFF"):
275
+ muted = payload == "ON"
276
+ device_state["microphone_muted"] = muted
277
+ loop.call_soon_threadsafe(microphone_mute_queue.put_nowait, muted)
278
+ except Exception as error:
279
+ logger.error(f"Error decoding or handling MQTT microphone mute command: {error}")
280
+
281
+ elif speaker_enabled and topic == mqtt_client.get_speaker_command_topic():
282
+ try:
283
+ new_speaker = message.payload.decode("utf-8").strip()
284
+ logger.info(f"Received speaker set command: {new_speaker}")
285
+ loop.call_soon_threadsafe(speaker_change_queue.put_nowait, new_speaker)
286
+ except Exception as error:
287
+ logger.error(f"Error decoding or handling MQTT speaker command: {error}")
288
+
289
+ elif speaker_enabled and topic == mqtt_client.get_speaker_mute_cmd_topic():
290
+ try:
291
+ payload = message.payload.decode("utf-8").strip().upper()
292
+ logger.info(f"Received speaker mute command: {payload}")
293
+
294
+ if payload in ("ON", "OFF"):
295
+ loop.call_soon_threadsafe(speaker_mute_queue.put_nowait, payload == "ON")
296
+ except Exception as error:
297
+ logger.error(f"Error decoding or handling MQTT speaker mute command: {error}")
298
+
299
+ elif speaker_enabled and topic == mqtt_client.get_speaker_volume_cmd_topic():
300
+ try:
301
+ volume = float(message.payload.decode("utf-8").strip()) / 100
302
+ logger.info(f"Received speaker volume command: {volume:.0%}")
303
+
304
+ if 0.0 <= volume <= 1.0:
305
+ loop.call_soon_threadsafe(speaker_volume_queue.put_nowait, volume)
306
+ except (UnicodeDecodeError, ValueError) as error:
307
+ logger.error(f"Error decoding or handling MQTT speaker volume command: {error}")
308
+
309
+ elif speaker_enabled and topic == mqtt_client.get_player_play_command_topic():
310
+ try:
311
+ media_url = message.payload.decode("utf-8").strip()
312
+
313
+ if media_url:
314
+ loop.call_soon_threadsafe(player_play_queue.put_nowait, media_url)
315
+ except UnicodeDecodeError as error:
316
+ logger.error(f"Error decoding media playback command: {error}")
317
+
318
+ elif speaker_enabled and topic == mqtt_client.get_player_stop_command_topic():
319
+ loop.call_soon_threadsafe(player_stop_queue.put_nowait, True)
320
+
321
+ elif topic == mqtt_client.get_language_command_topic():
322
+ try:
323
+ new_language = message.payload.decode("utf-8").strip().lower()
324
+ logger.info(f"Received language set command: {new_language}")
325
+
326
+ if new_language in ("fr", "en"):
327
+ loop.call_soon_threadsafe(language_change_queue.put_nowait, new_language)
328
+ else:
329
+ logger.warning(f"Ignoring unsupported language command: {new_language}")
330
+ except Exception as error:
331
+ logger.error(f"Error decoding or handling MQTT language command: {error}")
332
+
333
+ def handle_task_done(task: asyncio.Task) -> None:
334
+ try:
335
+ if task.exception() is not None:
336
+ logger.error(task)
337
+ elif task.cancelled():
338
+ logger.info(f"{task.get_name()} cancelled")
339
+ else:
340
+ logger.info(f"{task.get_name()} done")
341
+ except (asyncio.CancelledError, asyncio.InvalidStateError) as error:
342
+ logger.error(error)
343
+
344
+ # ── Audio device switching (only when needs_audio) ───────────────────────────
345
+ started_components: list[tuple] = []
346
+
347
+ async def change_audio_device(target_device, fallback_to_previous=True) -> bool:
348
+ """Open a new audio stream and notify all AudioStreamComponents."""
349
+ prev_stream = active_stream_container["stream"]
350
+ prev_device = prev_stream.device_name if prev_stream else None
351
+
352
+ # Tear down the current stream
353
+ if prev_stream is not None:
354
+ try:
355
+ prev_stream.stop()
356
+ except Exception:
357
+ pass
358
+
359
+ # Signal components that the stream is gone
360
+ for component, _ in started_components:
361
+ if isinstance(component, AudioStreamComponent):
362
+ await component.on_device_changed(None)
363
+
364
+ try:
365
+ if target_device is not None:
366
+ if isinstance(target_device, int):
367
+ new_stream = Audio(device_index=target_device)
368
+ else:
369
+ new_stream = Audio(device_name=str(target_device))
370
+ else:
371
+ # Auto-select: find any compatible device
372
+ compatible = Audio.find_compatible_device()
373
+ if compatible:
374
+ new_stream = Audio(device_index=compatible["index"])
375
+ else:
376
+ raise AudioDeviceError("No compatible device found")
377
+
378
+ active_stream_container["stream"] = new_stream
379
+
380
+ # Signal components only when microphone capture should be active.
381
+ if device_state["enabled"] and not device_state["microphone_muted"]:
382
+ for component, _ in started_components:
383
+ if isinstance(component, AudioStreamComponent):
384
+ await component.on_device_changed(new_stream)
385
+
386
+ # Publish microphone state
387
+ mqtt_client.publish("microphone/state", new_stream.device_name, retain=True)
388
+ mqtt_client.set_availability(True)
389
+
390
+ # Republish HA discovery options with updated device list
391
+ if mqtt_client.discovery_prefix:
392
+ node_id = mqtt_client.get_node_id()
393
+ device = mqtt_client.get_device_descriptor()
394
+ ha_entities = [
395
+ entity for component, _ in started_components for entity in component.ha_entities(node_id, mqtt_client.topic, device)
396
+ ]
397
+ comp_devices = Audio.get_compatible_input_devices(current_device=new_stream.device_name)
398
+ mqtt_client.publish_discovery_configs(
399
+ input_devices=comp_devices,
400
+ current_device=new_stream.device_name,
401
+ entities=ha_entities,
402
+ enabled=device_state["enabled"],
403
+ microphone_muted=device_state["microphone_muted"],
404
+ output_devices=Speaker.get_compatible_output_devices() if speaker_enabled else [],
405
+ current_output=speaker_state["device"],
406
+ speaker_muted=speaker_state["muted"],
407
+ speaker_volume=speaker_state["volume"],
408
+ current_language=language_state["language"],
409
+ microphone_enabled=microphone_enabled,
410
+ speaker_enabled=speaker_enabled,
411
+ )
412
+
413
+ logger.info(f"Successfully switched to microphone: {new_stream.device_name}")
414
+
415
+ return True
416
+ except Exception as err:
417
+ logger.error(f"Failed to switch to device {target_device}: {err}")
418
+
419
+ if fallback_to_previous and prev_device is not None:
420
+ logger.info(f"Attempting to revert to previous microphone: {prev_device}")
421
+ success = await change_audio_device(prev_device, fallback_to_previous=False)
422
+
423
+ if success:
424
+ return True
425
+
426
+ # No fallback available — mark stream as unavailable
427
+ active_stream_container["stream"] = None
428
+
429
+ for component, _ in started_components:
430
+ if isinstance(component, AudioStreamComponent):
431
+ await component.on_device_changed(None)
432
+
433
+ mqtt_client.publish("microphone/state", "None", retain=True)
434
+ mqtt_client.set_availability(False)
435
+
436
+ return False
437
+
438
+ shared_resources = {
439
+ "active_stream_container": active_stream_container,
440
+ "device_change_queue": device_change_queue,
441
+ "device_enable_queue": device_enable_queue,
442
+ "microphone_mute_queue": microphone_mute_queue,
443
+ "speaker": speaker,
444
+ "speaker_state": speaker_state,
445
+ "language_change_queue": language_change_queue,
446
+ "wakeword_event": wakeword_event,
447
+ "device_state": device_state,
448
+ "language_state": language_state,
449
+ "change_audio_device": change_audio_device,
450
+ "handle_task_done": handle_task_done,
451
+ }
452
+
453
+ # ── Start all active components ──────────────────────────────────────────────
454
+ for component, comp_config in active_components:
455
+ logger.info(f"Starting component: {component.name}")
456
+ await component.start(comp_config, mqtt_client, shared_resources)
457
+ started_components.append((component, comp_config))
458
+
459
+ async def shutdown() -> None:
460
+ logger.info("Shutdown signal received. Stopping all components...")
461
+ for component, _ in started_components:
462
+ await component.stop()
463
+
464
+ loop.add_signal_handler(signal.SIGINT, lambda: loop.create_task(shutdown()))
465
+ loop.add_signal_handler(signal.SIGTERM, lambda: loop.create_task(shutdown()))
466
+
467
+ # ── Main event loop ──────────────────────────────────────────────────────────
468
+ preferred_device = args.device if args.device is not None else args.device_index
469
+
470
+ def is_device_available(device, current_devs, compatible_devs) -> bool:
471
+ if isinstance(device, int):
472
+ return device < len(current_devs) and current_devs[device]["max_input_channels"] > 0
473
+
474
+ for name in compatible_devs:
475
+ if device.lower() in name.lower():
476
+ return True
477
+
478
+ return False
479
+
480
+ async def monitor_devices() -> None:
481
+ """Poll the OS device list and enqueue auto-select events on changes."""
482
+ nonlocal preferred_device
483
+ last_signatures: set = set()
484
+
485
+ try:
486
+ last_signatures = {(d["name"], d.get("hostapi"), d.get("max_input_channels")) for d in sd.query_devices()}
487
+ except Exception as error:
488
+ logger.warning(f"Failed to initialize device monitoring signatures: {error}")
489
+
490
+ while True:
491
+ try:
492
+ await asyncio.sleep(3.0)
493
+ except asyncio.CancelledError:
494
+ break
495
+
496
+ try:
497
+ current_devs = sd.query_devices()
498
+ current_signatures = {(d["name"], d.get("hostapi"), d.get("max_input_channels")) for d in current_devs}
499
+ except Exception as error:
500
+ logger.warning(f"Failed to query audio devices: {error}")
501
+ continue
502
+
503
+ if current_signatures == last_signatures:
504
+ continue
505
+
506
+ logger.info("Audio device change detected (added or removed).")
507
+ last_signatures = current_signatures
508
+
509
+ curr_stream = active_stream_container["stream"]
510
+ curr_name = curr_stream.device_name if curr_stream else None
511
+ compatible_devs = Audio.get_compatible_input_devices(current_device=curr_name)
512
+
513
+ if mqtt_client.discovery_prefix:
514
+ try:
515
+ node_id = mqtt_client.get_node_id()
516
+ device = mqtt_client.get_device_descriptor()
517
+ ha_entities = [
518
+ entity
519
+ for component, _ in started_components
520
+ for entity in component.ha_entities(node_id, mqtt_client.topic, device)
521
+ ]
522
+ mqtt_client.publish_discovery_configs(
523
+ input_devices=compatible_devs,
524
+ current_device=curr_name or "",
525
+ entities=ha_entities,
526
+ enabled=device_state["enabled"],
527
+ microphone_muted=device_state["microphone_muted"],
528
+ output_devices=Speaker.get_compatible_output_devices() if speaker_enabled else [],
529
+ current_output=speaker_state["device"],
530
+ speaker_muted=speaker_state["muted"],
531
+ speaker_volume=speaker_state["volume"],
532
+ current_language=language_state["language"],
533
+ microphone_enabled=microphone_enabled,
534
+ speaker_enabled=speaker_enabled,
535
+ )
536
+ except Exception as error:
537
+ logger.error(f"Failed to publish MQTT discovery configs on device change: {error}")
538
+
539
+ # Current stream disappeared
540
+ if curr_stream is not None:
541
+ if curr_name not in compatible_devs:
542
+ logger.warning(f"Active microphone '{curr_name}' was disconnected (removed).")
543
+ loop.call_soon_threadsafe(device_change_queue.put_nowait, "__auto_select__")
544
+ else:
545
+ if compatible_devs:
546
+ logger.info("A compatible microphone is now available. Starting audio stream...")
547
+ loop.call_soon_threadsafe(device_change_queue.put_nowait, "__auto_select__")
548
+
549
+ # Preferred device reconnected while a different device is active
550
+ if preferred_device is not None and curr_stream is not None:
551
+ is_preferred_active = False
552
+
553
+ if isinstance(preferred_device, int):
554
+ if curr_stream.stream.device == preferred_device:
555
+ is_preferred_active = True
556
+ else:
557
+ if curr_name and preferred_device.lower() in curr_name.lower():
558
+ is_preferred_active = True
559
+
560
+ if not is_preferred_active and is_device_available(preferred_device, current_devs, compatible_devs):
561
+ logger.info(f"Preferred microphone '{preferred_device}' is now available. Switching back...")
562
+ loop.call_soon_threadsafe(device_change_queue.put_nowait, preferred_device)
563
+
564
+ # Start the background device monitor only when audio is needed
565
+ monitor_task: asyncio.Task | None = None
566
+
567
+ if needs_audio:
568
+ monitor_task = loop.create_task(monitor_devices(), name="Monitor Devices Task")
569
+ monitor_task.add_done_callback(handle_task_done)
570
+
571
+ try:
572
+ while True:
573
+ tasks = []
574
+ component_tasks: dict = {}
575
+ listening_tasks: dict = {}
576
+
577
+ for component, _ in started_components:
578
+ if component.task and not component.task.done():
579
+ tasks.append(component.task)
580
+ component_tasks[component.task] = component
581
+
582
+ if isinstance(component, AudioStreamComponent):
583
+ if component.listening_task and not component.listening_task.done():
584
+ tasks.append(component.listening_task)
585
+ listening_tasks[component.listening_task] = component
586
+
587
+ # No component tasks are running — clean exit
588
+ if not tasks:
589
+ break
590
+
591
+ if needs_audio:
592
+ device_change_task = loop.create_task(device_change_queue.get())
593
+ tasks.append(device_change_task)
594
+ else:
595
+ device_change_task = None
596
+
597
+ device_enable_task = loop.create_task(device_enable_queue.get())
598
+ tasks.append(device_enable_task)
599
+
600
+ microphone_mute_task = loop.create_task(microphone_mute_queue.get()) if needs_audio else None
601
+
602
+ if microphone_mute_task is not None:
603
+ tasks.append(microphone_mute_task)
604
+
605
+ speaker_change_task = loop.create_task(speaker_change_queue.get()) if speaker_enabled else None
606
+ speaker_mute_task = loop.create_task(speaker_mute_queue.get()) if speaker_enabled else None
607
+ speaker_volume_task = loop.create_task(speaker_volume_queue.get()) if speaker_enabled else None
608
+ player_play_task = loop.create_task(player_play_queue.get()) if speaker_enabled else None
609
+ player_stop_task = loop.create_task(player_stop_queue.get()) if speaker_enabled else None
610
+
611
+ if speaker_enabled:
612
+ tasks.extend((speaker_change_task, speaker_mute_task, speaker_volume_task, player_play_task, player_stop_task))
613
+
614
+ language_change_task = loop.create_task(language_change_queue.get())
615
+ tasks.append(language_change_task)
616
+
617
+ done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
618
+
619
+ # Queue reads are recreated on each iteration. Cancel the pending ones so
620
+ # they cannot consume a later command behind the active iteration's task.
621
+ command_tasks = [
622
+ device_change_task,
623
+ device_enable_task,
624
+ microphone_mute_task,
625
+ speaker_change_task,
626
+ speaker_mute_task,
627
+ speaker_volume_task,
628
+ player_play_task,
629
+ player_stop_task,
630
+ language_change_task,
631
+ ]
632
+
633
+ for command_task in command_tasks:
634
+ if command_task is not None and command_task not in done:
635
+ command_task.cancel()
636
+
637
+ if device_change_task is not None and device_change_task in done:
638
+ new_device = device_change_task.result()
639
+
640
+ if new_device == "__auto_select__":
641
+ success = False
642
+
643
+ if preferred_device is not None:
644
+ logger.info(f"Attempting to switch to preferred microphone: {preferred_device}")
645
+ success = await change_audio_device(preferred_device)
646
+
647
+ if not success:
648
+ logger.info("Attempting to auto-select any compatible microphone...")
649
+ success = await change_audio_device(None)
650
+
651
+ if not success:
652
+ logger.error("No compatible microphone could be started. Waiting for a device to be connected...")
653
+ else:
654
+ # Explicit switch requested (from MQTT or reconnection logic)
655
+ preferred_device = new_device
656
+ logger.info(f"Switching microphone to: {preferred_device}")
657
+ success = await change_audio_device(preferred_device)
658
+
659
+ if not success:
660
+ logger.info("Explicit switch failed. Attempting to auto-select any compatible microphone...")
661
+ await change_audio_device(None)
662
+
663
+ elif device_enable_task is not None and device_enable_task in done:
664
+ new_enabled = device_enable_task.result()
665
+ device_state["enabled"] = new_enabled
666
+
667
+ if new_enabled:
668
+ logger.info("Device enabled from HA UI. Resuming listening...")
669
+ mqtt_client.set_switch_state(True)
670
+
671
+ # Resume the audio stream if it was up before being disabled
672
+ if needs_audio:
673
+ curr_stream = active_stream_container["stream"]
674
+
675
+ if curr_stream is not None and not device_state["microphone_muted"]:
676
+ for component, _ in started_components:
677
+ if isinstance(component, AudioStreamComponent):
678
+ await component.on_device_changed(curr_stream)
679
+ else:
680
+ loop.call_soon_threadsafe(device_change_queue.put_nowait, "__auto_select__")
681
+
682
+ mqtt_client.set_availability(True)
683
+ else:
684
+ logger.info("Device disabled from HA UI. Stopping listening and sleeping...")
685
+ mqtt_client.set_switch_state(False)
686
+
687
+ # Stop all audio stream components (stop listening but keep WS/MQTT alive)
688
+ if needs_audio:
689
+ for component, _ in started_components:
690
+ if isinstance(component, AudioStreamComponent):
691
+ await component.on_device_changed(None)
692
+
693
+ mqtt_client.set_availability(False)
694
+ elif microphone_mute_task is not None and microphone_mute_task in done:
695
+ muted = microphone_mute_task.result()
696
+ device_state["microphone_muted"] = muted
697
+ mqtt_client.set_mute_state(muted)
698
+
699
+ if muted:
700
+ logger.info("Microphone muted. Stopping audio capture...")
701
+
702
+ for component, _ in started_components:
703
+ if isinstance(component, AudioStreamComponent):
704
+ await component.on_device_changed(None)
705
+ elif device_state["enabled"]:
706
+ logger.info("Microphone unmuted. Resuming audio capture...")
707
+ curr_stream = active_stream_container["stream"]
708
+
709
+ if curr_stream is not None:
710
+ for component, _ in started_components:
711
+ if isinstance(component, AudioStreamComponent):
712
+ await component.on_device_changed(curr_stream)
713
+ else:
714
+ loop.call_soon_threadsafe(device_change_queue.put_nowait, "__auto_select__")
715
+ elif speaker_change_task is not None and speaker_change_task in done:
716
+ requested_speaker = speaker_change_task.result()
717
+
718
+ try:
719
+ if speaker is None:
720
+ speaker = Speaker(
721
+ requested_speaker,
722
+ volume=speaker_state["volume"],
723
+ muted=speaker_state["muted"],
724
+ )
725
+ shared_resources["speaker"] = speaker
726
+ else:
727
+ speaker.select_device(requested_speaker)
728
+
729
+ speaker_state["device"] = speaker.device_name
730
+ mqtt_client.set_speaker_state(speaker.device_name)
731
+ logger.info(f"Speaker switched to: {speaker.device_name}")
732
+ except SpeakerDeviceError as error:
733
+ logger.error(error)
734
+ mqtt_client.set_speaker_state(speaker_state["device"])
735
+ elif speaker_mute_task is not None and speaker_mute_task in done:
736
+ muted = speaker_mute_task.result()
737
+ speaker_state["muted"] = muted
738
+
739
+ if speaker:
740
+ speaker.set_muted(muted)
741
+
742
+ mqtt_client.set_speaker_mute_state(muted)
743
+ logger.info(f"Speaker {'muted' if muted else 'unmuted'}")
744
+ elif speaker_volume_task is not None and speaker_volume_task in done:
745
+ volume = speaker_volume_task.result()
746
+ speaker_state["volume"] = volume
747
+
748
+ if speaker:
749
+ speaker.set_volume(volume)
750
+
751
+ mqtt_client.set_speaker_volume_state(volume)
752
+ logger.info(f"Speaker volume set to {volume:.0%}")
753
+ elif player_play_task is not None and player_play_task in done:
754
+ media_url = player_play_task.result()
755
+
756
+ if speaker is not None:
757
+ speaker.stop()
758
+
759
+ if playback_task is not None:
760
+ await playback_task
761
+
762
+ playback_task = loop.create_task(play_media(media_url), name="Speaker Playback Task")
763
+ elif player_stop_task is not None and player_stop_task in done:
764
+ if speaker is not None:
765
+ speaker.stop()
766
+
767
+ if playback_task is not None:
768
+ await playback_task
769
+ playback_task = None
770
+ elif language_change_task is not None and language_change_task in done:
771
+ new_language = language_change_task.result()
772
+ language_state["language"] = new_language
773
+ context.language = new_language
774
+ logger.info(f"Language switched to: {new_language}")
775
+
776
+ # Publish the new language state for HA
777
+ mqtt_client.publish_language_state(new_language)
778
+
779
+ # Republish HA discovery so the select shows the new current value
780
+ if mqtt_client.discovery_prefix:
781
+ try:
782
+ curr_stream = active_stream_container["stream"]
783
+ curr_name = curr_stream.device_name if curr_stream else ""
784
+ node_id = mqtt_client.get_node_id()
785
+ device = mqtt_client.get_device_descriptor()
786
+ ha_entities = [
787
+ entity for component, _ in started_components for entity in component.ha_entities(node_id, mqtt_client.topic, device)
788
+ ]
789
+ mqtt_client.publish_discovery_configs(
790
+ input_devices=Audio.get_compatible_input_devices(current_device=curr_name) if needs_audio else [],
791
+ current_device=curr_name,
792
+ entities=ha_entities,
793
+ enabled=device_state["enabled"],
794
+ microphone_muted=device_state["microphone_muted"],
795
+ output_devices=Speaker.get_compatible_output_devices() if speaker_enabled else [],
796
+ current_output=speaker_state["device"],
797
+ speaker_muted=speaker_state["muted"],
798
+ speaker_volume=speaker_state["volume"],
799
+ current_language=new_language,
800
+ microphone_enabled=microphone_enabled,
801
+ speaker_enabled=speaker_enabled,
802
+ )
803
+ except Exception as error:
804
+ logger.error(f"Failed to republish discovery after language change: {error}")
805
+
806
+ # Ohunerin reads the language when opening its WebSocket, so reconnect it.
807
+ for component, comp_config in started_components:
808
+ if component.name == "ohunerin":
809
+ await component.stop()
810
+ await component.start(comp_config, mqtt_client, shared_resources)
811
+ else:
812
+ is_listening_done = any(t in listening_tasks for t in done)
813
+ is_main_done = any(t in component_tasks for t in done)
814
+
815
+ if is_listening_done and not is_main_done:
816
+ # Audio feed task ended (e.g. mic disconnected) but WebSocket is still up
817
+ logger.warning("Listening task finished unexpectedly (possibly microphone disconnected).")
818
+ loop.call_soon_threadsafe(device_change_queue.put_nowait, "__auto_select__")
819
+ else:
820
+ # A main component task finished — time to exit
821
+ break
822
+ finally:
823
+ if speaker is not None:
824
+ speaker.stop()
825
+
826
+ if playback_task is not None:
827
+ await playback_task
828
+
829
+ if monitor_task is not None:
830
+ monitor_task.cancel()
831
+
832
+ # Ensure all components are stopped
833
+ for component, _ in started_components:
834
+ await component.stop()
835
+
836
+ current_stream = active_stream_container["stream"]
837
+
838
+ if current_stream is not None:
839
+ current_stream.stop()
840
+
841
+ mqtt_client.set_availability(False)
842
+ mqtt_client.disconnect()