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,16 @@
|
|
|
1
|
+
"""Mapping between logical output states (closed/open) and physical GPIO levels."""
|
|
2
|
+
|
|
3
|
+
__all__ = ["closed_level"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def closed_level(physical_closed: str, entry_name: str = "physical-closed") -> int:
|
|
7
|
+
"""GPIO level (0/1) that closes an output: config value ``low`` → 0, ``high`` → 1.
|
|
8
|
+
|
|
9
|
+
:raises ValueError: for any other value.
|
|
10
|
+
"""
|
|
11
|
+
value = str(physical_closed).lower()
|
|
12
|
+
if value == "low":
|
|
13
|
+
return 0
|
|
14
|
+
if value == "high":
|
|
15
|
+
return 1
|
|
16
|
+
raise ValueError(f"'{entry_name}' - expected 'low'/'high' but received '{physical_closed}'.")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Parts of the bluetooth driver: BlueZ client (D-Bus), headset link (logic), AVRCP input and the virtual headset."""
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The buttons of the headset as linux input device: without a registered media player BlueZ passes the AVRCP buttons
|
|
2
|
+
to an input device named '<headset> (AVRCP)'. Read with the standard library only (no python-evdev)."""
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import glob
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import select
|
|
9
|
+
import struct
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from tinyjukebox.core.logging import get_child
|
|
14
|
+
from tinyjukebox.core.threads import LoggerThread
|
|
15
|
+
|
|
16
|
+
__all__ = ["KEYS", "AvrcpInput"]
|
|
17
|
+
|
|
18
|
+
_EVENT = struct.Struct("llHHi") # struct input_event: timeval, type, code, value (native sizes)
|
|
19
|
+
_EV_KEY = 1
|
|
20
|
+
_PRESSED = 1
|
|
21
|
+
KEYS = {
|
|
22
|
+
200: "Play", # KEY_PLAYCD
|
|
23
|
+
201: "Pause", # KEY_PAUSECD
|
|
24
|
+
164: "PlayPause", # KEY_PLAYPAUSE
|
|
25
|
+
166: "Stop", # KEY_STOPCD
|
|
26
|
+
163: "Next", # KEY_NEXTSONG
|
|
27
|
+
165: "Previous", # KEY_PREVIOUSSONG
|
|
28
|
+
115: "VolumeUp", # KEY_VOLUMEUP
|
|
29
|
+
114: "VolumeDown", # KEY_VOLUMEDOWN
|
|
30
|
+
}
|
|
31
|
+
"""key code -> command of the headset (as the methods of the media player, plus the volume buttons)"""
|
|
32
|
+
SCAN_INTERVAL = 2.0 # seconds between two looks for the input device while there is none
|
|
33
|
+
POLL_MS = 500 # the thread checks for stop at least this often
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AvrcpInput:
|
|
37
|
+
"""Reads the input device whose name ends with ``(AVRCP)`` and calls ``on_command`` with the command of every
|
|
38
|
+
pressed button (in the reader thread). The device comes and goes with the connection of the headset - the reader
|
|
39
|
+
looks for it every :data:`SCAN_INTERVAL` seconds. The user of the service needs read access (group ``input``)."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
on_command: Callable[[str], object],
|
|
44
|
+
logger: logging.Logger,
|
|
45
|
+
sys_folder: str = "/sys/class/input",
|
|
46
|
+
dev_folder: str = "/dev/input",
|
|
47
|
+
) -> None:
|
|
48
|
+
self._on_command = on_command
|
|
49
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
50
|
+
self._sys_folder = sys_folder
|
|
51
|
+
self._dev_folder = dev_folder
|
|
52
|
+
self._stop = threading.Event()
|
|
53
|
+
self._thread: LoggerThread | None = None
|
|
54
|
+
|
|
55
|
+
def start(self) -> None:
|
|
56
|
+
self._stop.clear()
|
|
57
|
+
self._thread = LoggerThread(target=self._run, name="AvrcpInput", logger=self._logger, daemon=True)
|
|
58
|
+
self._thread.start()
|
|
59
|
+
|
|
60
|
+
def stop(self) -> None:
|
|
61
|
+
self._stop.set()
|
|
62
|
+
if self._thread is not None:
|
|
63
|
+
self._thread.join()
|
|
64
|
+
self._thread = None
|
|
65
|
+
|
|
66
|
+
def find_device(self) -> str | None:
|
|
67
|
+
"""Path of the AVRCP input device (``/dev/input/eventN``) or None."""
|
|
68
|
+
for name_file in sorted(glob.glob(os.path.join(self._sys_folder, "event*", "device", "name"))):
|
|
69
|
+
try:
|
|
70
|
+
with open(name_file, encoding="utf-8", errors="replace") as f:
|
|
71
|
+
name = f.read().strip()
|
|
72
|
+
except OSError:
|
|
73
|
+
continue
|
|
74
|
+
if name.endswith("(AVRCP)"):
|
|
75
|
+
event = os.path.basename(os.path.dirname(os.path.dirname(name_file)))
|
|
76
|
+
return os.path.join(self._dev_folder, event)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def _run(self) -> None:
|
|
80
|
+
failed: str | None = None
|
|
81
|
+
while not self._stop.is_set():
|
|
82
|
+
device = self.find_device()
|
|
83
|
+
if device is not None:
|
|
84
|
+
try:
|
|
85
|
+
self._read(device)
|
|
86
|
+
failed = None
|
|
87
|
+
except OSError as e: # the device has gone with the headset - or may not be read
|
|
88
|
+
if failed != str(e):
|
|
89
|
+
failed = str(e)
|
|
90
|
+
self._logger.warning("reading %s ended: %s", device, e)
|
|
91
|
+
self._stop.wait(SCAN_INTERVAL)
|
|
92
|
+
|
|
93
|
+
def _read(self, device: str) -> None:
|
|
94
|
+
fd = os.open(device, os.O_RDONLY | os.O_NONBLOCK)
|
|
95
|
+
self._logger.info("reading the buttons of the headset from %s", device)
|
|
96
|
+
try:
|
|
97
|
+
poller = select.poll()
|
|
98
|
+
poller.register(fd, select.POLLIN)
|
|
99
|
+
buffer = b""
|
|
100
|
+
while not self._stop.is_set():
|
|
101
|
+
events = poller.poll(POLL_MS)
|
|
102
|
+
if not events:
|
|
103
|
+
continue
|
|
104
|
+
if events[0][1] & (select.POLLHUP | select.POLLERR) and not events[0][1] & select.POLLIN:
|
|
105
|
+
raise OSError("device closed")
|
|
106
|
+
with contextlib.suppress(BlockingIOError):
|
|
107
|
+
data = os.read(fd, _EVENT.size * 64)
|
|
108
|
+
if not data:
|
|
109
|
+
raise OSError("device closed")
|
|
110
|
+
buffer += data
|
|
111
|
+
while len(buffer) >= _EVENT.size:
|
|
112
|
+
_, _, kind, code, value = _EVENT.unpack_from(buffer)
|
|
113
|
+
buffer = buffer[_EVENT.size :]
|
|
114
|
+
if kind == _EV_KEY and value == _PRESSED and code in KEYS:
|
|
115
|
+
self._logger.info("button %s (%s)", KEYS[code], code)
|
|
116
|
+
self._on_command(KEYS[code])
|
|
117
|
+
finally:
|
|
118
|
+
os.close(fd)
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""BlueZ on the system D-Bus (dbus-fast, asyncio): the objects of BlueZ as a cache that follows its signals, the calls
|
|
2
|
+
the headset link needs, and the two objects BlueZ calls back - the pairing agent and the media player (AVRCP).
|
|
3
|
+
|
|
4
|
+
Everything runs in the event loop of the bluetooth driver; ``on_change`` is called there after every change.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import contextlib
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from dbus_fast import BusType, Message, MessageType, Variant, unpack_variants
|
|
15
|
+
from dbus_fast.aio import MessageBus
|
|
16
|
+
|
|
17
|
+
from tinyjukebox.core.logging import get_child
|
|
18
|
+
|
|
19
|
+
__all__ = ["A2DP_SINK_UUID", "AgentPolicy", "Bluez", "BluezError", "DbusBluez", "Device", "PlayerCommands"]
|
|
20
|
+
|
|
21
|
+
BLUEZ = "org.bluez"
|
|
22
|
+
ADAPTER = "org.bluez.Adapter1"
|
|
23
|
+
DEVICE = "org.bluez.Device1"
|
|
24
|
+
BATTERY = "org.bluez.Battery1"
|
|
25
|
+
TRANSPORT = "org.bluez.MediaTransport1"
|
|
26
|
+
MEDIA = "org.bluez.Media1"
|
|
27
|
+
AGENT_MANAGER = "org.bluez.AgentManager1"
|
|
28
|
+
AGENT = "org.bluez.Agent1"
|
|
29
|
+
PLAYER = "org.mpris.MediaPlayer2.Player"
|
|
30
|
+
PROPERTIES = "org.freedesktop.DBus.Properties"
|
|
31
|
+
OBJECT_MANAGER = "org.freedesktop.DBus.ObjectManager"
|
|
32
|
+
DBUS = "org.freedesktop.DBus"
|
|
33
|
+
DBUS_PATH = "/org/freedesktop/DBus"
|
|
34
|
+
|
|
35
|
+
AGENT_PATH = "/org/tinyjukebox/agent"
|
|
36
|
+
PLAYER_PATH = "/org/tinyjukebox/player"
|
|
37
|
+
REJECTED = "org.bluez.Error.Rejected"
|
|
38
|
+
PIN = "0000" # for headsets that ask for a pin after all (Sony's default)
|
|
39
|
+
|
|
40
|
+
A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb"
|
|
41
|
+
_MAJOR_AUDIO_VIDEO = 0x04 # major device class (bits 8-12 of the class of device)
|
|
42
|
+
_SERVICE_RENDERING_OR_AUDIO = 0x240000 # service classes rendering (bit 18) and audio (bit 21)
|
|
43
|
+
|
|
44
|
+
CALL_TIMEOUT = 10.0 # seconds for ordinary calls
|
|
45
|
+
SETUP_RETRY = 5.0 # seconds until the registration is tried again after BlueZ has been restarted
|
|
46
|
+
PAIR_TIMEOUT = 40.0
|
|
47
|
+
CONNECT_TIMEOUT = 25.0 # paging a headset that is switched off takes BlueZ about 5-20 s
|
|
48
|
+
|
|
49
|
+
_MATCH_RULES = (
|
|
50
|
+
f"type='signal',sender='{BLUEZ}',interface='{PROPERTIES}',member='PropertiesChanged'",
|
|
51
|
+
f"type='signal',sender='{BLUEZ}',interface='{OBJECT_MANAGER}'",
|
|
52
|
+
f"type='signal',sender='{DBUS}',interface='{DBUS}',member='NameOwnerChanged',arg0='{BLUEZ}'",
|
|
53
|
+
)
|
|
54
|
+
_PLAYBACK_STATUS = {"playing": "Playing", "paused": "Paused", "stopped": "Stopped"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BluezError(Exception):
|
|
58
|
+
"""A call to BlueZ failed - ``name``: D-Bus error name (e.g. ``org.bluez.Error.AuthenticationFailed``)."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, name: str, text: str = "") -> None:
|
|
61
|
+
super().__init__(f"{name}: {text}" if text else name)
|
|
62
|
+
self.name = name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Device:
|
|
67
|
+
"""A bluetooth device as BlueZ knows it."""
|
|
68
|
+
|
|
69
|
+
path: str
|
|
70
|
+
address: str
|
|
71
|
+
name: str
|
|
72
|
+
paired: bool = False
|
|
73
|
+
trusted: bool = False
|
|
74
|
+
connected: bool = False
|
|
75
|
+
rssi: int | None = None
|
|
76
|
+
uuids: tuple[str, ...] = ()
|
|
77
|
+
device_class: int | None = None
|
|
78
|
+
battery: int | None = None
|
|
79
|
+
audio: bool = False # an A2DP transport exists - the headset can play
|
|
80
|
+
discovered: bool = False # seen by the running (or last) search
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_audio_sink(self) -> bool:
|
|
84
|
+
"""A headset or speaker: A2DP sink among its services or - before they are known - by its device class."""
|
|
85
|
+
if A2DP_SINK_UUID in self.uuids:
|
|
86
|
+
return True
|
|
87
|
+
if self.device_class is None:
|
|
88
|
+
return False
|
|
89
|
+
major = (self.device_class >> 8) & 0x1F
|
|
90
|
+
return major == _MAJOR_AUDIO_VIDEO and bool(self.device_class & _SERVICE_RENDERING_OR_AUDIO)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AgentPolicy(Protocol):
|
|
94
|
+
"""Decides the questions BlueZ asks the agent."""
|
|
95
|
+
|
|
96
|
+
def allows_pairing(self, device_path: str) -> bool:
|
|
97
|
+
"""True for the headset that is being paired right now."""
|
|
98
|
+
|
|
99
|
+
def allows_service(self, device_path: str) -> bool:
|
|
100
|
+
"""True for the headset of the box - nobody else may connect."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PlayerCommands(Protocol):
|
|
104
|
+
def player_command(self, command: str) -> None:
|
|
105
|
+
"""A button of the headset: ``Play``, ``Pause``, ``PlayPause``, ``Stop``, ``Next`` or ``Previous``."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Bluez(Protocol):
|
|
109
|
+
"""What the headset link needs of BlueZ (:class:`DbusBluez`, or a fake in tests)."""
|
|
110
|
+
|
|
111
|
+
on_change: Callable[[], None] | None
|
|
112
|
+
|
|
113
|
+
async def connect(self, policy: AgentPolicy, player: PlayerCommands | None) -> None: ...
|
|
114
|
+
|
|
115
|
+
async def close(self) -> None: ...
|
|
116
|
+
|
|
117
|
+
def devices(self) -> list[Device]: ...
|
|
118
|
+
|
|
119
|
+
async def start_discovery(self) -> None: ...
|
|
120
|
+
|
|
121
|
+
async def stop_discovery(self) -> None: ...
|
|
122
|
+
|
|
123
|
+
async def pair(self, path: str) -> None: ...
|
|
124
|
+
|
|
125
|
+
async def set_trusted(self, path: str) -> None: ...
|
|
126
|
+
|
|
127
|
+
async def connect_device(self, path: str) -> None: ...
|
|
128
|
+
|
|
129
|
+
async def remove_device(self, path: str) -> None: ...
|
|
130
|
+
|
|
131
|
+
def set_playback_status(self, status: str) -> None: ...
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class DbusBluez:
|
|
135
|
+
""":class:`Bluez` over D-Bus.
|
|
136
|
+
|
|
137
|
+
The box is never visible (``Discoverable`` off) and its agent - without input and output ("just works") - asks
|
|
138
|
+
``policy``, which accepts nothing but the headset the box itself is pairing. With ``player`` the box registers as
|
|
139
|
+
media player: BlueZ then passes the AVRCP buttons of the headset to it.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, logger: logging.Logger, adapter: str = "hci0", bus_address: str | None = None) -> None:
|
|
143
|
+
"""``bus_address``: another bus than the system bus (tests)."""
|
|
144
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
145
|
+
self._adapter_path = f"/org/bluez/{adapter}"
|
|
146
|
+
self._bus_address = bus_address
|
|
147
|
+
self._bus: MessageBus | None = None
|
|
148
|
+
self._objects: dict[str, dict[str, dict[str, Any]]] = {} # path -> interface -> properties
|
|
149
|
+
self._discovered: set[str] = set()
|
|
150
|
+
self._discovering = False
|
|
151
|
+
self._policy: AgentPolicy | None = None
|
|
152
|
+
self._player: PlayerCommands | None = None
|
|
153
|
+
self._playback_status = "Stopped"
|
|
154
|
+
self._setup_task: asyncio.Task[None] | None = None
|
|
155
|
+
self.on_change: Callable[[], None] | None = None
|
|
156
|
+
|
|
157
|
+
# --- connection -----------------------------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async def connect(self, policy: AgentPolicy, player: PlayerCommands | None) -> None:
|
|
160
|
+
""":raises BluezError: without BlueZ or the adapter. :raises Exception: without the bus."""
|
|
161
|
+
self._policy, self._player = policy, player
|
|
162
|
+
if self._bus_address is not None:
|
|
163
|
+
bus = MessageBus(bus_address=self._bus_address)
|
|
164
|
+
else:
|
|
165
|
+
bus = MessageBus(bus_type=BusType.SYSTEM)
|
|
166
|
+
self._bus = await bus.connect()
|
|
167
|
+
bus.add_message_handler(self._on_message)
|
|
168
|
+
for rule in _MATCH_RULES:
|
|
169
|
+
await self._call(DBUS_PATH, DBUS, "AddMatch", "s", [rule], destination=DBUS)
|
|
170
|
+
await self._setup()
|
|
171
|
+
|
|
172
|
+
async def _setup(self) -> None:
|
|
173
|
+
"""Load the objects of BlueZ and register adapter settings, agent and player (also after BlueZ restarted)."""
|
|
174
|
+
(objects,) = await self._call("/", OBJECT_MANAGER, "GetManagedObjects")
|
|
175
|
+
self._objects = unpack_variants(objects)
|
|
176
|
+
self._discovered.clear()
|
|
177
|
+
if ADAPTER not in self._objects.get(self._adapter_path, {}):
|
|
178
|
+
raise BluezError("org.bluez.Error.DoesNotExist", f"no bluetooth adapter {self._adapter_path}")
|
|
179
|
+
# Pairable (bondable) must be on: without it the kernel pairs without bonding - the headset would be forgotten
|
|
180
|
+
# with its first disconnection. Pairing from outside is kept away by the agent and by not being visible.
|
|
181
|
+
for name, value in (("Powered", True), ("Pairable", True), ("Discoverable", False)):
|
|
182
|
+
await self._set(self._adapter_path, ADAPTER, name, Variant("b", value))
|
|
183
|
+
await self._call("/org/bluez", AGENT_MANAGER, "RegisterAgent", "os", [AGENT_PATH, "NoInputNoOutput"])
|
|
184
|
+
await self._call("/org/bluez", AGENT_MANAGER, "RequestDefaultAgent", "o", [AGENT_PATH])
|
|
185
|
+
if self._player is not None:
|
|
186
|
+
try:
|
|
187
|
+
await self._call(
|
|
188
|
+
self._adapter_path, MEDIA, "RegisterPlayer", "oa{sv}", [PLAYER_PATH, self._player_properties()]
|
|
189
|
+
)
|
|
190
|
+
except BluezError as e: # the headset still plays - only its buttons do not work
|
|
191
|
+
self._logger.error("registering the media player (buttons of the headset) failed: %s", e)
|
|
192
|
+
self._changed()
|
|
193
|
+
|
|
194
|
+
async def close(self) -> None:
|
|
195
|
+
bus, self._bus = self._bus, None
|
|
196
|
+
if self._setup_task is not None:
|
|
197
|
+
self._setup_task.cancel()
|
|
198
|
+
if bus is None:
|
|
199
|
+
return
|
|
200
|
+
for path, interface, member, signature, body in (
|
|
201
|
+
(self._adapter_path, MEDIA, "UnregisterPlayer", "o", [PLAYER_PATH]),
|
|
202
|
+
("/org/bluez", AGENT_MANAGER, "UnregisterAgent", "o", [AGENT_PATH]),
|
|
203
|
+
):
|
|
204
|
+
with contextlib.suppress(BluezError, TimeoutError, OSError): # BlueZ forgets both anyway
|
|
205
|
+
await self._call(path, interface, member, signature, body, bus=bus, timeout=2.0)
|
|
206
|
+
bus.disconnect()
|
|
207
|
+
|
|
208
|
+
async def _call(
|
|
209
|
+
self,
|
|
210
|
+
path: str,
|
|
211
|
+
interface: str,
|
|
212
|
+
member: str,
|
|
213
|
+
signature: str = "",
|
|
214
|
+
body: Sequence[Any] = (),
|
|
215
|
+
timeout: float = CALL_TIMEOUT,
|
|
216
|
+
destination: str = BLUEZ,
|
|
217
|
+
bus: MessageBus | None = None,
|
|
218
|
+
) -> list[Any]:
|
|
219
|
+
bus = bus or self._bus
|
|
220
|
+
if bus is None:
|
|
221
|
+
raise BluezError("org.freedesktop.DBus.Error.Disconnected", "not connected")
|
|
222
|
+
message = Message(
|
|
223
|
+
destination=destination, path=path, interface=interface, member=member, signature=signature, body=list(body)
|
|
224
|
+
)
|
|
225
|
+
reply = await asyncio.wait_for(bus.call(message), timeout)
|
|
226
|
+
if reply is None:
|
|
227
|
+
raise BluezError("org.freedesktop.DBus.Error.NoReply", f"{interface}.{member}")
|
|
228
|
+
if reply.message_type == MessageType.ERROR:
|
|
229
|
+
raise BluezError(str(reply.error_name), str(reply.body[0]) if reply.body else "")
|
|
230
|
+
return list(reply.body)
|
|
231
|
+
|
|
232
|
+
async def _set(self, path: str, interface: str, name: str, value: Variant) -> None:
|
|
233
|
+
await self._call(path, PROPERTIES, "Set", "ssv", [interface, name, value])
|
|
234
|
+
|
|
235
|
+
# --- objects --------------------------------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def devices(self) -> list[Device]:
|
|
238
|
+
audio = {
|
|
239
|
+
interfaces[TRANSPORT].get("Device")
|
|
240
|
+
for interfaces in self._objects.values()
|
|
241
|
+
if TRANSPORT in interfaces
|
|
242
|
+
} # fmt: skip
|
|
243
|
+
devices = []
|
|
244
|
+
for path, interfaces in self._objects.items():
|
|
245
|
+
properties = interfaces.get(DEVICE)
|
|
246
|
+
if properties is None or not path.startswith(self._adapter_path + "/"):
|
|
247
|
+
continue
|
|
248
|
+
address = str(properties.get("Address", ""))
|
|
249
|
+
devices.append(
|
|
250
|
+
Device(
|
|
251
|
+
path=path,
|
|
252
|
+
address=address,
|
|
253
|
+
name=str(properties.get("Alias") or properties.get("Name") or address),
|
|
254
|
+
paired=bool(properties.get("Paired", False)),
|
|
255
|
+
trusted=bool(properties.get("Trusted", False)),
|
|
256
|
+
connected=bool(properties.get("Connected", False)),
|
|
257
|
+
rssi=properties.get("RSSI"),
|
|
258
|
+
uuids=tuple(str(uuid).lower() for uuid in properties.get("UUIDs", ())),
|
|
259
|
+
device_class=properties.get("Class"),
|
|
260
|
+
battery=interfaces.get(BATTERY, {}).get("Percentage"),
|
|
261
|
+
audio=path in audio,
|
|
262
|
+
discovered=path in self._discovered,
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
return devices
|
|
266
|
+
|
|
267
|
+
def _changed(self) -> None:
|
|
268
|
+
if self.on_change is not None:
|
|
269
|
+
self.on_change()
|
|
270
|
+
|
|
271
|
+
def _on_message(self, message: Message) -> Message | bool | None:
|
|
272
|
+
if message.message_type == MessageType.METHOD_CALL:
|
|
273
|
+
if message.path == AGENT_PATH and message.interface == AGENT:
|
|
274
|
+
return self._agent_call(message)
|
|
275
|
+
if message.path == PLAYER_PATH:
|
|
276
|
+
return self._player_call(message)
|
|
277
|
+
return None
|
|
278
|
+
if message.message_type == MessageType.SIGNAL:
|
|
279
|
+
self._on_signal(message)
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
def _on_signal(self, message: Message) -> None:
|
|
283
|
+
"""Follow the objects of BlueZ."""
|
|
284
|
+
if message.member == "NameOwnerChanged" and message.interface == DBUS:
|
|
285
|
+
self._bluez_restarted(str(message.body[2]))
|
|
286
|
+
elif not str(message.path).startswith("/org/bluez") and message.path != "/":
|
|
287
|
+
return
|
|
288
|
+
elif message.member == "PropertiesChanged" and message.interface == PROPERTIES:
|
|
289
|
+
interface, changed, invalidated = message.body
|
|
290
|
+
properties = self._objects.setdefault(str(message.path), {}).setdefault(interface, {})
|
|
291
|
+
properties.update(unpack_variants(changed))
|
|
292
|
+
for name in invalidated:
|
|
293
|
+
properties.pop(name, None)
|
|
294
|
+
if interface == DEVICE and "RSSI" in changed and self._discovering:
|
|
295
|
+
self._discovered.add(str(message.path))
|
|
296
|
+
self._changed()
|
|
297
|
+
elif message.member == "InterfacesAdded" and message.interface == OBJECT_MANAGER:
|
|
298
|
+
path, interfaces = message.body
|
|
299
|
+
self._objects.setdefault(path, {}).update(unpack_variants(interfaces))
|
|
300
|
+
if self._discovering and "RSSI" in interfaces.get(DEVICE, {}):
|
|
301
|
+
self._discovered.add(path)
|
|
302
|
+
self._changed()
|
|
303
|
+
elif message.member == "InterfacesRemoved" and message.interface == OBJECT_MANAGER:
|
|
304
|
+
path, removed = message.body
|
|
305
|
+
for interface in removed:
|
|
306
|
+
self._objects.get(path, {}).pop(interface, None)
|
|
307
|
+
if not self._objects.get(path, True):
|
|
308
|
+
del self._objects[path]
|
|
309
|
+
self._discovered.discard(path)
|
|
310
|
+
self._changed()
|
|
311
|
+
|
|
312
|
+
def _bluez_restarted(self, new_owner: str) -> None:
|
|
313
|
+
if not new_owner:
|
|
314
|
+
self._logger.warning("BlueZ has gone")
|
|
315
|
+
if self._setup_task is not None:
|
|
316
|
+
self._setup_task.cancel()
|
|
317
|
+
self._objects = {}
|
|
318
|
+
self._discovered.clear()
|
|
319
|
+
self._changed()
|
|
320
|
+
return
|
|
321
|
+
self._logger.warning("BlueZ is back - registering again")
|
|
322
|
+
|
|
323
|
+
async def setup() -> None:
|
|
324
|
+
failure: str | None = None
|
|
325
|
+
while self._bus is not None:
|
|
326
|
+
try:
|
|
327
|
+
await self._setup()
|
|
328
|
+
return
|
|
329
|
+
except (BluezError, TimeoutError) as e: # e.g. the adapter is not there yet
|
|
330
|
+
if str(e) != failure:
|
|
331
|
+
failure = str(e)
|
|
332
|
+
self._logger.error("registering at BlueZ failed: %s - trying again", e)
|
|
333
|
+
await asyncio.sleep(SETUP_RETRY)
|
|
334
|
+
|
|
335
|
+
if self._setup_task is not None:
|
|
336
|
+
self._setup_task.cancel()
|
|
337
|
+
self._setup_task = asyncio.get_running_loop().create_task(setup())
|
|
338
|
+
|
|
339
|
+
# --- search / pairing -----------------------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
async def start_discovery(self) -> None:
|
|
342
|
+
"""Search for classic bluetooth devices only (BlueALSA cannot play LE audio; headsets in pairing mode advertise
|
|
343
|
+
over LE as well)."""
|
|
344
|
+
self._discovered.clear()
|
|
345
|
+
self._discovering = True
|
|
346
|
+
await self._call(
|
|
347
|
+
self._adapter_path, ADAPTER, "SetDiscoveryFilter", "a{sv}", [{"Transport": Variant("s", "bredr")}]
|
|
348
|
+
)
|
|
349
|
+
await self._call(self._adapter_path, ADAPTER, "StartDiscovery")
|
|
350
|
+
|
|
351
|
+
async def stop_discovery(self) -> None:
|
|
352
|
+
if not self._discovering:
|
|
353
|
+
return
|
|
354
|
+
self._discovering = False
|
|
355
|
+
try:
|
|
356
|
+
await self._call(self._adapter_path, ADAPTER, "StopDiscovery")
|
|
357
|
+
except BluezError as e:
|
|
358
|
+
self._logger.debug("stop_discovery - %s", e)
|
|
359
|
+
|
|
360
|
+
async def pair(self, path: str) -> None:
|
|
361
|
+
await self._call(path, DEVICE, "Pair", timeout=PAIR_TIMEOUT)
|
|
362
|
+
|
|
363
|
+
async def set_trusted(self, path: str) -> None:
|
|
364
|
+
await self._set(path, DEVICE, "Trusted", Variant("b", True))
|
|
365
|
+
|
|
366
|
+
async def connect_device(self, path: str) -> None:
|
|
367
|
+
await self._call(path, DEVICE, "Connect", timeout=CONNECT_TIMEOUT)
|
|
368
|
+
|
|
369
|
+
async def remove_device(self, path: str) -> None:
|
|
370
|
+
"""Remove the device and its pairing (it is disconnected as well)."""
|
|
371
|
+
await self._call(self._adapter_path, ADAPTER, "RemoveDevice", "o", [path])
|
|
372
|
+
|
|
373
|
+
# --- agent (called by BlueZ) ----------------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
def _agent_call(self, message: Message) -> Message:
|
|
376
|
+
member = message.member
|
|
377
|
+
device = str(message.body[0]) if message.body else ""
|
|
378
|
+
policy = self._policy
|
|
379
|
+
self._logger.info("agent - %s %s", member, device)
|
|
380
|
+
if member in ("Release", "Cancel", "DisplayPinCode", "DisplayPasskey"):
|
|
381
|
+
return Message.new_method_return(message)
|
|
382
|
+
if member == "AuthorizeService":
|
|
383
|
+
allowed = policy is not None and policy.allows_service(device)
|
|
384
|
+
else:
|
|
385
|
+
allowed = policy is not None and policy.allows_pairing(device)
|
|
386
|
+
if not allowed:
|
|
387
|
+
self._logger.warning("agent - %s of %s rejected", member, device)
|
|
388
|
+
return Message.new_error(message, REJECTED, "not the headset of the box")
|
|
389
|
+
if member == "RequestPinCode":
|
|
390
|
+
return Message.new_method_return(message, "s", [PIN])
|
|
391
|
+
if member == "RequestPasskey":
|
|
392
|
+
return Message.new_method_return(message, "u", [int(PIN)])
|
|
393
|
+
if member in ("RequestConfirmation", "RequestAuthorization", "AuthorizeService"):
|
|
394
|
+
return Message.new_method_return(message)
|
|
395
|
+
return Message.new_error(message, "org.freedesktop.DBus.Error.UnknownMethod", str(member))
|
|
396
|
+
|
|
397
|
+
# --- media player (called by BlueZ: AVRCP buttons of the headset) -----------------------------------------------
|
|
398
|
+
|
|
399
|
+
def _player_properties(self) -> dict[str, Variant]:
|
|
400
|
+
return {
|
|
401
|
+
"PlaybackStatus": Variant("s", self._playback_status),
|
|
402
|
+
"LoopStatus": Variant("s", "None"),
|
|
403
|
+
"Rate": Variant("d", 1.0),
|
|
404
|
+
"Shuffle": Variant("b", False),
|
|
405
|
+
"Metadata": Variant("a{sv}", {}),
|
|
406
|
+
"Position": Variant("x", 0),
|
|
407
|
+
"MinimumRate": Variant("d", 1.0),
|
|
408
|
+
"MaximumRate": Variant("d", 1.0),
|
|
409
|
+
"CanGoNext": Variant("b", True),
|
|
410
|
+
"CanGoPrevious": Variant("b", True),
|
|
411
|
+
"CanPlay": Variant("b", True),
|
|
412
|
+
"CanPause": Variant("b", True),
|
|
413
|
+
"CanSeek": Variant("b", False),
|
|
414
|
+
"CanControl": Variant("b", True),
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
def _player_call(self, message: Message) -> Message:
|
|
418
|
+
member = str(message.member)
|
|
419
|
+
if message.interface == PROPERTIES:
|
|
420
|
+
properties = self._player_properties()
|
|
421
|
+
if member == "GetAll":
|
|
422
|
+
return Message.new_method_return(message, "a{sv}", [properties])
|
|
423
|
+
if member == "Get" and message.body[1] in properties:
|
|
424
|
+
return Message.new_method_return(message, "v", [properties[message.body[1]]])
|
|
425
|
+
if member == "Set":
|
|
426
|
+
return Message.new_method_return(message) # shuffle, loop, ... - not supported, not an error
|
|
427
|
+
return Message.new_error(message, "org.freedesktop.DBus.Error.InvalidArgs", member)
|
|
428
|
+
if message.interface == PLAYER:
|
|
429
|
+
self._logger.info("player - %s", member)
|
|
430
|
+
if self._player is not None and member in ("Play", "Pause", "PlayPause", "Stop", "Next", "Previous"):
|
|
431
|
+
self._player.player_command(member)
|
|
432
|
+
return Message.new_method_return(message)
|
|
433
|
+
return Message.new_error(message, "org.freedesktop.DBus.Error.UnknownInterface", str(message.interface))
|
|
434
|
+
|
|
435
|
+
def set_playback_status(self, status: str) -> None:
|
|
436
|
+
"""Tell the headset (via BlueZ) what the box plays: ``playing``, ``paused`` or ``stopped`` - it then sends
|
|
437
|
+
the matching button."""
|
|
438
|
+
playback_status = _PLAYBACK_STATUS.get(status, "Stopped")
|
|
439
|
+
if playback_status == self._playback_status:
|
|
440
|
+
return
|
|
441
|
+
self._playback_status = playback_status
|
|
442
|
+
if self._bus is not None and self._player is not None:
|
|
443
|
+
changed = {"PlaybackStatus": Variant("s", playback_status)}
|
|
444
|
+
self._bus.send(
|
|
445
|
+
Message.new_signal(PLAYER_PATH, PROPERTIES, "PropertiesChanged", "sa{sv}as", [PLAYER, changed, []])
|
|
446
|
+
)
|