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.
- oremi_device/__init__.py +842 -0
- oremi_device/__main__.py +23 -0
- oremi_device/components/__init__.py +35 -0
- oremi_device/components/andika.py +370 -0
- oremi_device/components/base.py +176 -0
- oremi_device/components/ohunerin.py +313 -0
- oremi_device/core/args.py +181 -0
- oremi_device/core/audio.py +265 -0
- oremi_device/core/detector.py +114 -0
- oremi_device/core/listener.py +53 -0
- oremi_device/core/models.py +156 -0
- oremi_device/core/mqtt.py +424 -0
- oremi_device/core/package.py +34 -0
- oremi_device/core/speaker.py +223 -0
- oremi_device/utils/logger.py +55 -0
- oremi_device/utils/string.py +29 -0
- oremi_device-1.0.0b1.dist-info/METADATA +74 -0
- oremi_device-1.0.0b1.dist-info/RECORD +22 -0
- oremi_device-1.0.0b1.dist-info/WHEEL +4 -0
- oremi_device-1.0.0b1.dist-info/entry_points.txt +2 -0
- oremi_device-1.0.0b1.dist-info/licenses/LICENSE +201 -0
- oremi_device-1.0.0b1.dist-info/licenses/LICENSE_HEADER.txt +14 -0
oremi_device/__init__.py
ADDED
|
@@ -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()
|