tinyjukebox 0.47.0__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.
Files changed (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,396 @@
1
+ """The logic of the bluetooth driver: exactly one paired headset, search and pairing, reconnecting, its volume and
2
+ buttons - on top of :class:`~tinyjukebox.devices.bluetooth.bluez.Bluez`. Runs in the event loop of the driver."""
3
+
4
+ import asyncio
5
+ import contextlib
6
+ import logging
7
+ from collections.abc import Callable, Coroutine
8
+ from typing import Any, Protocol
9
+
10
+ from tinyjukebox.core.logging import get_child
11
+ from tinyjukebox.devices.bluetooth.bluez import Bluez, BluezError, Device
12
+ from tinyjukebox.messages import (
13
+ BluetoothAction,
14
+ BluetoothCommand,
15
+ BluetoothDevice,
16
+ BluetoothState,
17
+ Button,
18
+ PairingResult,
19
+ PlaybackStatus,
20
+ )
21
+
22
+ __all__ = ["RECONNECT_INTERVAL", "SEARCH_TIME", "HeadsetLink", "Mixer", "MixerFactory", "button_of"]
23
+
24
+ RECONNECT_INTERVAL = 15.0
25
+ """Seconds between the attempts to reach the paired headset while it is not connected."""
26
+ SEARCH_TIME = 60.0
27
+ """Seconds a search for a headset in pairing mode lasts at most."""
28
+ COLLECT_TIME = 3.0
29
+ """Seconds the search goes on after the first headset has been found - the one with the strongest signal wins."""
30
+ DISCOVERY_RETRY = 1.0 # seconds until a refused start of the search is tried again
31
+ VOLUME_RETRIES = 10
32
+ VOLUME_RETRY_INTERVAL = 0.5 # seconds - the mixer of BlueALSA appears a moment after the headset has connected
33
+
34
+ _COMMANDS = {"Play": Button.PLAY, "Pause": Button.PAUSE, "Stop": Button.PAUSE, "Next": Button.NEXT,
35
+ "Previous": Button.PREV, "VolumeUp": Button.VOLUME_UP, "VolumeDown": Button.VOLUME_DOWN} # fmt: skip
36
+
37
+
38
+ def button_of(command: str, status: PlaybackStatus, toggle: bool) -> Button | None:
39
+ """The button for a command of the headset (``Play``, ``Pause``, ``PlayPause``, ``Stop``, ``Next``, ``Previous``,
40
+ ``VolumeUp``, ``VolumeDown``). ``PlayPause`` - and with ``toggle`` also ``Play`` and ``Pause`` - switch between
41
+ play and pause: some headsets send the same button for both."""
42
+ if command == "PlayPause" or (toggle and command in ("Play", "Pause")):
43
+ return Button.PAUSE if status is PlaybackStatus.PLAYING else Button.PLAY
44
+ return _COMMANDS.get(command)
45
+
46
+
47
+ class Mixer(Protocol):
48
+ """The volume control of the connected headset (blocking calls - used in a worker thread)."""
49
+
50
+ def set_volume(self, volume: float) -> bool:
51
+ """Set the volume in % - False if the mixer is not available (yet)."""
52
+
53
+ def watch(self, callback: Callable[[float], None]) -> None:
54
+ """Call ``callback`` (in another thread) with the volume in % whenever it changes."""
55
+
56
+ def stop_watching(self) -> None: ...
57
+
58
+
59
+ MixerFactory = Callable[[str], Mixer]
60
+ """address of the headset -> its mixer"""
61
+
62
+
63
+ class HeadsetLink:
64
+ """Keeps the connection to the one paired headset and publishes its :class:`~tinyjukebox.messages.BluetoothState`.
65
+
66
+ * **search** (:meth:`command` ``PAIR``): looks for audio outputs in pairing mode for :data:`SEARCH_TIME` seconds,
67
+ takes the one with the strongest signal, pairs, trusts and connects it - and only then removes all other
68
+ pairings. If the search fails, the old pairing stays.
69
+ * **reconnect**: tries to reach the paired headset every :data:`RECONNECT_INTERVAL` seconds.
70
+ * **connected** means that the headset can play (A2DP transport) - not just a bluetooth connection.
71
+ * **buttons** of the headset (:meth:`player_command`) are published as buttons of the box.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ bluez: Bluez,
77
+ mixer_factory: MixerFactory | None,
78
+ publish_state: Callable[[BluetoothState], object],
79
+ publish_button: Callable[[Button], object],
80
+ logger: logging.Logger,
81
+ play_pause_toggle: bool = False,
82
+ register_player: bool = True,
83
+ reconnect_interval: float = RECONNECT_INTERVAL,
84
+ search_time: float = SEARCH_TIME,
85
+ collect_time: float = COLLECT_TIME,
86
+ ) -> None:
87
+ self._logger = get_child(logger, type(self).__name__)
88
+ self._bluez = bluez
89
+ self._mixer_factory = mixer_factory
90
+ self._publish_state = publish_state
91
+ self._publish_button = publish_button
92
+ self._toggle = play_pause_toggle
93
+ self._register_player = register_player
94
+ self._reconnect_interval = reconnect_interval
95
+ self._search_time = search_time
96
+ self._collect_time = collect_time
97
+
98
+ self._headset: str | None = None # object path of the paired headset
99
+ self._candidate: str | None = None # object path of the headset that is being paired
100
+ self._pairing = False
101
+ self._removing = False # the pairing is being removed
102
+ self._result: PairingResult | None = None
103
+ self._reconnecting: asyncio.Task[None] | None = None
104
+ self._tasks: set[asyncio.Task[None]] = set()
105
+ self._changed = asyncio.Event()
106
+ self._mixer: Mixer | None = None
107
+ self._mixer_address: str | None = None
108
+ self._volume: float | None = None
109
+ self._wanted_volume: float | None = None # set by the box, not confirmed by the mixer yet
110
+ self._volume_task: asyncio.Task[None] | None = None
111
+ self._published: BluetoothState | None = None
112
+ self._running = False # connected to BlueZ - commands are accepted
113
+ self.playback = PlaybackStatus.STOPPED
114
+
115
+ # --- agent policy / player commands (called by Bluez) ---------------------------------------------------------
116
+
117
+ def allows_pairing(self, device_path: str) -> bool:
118
+ return device_path == self._candidate
119
+
120
+ def allows_service(self, device_path: str) -> bool:
121
+ return device_path in (self._headset, self._candidate)
122
+
123
+ def player_command(self, command: str) -> None:
124
+ button = button_of(command, self.playback, self._toggle)
125
+ self._logger.info("player_command - %s -> %s", command, button)
126
+ if button is not None:
127
+ self._publish_button(button)
128
+
129
+ # --- life cycle -----------------------------------------------------------------------------------------------
130
+
131
+ async def run(self, stop: asyncio.Event) -> None:
132
+ """Connect to BlueZ and keep the headset connected until ``stop`` is set.
133
+
134
+ :raises BluezError: if BlueZ or the adapter is missing."""
135
+ self._bluez.on_change = self._on_change
136
+ try:
137
+ await self._bluez.connect(self, self if self._register_player else None)
138
+ self._running = True
139
+ self._headset = self._paired_headset(warn=True)
140
+ self._update()
141
+ while not stop.is_set():
142
+ if self._reconnecting is None or self._reconnecting.done():
143
+ self._reconnecting = self._spawn(self._reconnect())
144
+ with contextlib.suppress(TimeoutError):
145
+ await asyncio.wait_for(stop.wait(), self._reconnect_interval)
146
+ finally:
147
+ self._running = False
148
+ self._bluez.on_change = None
149
+ while self._tasks: # ending tasks may start others (e.g. the mixer follows the state)
150
+ tasks = list(self._tasks)
151
+ for task in tasks:
152
+ task.cancel()
153
+ await asyncio.gather(*tasks, return_exceptions=True)
154
+ self._mixer_address = None
155
+ await self._set_mixer(None)
156
+ with contextlib.suppress(BluezError, TimeoutError, OSError):
157
+ await self._bluez.stop_discovery()
158
+ await self._bluez.close()
159
+
160
+ def _paired_headset(self, warn: bool = False) -> str | None:
161
+ """The headset BlueZ has paired: the (first) paired audio output."""
162
+ paired = [device for device in self._bluez.devices() if device.paired]
163
+ paired.sort(key=lambda device: (not device.is_audio_sink, device.path))
164
+ if warn and len(paired) > 1:
165
+ self._logger.warning(
166
+ "%s paired devices - using %s (a new pairing removes the others)", len(paired), paired[0].address
167
+ )
168
+ return paired[0].path if paired else None
169
+
170
+ def _device(self, path: str | None) -> Device | None:
171
+ return next((device for device in self._bluez.devices() if device.path == path), None)
172
+
173
+ def _spawn(self, coroutine: Coroutine[Any, Any, None]) -> "asyncio.Task[None]":
174
+ task = asyncio.get_running_loop().create_task(coroutine)
175
+ self._tasks.add(task)
176
+ task.add_done_callback(self._task_done)
177
+ return task
178
+
179
+ def _task_done(self, task: "asyncio.Task[None]") -> None:
180
+ self._tasks.discard(task)
181
+ if not task.cancelled() and task.exception() is not None:
182
+ self._logger.error("task failed", exc_info=task.exception())
183
+
184
+ # --- state ----------------------------------------------------------------------------------------------------
185
+
186
+ def _on_change(self) -> None:
187
+ """Something changed in BlueZ (called by Bluez in the event loop)."""
188
+ self._changed.set()
189
+ self._update()
190
+
191
+ def _update(self) -> None:
192
+ """Publish the state if it changed and follow the connection with the mixer."""
193
+ if not self._running:
194
+ return
195
+ headset = self._device(self._headset)
196
+ if headset is None and not self._pairing and not self._removing:
197
+ # removed or paired outside the box (bluetoothctl), or BlueZ has been restarted
198
+ self._headset = self._paired_headset()
199
+ headset = self._device(self._headset)
200
+ connected = headset is not None and headset.connected and headset.audio
201
+ address = headset.address if headset is not None and connected else None
202
+ if address != self._mixer_address:
203
+ self._mixer_address = address
204
+ self._volume = None
205
+ if address is None:
206
+ self._wanted_volume = None
207
+ self._spawn(self._set_mixer(address))
208
+ state = BluetoothState(
209
+ device=BluetoothDevice(headset.address, headset.name) if headset is not None else None,
210
+ connected=connected,
211
+ battery=headset.battery if headset is not None else None,
212
+ volume=self._volume if connected else None,
213
+ pairing=self._pairing,
214
+ pairing_result=self._result,
215
+ )
216
+ if state != self._published:
217
+ self._published = state
218
+ self._publish_state(state)
219
+
220
+ async def _set_mixer(self, address: str | None) -> None:
221
+ """Watch the volume control of the connected headset (``None``: stop watching)."""
222
+ loop = asyncio.get_running_loop()
223
+ mixer, self._mixer = self._mixer, None
224
+ if mixer is not None:
225
+ await loop.run_in_executor(None, mixer.stop_watching)
226
+ if address is None or self._mixer_factory is None or address != self._mixer_address:
227
+ return
228
+ mixer = self._mixer_factory(address)
229
+ self._mixer = mixer
230
+
231
+ def reported(volume: float) -> None: # in the watch thread of the mixer
232
+ loop.call_soon_threadsafe(self._volume_reported, address, volume)
233
+
234
+ mixer.watch(reported)
235
+ if self._wanted_volume is not None and (self._volume_task is None or self._volume_task.done()):
236
+ self._volume_task = self._spawn(self._set_volume()) # commanded before the mixer was there
237
+
238
+ def _volume_reported(self, address: str, volume: float) -> None:
239
+ if address == self._mixer_address:
240
+ self._volume = volume
241
+ self._update()
242
+
243
+ # --- commands -------------------------------------------------------------------------------------------------
244
+
245
+ def command(self, command: BluetoothCommand) -> None:
246
+ """A command of the box (called in the event loop)."""
247
+ self._logger.info("command - %s", command)
248
+ if not self._running:
249
+ self._logger.warning("command - ignored: no connection to BlueZ")
250
+ return
251
+ if command.action is BluetoothAction.PAIR:
252
+ if self._pairing:
253
+ self._logger.info("a search is running already")
254
+ return
255
+ self._pairing = True # before the task starts - a second command is ignored
256
+ self._result = None
257
+ self._spawn(self._pair())
258
+ self._update()
259
+ elif command.action is BluetoothAction.UNPAIR:
260
+ self._spawn(self._unpair())
261
+ elif command.action is BluetoothAction.VOLUME and command.volume is not None:
262
+ self._wanted_volume = command.volume
263
+ if self._mixer is not None and (self._volume_task is None or self._volume_task.done()):
264
+ self._volume_task = self._spawn(self._set_volume())
265
+
266
+ async def _set_volume(self) -> None:
267
+ """Set the wanted volume on the mixer of the connected headset - again if the mixer is not there yet, and
268
+ always the latest one."""
269
+ loop = asyncio.get_running_loop()
270
+ failures = 0
271
+ while (volume := self._wanted_volume) is not None and (mixer := self._mixer) is not None:
272
+ if await loop.run_in_executor(None, mixer.set_volume, volume):
273
+ failures = 0
274
+ if self._wanted_volume == volume:
275
+ self._wanted_volume = None
276
+ if mixer is self._mixer: # known now - even if the report of the mixer has been missed
277
+ self._volume = volume
278
+ self._update()
279
+ continue
280
+ failures += 1
281
+ if failures >= VOLUME_RETRIES:
282
+ self._logger.error("the volume of the headset cannot be set")
283
+ return
284
+ await asyncio.sleep(VOLUME_RETRY_INTERVAL)
285
+
286
+ def playback_status(self, status: PlaybackStatus) -> None:
287
+ """What the box plays (called in the event loop) - reported to the headset."""
288
+ self.playback = status
289
+ self._bluez.set_playback_status(status.value)
290
+
291
+ async def _unpair(self) -> None:
292
+ headset = self._headset
293
+ if headset is None or self._removing:
294
+ return
295
+ self._removing = True
296
+ try:
297
+ await self._bluez.remove_device(headset)
298
+ self._headset = None
299
+ except (BluezError, TimeoutError) as e:
300
+ self._logger.error("removing the headset failed: %s", e)
301
+ finally:
302
+ self._removing = False
303
+ self._update()
304
+
305
+ async def _reconnect(self) -> None:
306
+ headset = self._device(self._headset)
307
+ if headset is None or self._pairing or (headset.connected and headset.audio):
308
+ return
309
+ try:
310
+ await self._bluez.connect_device(headset.path)
311
+ except (BluezError, TimeoutError) as e:
312
+ self._logger.debug("headset %s not reachable: %s", headset.address, e) # switched off or out of range
313
+
314
+ # --- search and pairing ---------------------------------------------------------------------------------------
315
+
316
+ async def _pair(self) -> None:
317
+ loop = asyncio.get_running_loop()
318
+ deadline = loop.time() + self._search_time
319
+ result = PairingResult.FAILED
320
+ try:
321
+ await self._start_discovery(deadline)
322
+ found = await self._find(deadline)
323
+ if found is not None and found.paired:
324
+ # paired with the box already (e.g. the same headset after it has been reset): pair it anew
325
+ self._logger.info("%s is paired already - removing the old pairing", found.address)
326
+ if found.path == self._headset:
327
+ self._headset = None
328
+ await self._bluez.remove_device(found.path)
329
+ found = await self._find(deadline, found.address)
330
+ if found is None:
331
+ result = PairingResult.NOT_FOUND
332
+ return
333
+ await self._bluez.stop_discovery() # pairing while searching is unreliable
334
+ self._logger.info("pairing %s (%s), signal %s dBm", found.name, found.address, found.rssi)
335
+ self._candidate = found.path
336
+ await self._bluez.pair(found.path)
337
+ await self._bluez.set_trusted(found.path)
338
+ try:
339
+ await self._bluez.connect_device(found.path)
340
+ except (BluezError, TimeoutError) as e:
341
+ self._logger.warning("paired, but not connected yet: %s", e) # the reconnect tries again
342
+ # only now the old pairing goes - and everything else that has ever been paired (one headset only)
343
+ for device in self._bluez.devices():
344
+ if device.paired and device.path != found.path:
345
+ self._logger.info("removing the pairing of %s (%s)", device.name, device.address)
346
+ with contextlib.suppress(BluezError, TimeoutError):
347
+ await self._bluez.remove_device(device.path)
348
+ self._headset = found.path
349
+ result = PairingResult.PAIRED
350
+ except (BluezError, TimeoutError) as e:
351
+ self._logger.error("pairing failed: %s", e)
352
+ finally:
353
+ with contextlib.suppress(BluezError, TimeoutError, OSError):
354
+ await self._bluez.stop_discovery()
355
+ self._candidate = None
356
+ self._pairing = False
357
+ self._result = result
358
+ self._logger.info("search ended: %s", result.value)
359
+ self._update()
360
+
361
+ async def _start_discovery(self, deadline: float) -> None:
362
+ """Start the search - again and again until ``deadline`` if BlueZ refuses (e.g. while it still tries to reach
363
+ the old headset)."""
364
+ loop = asyncio.get_running_loop()
365
+ while True:
366
+ try:
367
+ await self._bluez.start_discovery()
368
+ return
369
+ except BluezError as e:
370
+ if loop.time() + DISCOVERY_RETRY >= deadline:
371
+ raise
372
+ self._logger.info("the search cannot start yet: %s", e)
373
+ await asyncio.sleep(DISCOVERY_RETRY)
374
+
375
+ async def _find(self, deadline: float, address: str | None = None) -> Device | None:
376
+ """The audio output in pairing mode with the strongest signal (or the one with ``address``) - ``None`` if
377
+ none shows up until ``deadline``."""
378
+ loop = asyncio.get_running_loop()
379
+ first_seen: float | None = None
380
+ while True:
381
+ self._changed.clear()
382
+ now = loop.time()
383
+ candidates = [
384
+ device
385
+ for device in self._bluez.devices()
386
+ if device.discovered and device.is_audio_sink and address in (None, device.address)
387
+ ]
388
+ if candidates:
389
+ first_seen = now if first_seen is None else first_seen
390
+ if address is not None or now - first_seen >= self._collect_time or now >= deadline:
391
+ return max(candidates, key=lambda device: device.rssi if device.rssi is not None else -1000)
392
+ if now >= deadline:
393
+ return None
394
+ wake = deadline if first_seen is None else min(deadline, first_seen + self._collect_time)
395
+ with contextlib.suppress(TimeoutError):
396
+ await asyncio.wait_for(self._changed.wait(), max(0.0, wake - now))
@@ -0,0 +1,155 @@
1
+ """Virtual headset for running without hardware (``--no-hardware``): behaves like the bluetooth driver on the pubsub
2
+ side - the whole headset logic of the box can be tried on the pc."""
3
+
4
+ import dataclasses
5
+ import logging
6
+ import threading
7
+ from collections.abc import Callable
8
+
9
+ from tinyjukebox.core.logging import get_child
10
+ from tinyjukebox.core.pubsub import PubSubClient
11
+ from tinyjukebox.core.ui import Command
12
+ from tinyjukebox.core.ui.tools import parse
13
+ from tinyjukebox.messages import (
14
+ BLUETOOTH_COMMAND,
15
+ BLUETOOTH_STATE,
16
+ BUTTON_PRESSED,
17
+ BluetoothAction,
18
+ BluetoothCommand,
19
+ BluetoothDevice,
20
+ BluetoothState,
21
+ Button,
22
+ PairingResult,
23
+ )
24
+
25
+ __all__ = ["VIRTUAL_DEVICE", "VirtualHeadset"]
26
+
27
+ VIRTUAL_DEVICE = BluetoothDevice("00:00:00:00:00:01", "Virtual headset")
28
+ SEARCH_TIME = 2.0 # seconds a (always successful) search takes
29
+ VOLUME_STEP = 6.0 # % per press of a volume button of the headset
30
+
31
+
32
+ class VirtualHeadset:
33
+ """Publishes :class:`~tinyjukebox.messages.BluetoothState` and the buttons of the headset like the bluetooth driver.
34
+
35
+ * ``PAIR``: after :data:`SEARCH_TIME` seconds the virtual headset is paired and connected,
36
+ * ``UNPAIR``: it is gone, ``VOLUME``: it takes the volume,
37
+ * :meth:`switch`: the headset is switched on (connects if paired) or off,
38
+ * :meth:`press`: a button of the headset - the volume buttons change its own volume (like a headset with
39
+ absolute volume; the box follows and limits it).
40
+ """
41
+
42
+ def __init__(self, pubsub_client: PubSubClient, logger: logging.Logger, search_time: float = SEARCH_TIME) -> None:
43
+ self._logger = get_child(logger, type(self).__name__)
44
+ self._pubsub_client = pubsub_client
45
+ self._search_time = search_time
46
+ self._lock = threading.RLock() # publishing may deliver a command at once (synchronous pubsub client)
47
+ self._state = BluetoothState(virtual=True)
48
+ self._switched_on = True
49
+ self._timer: threading.Timer | None = None
50
+ self.ui_commands: dict[str, Command] = {
51
+ "virtual_headset": self._cmd_switch,
52
+ "virtual_headset_button": self._cmd_button,
53
+ }
54
+
55
+ @property
56
+ def state(self) -> BluetoothState:
57
+ return self._state
58
+
59
+ def start(self) -> None:
60
+ self._pubsub_client.subscribe(BLUETOOTH_COMMAND, self._command_handler)
61
+ self._publish(lambda state: state)
62
+
63
+ def stop(self) -> None:
64
+ self._pubsub_client.unsubscribe(BLUETOOTH_COMMAND, self._command_handler, ignore_not_found=True)
65
+ with self._lock:
66
+ if self._timer is not None:
67
+ self._timer.cancel()
68
+ self._timer = None
69
+
70
+ def _publish(self, change: Callable[[BluetoothState], BluetoothState]) -> None:
71
+ with self._lock:
72
+ self._state = change(self._state)
73
+ self._pubsub_client.publish(BLUETOOTH_STATE, self._state)
74
+
75
+ def _command_handler(self, message: object) -> None:
76
+ if not isinstance(message, BluetoothCommand):
77
+ self._logger.error("expected a BluetoothCommand, received %r", message)
78
+ return
79
+ self._logger.info("command %s", message)
80
+ if message.action is BluetoothAction.PAIR:
81
+ with self._lock:
82
+ if self._state.pairing:
83
+ return
84
+ self._timer = threading.Timer(self._search_time, self._paired)
85
+ self._timer.name = "VirtualHeadset.search"
86
+ self._timer.daemon = True
87
+ self._timer.start()
88
+ self._publish(lambda state: dataclasses.replace(state, pairing=True, pairing_result=None))
89
+ elif message.action is BluetoothAction.UNPAIR:
90
+ self._publish(
91
+ lambda state: dataclasses.replace(state, device=None, connected=False, battery=None, volume=None)
92
+ )
93
+ elif message.action is BluetoothAction.VOLUME and message.volume is not None:
94
+ volume = message.volume
95
+ self._publish(lambda state: dataclasses.replace(state, volume=volume) if state.connected else state)
96
+
97
+ def _paired(self) -> None:
98
+ self._switched_on = True
99
+ self._publish(
100
+ lambda state: dataclasses.replace(
101
+ state, device=VIRTUAL_DEVICE, connected=True, battery=80, pairing=False,
102
+ pairing_result=PairingResult.PAIRED,
103
+ )
104
+ ) # fmt: skip
105
+
106
+ def switch(self, on: bool) -> None:
107
+ """Switch the headset on or off - it connects if it is paired."""
108
+ self._switched_on = on
109
+ self._publish(
110
+ lambda state: dataclasses.replace(
111
+ state,
112
+ connected=on and state.device is not None,
113
+ battery=80 if on and state.device is not None else None,
114
+ volume=state.volume if on and state.connected else None,
115
+ )
116
+ )
117
+
118
+ def press(self, button: Button) -> None:
119
+ """A button of the headset."""
120
+ if not self._state.connected:
121
+ self._logger.info("button %s ignored - the headset is not connected", button)
122
+ return
123
+ if button in (Button.VOLUME_UP, Button.VOLUME_DOWN):
124
+ step = VOLUME_STEP if button is Button.VOLUME_UP else -VOLUME_STEP
125
+ self._publish(
126
+ lambda state: dataclasses.replace(state, volume=min(100.0, max(0.0, (state.volume or 0) + step)))
127
+ )
128
+ else:
129
+ self._pubsub_client.publish(BUTTON_PRESSED, button)
130
+
131
+ # --- console --------------------------------------------------------------------------------------------------
132
+
133
+ def _cmd_switch(self, args: str) -> None:
134
+ """virtual_headset - switch the virtual headset on or off: VIRTUAL_HEADSET on|off"""
135
+ words = [word.lower() for word in parse(args)]
136
+ if words not in (["on"], ["off"]):
137
+ print("expected 'VIRTUAL_HEADSET on|off'.")
138
+ return
139
+ self.switch(words == ["on"])
140
+ device = self._state.device
141
+ print(f"virtual headset switched {words[0]}" + ("" if device else " - it is not paired (bluetooth_pair)."))
142
+
143
+ def _cmd_button(self, args: str) -> None:
144
+ """virtual_headset_button - press a button of the virtual headset:
145
+ VIRTUAL_HEADSET_BUTTON play|pause|next|prev|volume-up|volume-down"""
146
+ words = [word.lower() for word in parse(args)]
147
+ buttons = {button.value: button for button in Button}
148
+ if len(words) != 1 or words[0] not in buttons:
149
+ print(f"expected 'VIRTUAL_HEADSET_BUTTON {'|'.join(buttons)}'.")
150
+ return
151
+ if not self._state.connected:
152
+ print("the virtual headset is not connected.")
153
+ return
154
+ self.press(buttons[words[0]])
155
+ print(f"pressed {words[0]}.")
@@ -0,0 +1 @@
1
+ """DriverManager and DriverFactory."""
@@ -0,0 +1,75 @@
1
+ """Creates driver instances from the config section ``drivers``."""
2
+
3
+ import logging
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
7
+ from tinyjukebox.core.logging import get_child
8
+ from tinyjukebox.core.pubsub import PubSubClient
9
+ from tinyjukebox.devices.baseclasses.adriver import ADriver
10
+ from tinyjukebox.devices.drivers import get_driver_class
11
+
12
+ __all__ = ["DriverFactory"]
13
+
14
+
15
+ class DriverFactory:
16
+ """Creates drivers that share one pubsub client and one logger."""
17
+
18
+ def __init__(self, pubsub_client: PubSubClient, logger: logging.Logger) -> None:
19
+ self._pubsub_client = pubsub_client
20
+ self._logger = logger
21
+ self._factory_logger = get_child(logger, type(self).__name__)
22
+ self._config: Mapping[str, Any] = {}
23
+
24
+ def create_drivers(self, config: Mapping[str, Any]) -> dict[str, ADriver]:
25
+ """Create all active drivers of ``config["drivers"]``: ``{unique id: driver}``.
26
+
27
+ :param config: the complete config.
28
+ """
29
+ self._config = config
30
+ drivers: dict[str, ADriver] = {}
31
+ for driver_config in config["drivers"]:
32
+ if not driver_config["active"]:
33
+ continue
34
+ driver = self.create_driver(driver_config)
35
+ driver_id = self.get_unique_name(drivers, driver.name)
36
+ drivers[driver_id] = driver
37
+ self._factory_logger.info(" - added driver '[%s]: %s.%s'", driver_id, driver.driver_type, driver.name)
38
+ return drivers
39
+
40
+ @staticmethod
41
+ def get_unique_name(drivers: Mapping[str, Any], name: str) -> str:
42
+ """``name`` in lower case without spaces; ``_0``, ``_1`` ... is appended if it is already taken."""
43
+ prefix = name.replace(" ", "").lower()
44
+ unique = prefix
45
+ counter = 0
46
+ while unique in drivers:
47
+ unique = f"{prefix}_{counter}"
48
+ counter += 1
49
+ return unique
50
+
51
+ def create_driver(self, driver_config: Mapping[str, Any]) -> ADriver:
52
+ """Create the driver for one entry of the ``drivers`` list.
53
+
54
+ :raises ValueError: for unknown driver types.
55
+ """
56
+ driver_type = driver_config["type"]
57
+ try:
58
+ driver_class = get_driver_class(driver_type)
59
+ except ValueError:
60
+ self._factory_logger.error("unknown driver type '%s'.", driver_type)
61
+ raise
62
+ config = {**self._config, "driver": driver_config}
63
+ driver: ADriver = driver_class(config, self._pubsub_client, self._logger)
64
+ return driver
65
+
66
+ def reload_driver(self, drivers: dict[str, ADriver], driver_id: str) -> None:
67
+ """Stop the driver, replace it with a new instance created from its config and start the new one."""
68
+ driver = drivers[driver_id]
69
+ self._factory_logger.info("reloading driver '%s' - %s", driver_id, driver.get_short_info())
70
+ if not driver.is_stopped:
71
+ driver.stop()
72
+ new_driver = self.create_driver(driver.config)
73
+ drivers[driver_id] = new_driver
74
+ new_driver.start()
75
+ self._factory_logger.info("reloading driver '%s' - done", driver_id)