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,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
+ )