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.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- 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)
|