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,151 @@
1
+ """DriverManager - runs several drivers in one process."""
2
+
3
+ import functools
4
+ import logging
5
+ from collections.abc import Callable, Mapping
6
+ from typing import Any, ClassVar
7
+
8
+ import tinyjukebox
9
+ from tinyjukebox.core.microservice import AbstractMicroservice
10
+ from tinyjukebox.core.pubsub import PubSubClient
11
+ from tinyjukebox.core.ui import Command
12
+ from tinyjukebox.core.ui.tools import get_yes_no, more, parse
13
+ from tinyjukebox.devices.baseclasses.adriver import ADriver
14
+ from tinyjukebox.devices.drivermanager.driverfactory import DriverFactory
15
+ from tinyjukebox.devices.schema.drivermanager import get_schema
16
+
17
+ __all__ = ["DriverManager"]
18
+
19
+
20
+ class DriverManager(AbstractMicroservice):
21
+ """Creates all active drivers of the config section ``drivers`` and starts/stops them together.
22
+
23
+ * All drivers share the pubsub client and the logger of the manager.
24
+ * The command line offers ``list_drivers``, ``start_driver``, ``stop_driver``, ``restart_driver``, ``reload_driver``
25
+ and the driver specific commands with the driver id as prefix (e.g. ``rfid_gpio_state``).
26
+ """
27
+
28
+ _version: ClassVar[str] = tinyjukebox.__version__
29
+
30
+ def __init__(
31
+ self,
32
+ config: Mapping[str, Any],
33
+ pubsub_client: PubSubClient,
34
+ logger: logging.Logger,
35
+ ) -> None:
36
+ """The command line of the manager is offered by the owner (see :attr:`ui_commands`)."""
37
+ super().__init__(config, "drivers", pubsub_client, logger, logger_name=type(self).__name__, no_gui=True)
38
+ self._driver_factory = DriverFactory(self._pubsub_client, self._logger)
39
+ self._drivers = self._driver_factory.create_drivers(config)
40
+
41
+ for driver_id, driver in self._drivers.items():
42
+ for name, command in driver.driver_ui_commands.items():
43
+ self._add_ui_command(f"{driver_id}_{name}", self._driver_command(driver_id, name, command))
44
+ self._add_ui_command("list_drivers", self._cmd_list_drivers)
45
+ self._add_ui_command("reload_driver", self._cmd_reload_driver)
46
+ self._add_ui_command("stop_driver", self._cmd_stop_driver)
47
+ self._add_ui_command("start_driver", self._cmd_start_driver)
48
+ self._add_ui_command("restart_driver", self._cmd_restart_driver)
49
+
50
+ @property
51
+ def drivers(self) -> dict[str, ADriver]:
52
+ """``{driver id: driver}`` of all active drivers."""
53
+ return self._drivers
54
+
55
+ def states(self) -> list[dict[str, Any]]:
56
+ """The :meth:`~tinyjukebox.devices.baseclasses.adriver.ADriver.state` of every driver with its ``id``."""
57
+ return [{"id": driver_id, **driver.state()} for driver_id, driver in self._drivers.items()]
58
+
59
+ # --- life cycle ---------------------------------------------------------------------------------------------
60
+
61
+ def _start(self) -> None:
62
+ for driver in self._drivers.values():
63
+ driver.start()
64
+ self._logger.warning("DriverManager started")
65
+
66
+ def _stop(self) -> None:
67
+ # also after a failed start: only the started drivers, and a failing driver does not keep the others running
68
+ for driver in reversed(self._drivers.values()):
69
+ if driver.is_stopped:
70
+ continue
71
+ try:
72
+ driver.stop()
73
+ except Exception:
74
+ self._logger.exception("DriverManager - stopping %s failed", driver.get_short_info())
75
+ self._logger.warning("DriverManager stopped")
76
+
77
+ def restart_driver(self, driver_id: str) -> None:
78
+ driver = self._drivers[driver_id]
79
+ driver.stop()
80
+ driver.start()
81
+
82
+ def reload_driver(self, driver_id: str) -> None:
83
+ """Replace the driver with a new instance (e.g. after the hardware has been reconnected)."""
84
+ self._driver_factory.reload_driver(self._drivers, driver_id)
85
+
86
+ # --- console ------------------------------------------------------------------------------------------------
87
+
88
+ def _driver_command(self, driver_id: str, name: str, command: Command) -> Command:
89
+ """``command`` of the driver ``driver_id`` - of the instance that is current when it is called:
90
+ :meth:`reload_driver` replaces the instance, the command line keeps its commands."""
91
+
92
+ @functools.wraps(command) # the docstring is the help text
93
+ def current(args: str) -> object:
94
+ return self._drivers[driver_id].driver_ui_commands[name](args)
95
+
96
+ return current
97
+
98
+ def _get_id_from_arg(self, arg: str) -> str | None:
99
+ args = parse(arg)
100
+ if len(args) != 1:
101
+ print("missing mandatory parameter id (see 'list_drivers').")
102
+ return None
103
+ if args[0] not in self._drivers:
104
+ print(f"unknown id '{args[0]}'")
105
+ return None
106
+ return args[0]
107
+
108
+ def _confirmed_driver_action(
109
+ self, arg: str, verb: str, must_run: bool | None, action: Callable[[str], None]
110
+ ) -> None:
111
+ """Ask for confirmation and call ``action(driver_id)``; ``must_run``: required state (None = any)."""
112
+ driver_id = self._get_id_from_arg(arg)
113
+ if driver_id is None:
114
+ return
115
+ driver = self._drivers[driver_id]
116
+ if must_run is not None and driver.is_stopped == must_run:
117
+ print(f"[{driver_id}] is {'not' if must_run else 'already'} running")
118
+ return
119
+ print(f"{verb} [{driver_id}]: {driver.get_short_info()}")
120
+ if get_yes_no() == "y":
121
+ action(driver_id)
122
+
123
+ def _cmd_stop_driver(self, arg: str) -> None:
124
+ """stop_driver id - stops the driver (id from 'list_drivers'): STOP_DRIVER id"""
125
+ self._confirmed_driver_action(arg, "stopping", True, lambda driver_id: self._drivers[driver_id].stop())
126
+
127
+ def _cmd_start_driver(self, arg: str) -> None:
128
+ """start_driver id - starts the driver (id from 'list_drivers'): START_DRIVER id"""
129
+ self._confirmed_driver_action(arg, "starting", False, lambda driver_id: self._drivers[driver_id].start())
130
+
131
+ def _cmd_restart_driver(self, arg: str) -> None:
132
+ """restart_driver id - stops and starts the driver (id from 'list_drivers'): RESTART_DRIVER id"""
133
+ self._confirmed_driver_action(arg, "restarting", True, self.restart_driver)
134
+
135
+ def _cmd_reload_driver(self, arg: str) -> None:
136
+ """reload_driver id - replaces the driver with a new instance (id from 'list_drivers'): RELOAD_DRIVER id"""
137
+ self._confirmed_driver_action(arg, "reloading", None, self.reload_driver)
138
+
139
+ def _cmd_list_drivers(self, arg: str) -> None:
140
+ """list_drivers - list all drivers: LIST_DRIVERS"""
141
+ more("".join(f"{driver_id}: {driver.get_short_info()}\n" for driver_id, driver in self._drivers.items()))
142
+
143
+ # --- schema -------------------------------------------------------------------------------------------------------
144
+
145
+ @classmethod
146
+ def _get_description(cls) -> str:
147
+ return "Device drivers of the box."
148
+
149
+ @classmethod
150
+ def _get_schema(cls) -> dict[str, Any]:
151
+ return get_schema()
@@ -0,0 +1,31 @@
1
+ """All drivers - registry of driver types.
2
+
3
+ Driver modules import their hardware libraries at module level. They are therefore only imported on demand
4
+ (:func:`get_driver_class`), so tinyjukebox stays importable on machines without these libraries.
5
+ Convention: type ``foo`` → driver module ``tinyjukebox.devices.drivers.foo``, schema module
6
+ ``tinyjukebox.devices.schema.foo``.
7
+ """
8
+
9
+ import importlib
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from tinyjukebox.devices.baseclasses.adriver import ADriver
14
+
15
+ __all__ = ["DRIVERS", "get_driver_class"]
16
+
17
+ DRIVERS: dict[str, str] = {"input": "Input", "rfid": "RFID", "rgbled": "RGBLed", "bluetooth": "Bluetooth"}
18
+ """driver type (value of ``type`` in the config, lower case) → class name"""
19
+
20
+
21
+ def get_driver_class(driver_type: str) -> type["ADriver"]:
22
+ """Import and return the driver class for ``driver_type`` (case insensitive).
23
+
24
+ :raises ValueError: for unknown driver types.
25
+ """
26
+ key = driver_type.lower()
27
+ if key not in DRIVERS:
28
+ raise ValueError(f"unknown driver type '{driver_type}'.")
29
+ module = importlib.import_module(f"{__name__}.{key}")
30
+ driver_class: type[ADriver] = getattr(module, DRIVERS[key])
31
+ return driver_class
@@ -0,0 +1,257 @@
1
+ """Bluetooth headset: BlueZ (D-Bus) pairs and connects it, BlueALSA plays on it and sets its volume."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ import threading
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ from tinyjukebox.core import unexpected
11
+ from tinyjukebox.core.pubsub import PubSubClient
12
+ from tinyjukebox.core.threads import LoggerThread
13
+ from tinyjukebox.devices.baseclasses.adriver import ADriver
14
+ from tinyjukebox.devices.bluetooth.avrcp_input import AvrcpInput
15
+ from tinyjukebox.devices.bluetooth.bluez import Bluez, BluezError, DbusBluez
16
+ from tinyjukebox.devices.bluetooth.link import (
17
+ COLLECT_TIME,
18
+ RECONNECT_INTERVAL,
19
+ SEARCH_TIME,
20
+ HeadsetLink,
21
+ Mixer,
22
+ MixerFactory,
23
+ button_of,
24
+ )
25
+ from tinyjukebox.devices.schema.bluetooth import get_schema
26
+ from tinyjukebox.messages import (
27
+ BLUETOOTH_COMMAND,
28
+ BLUETOOTH_STATE,
29
+ BUTTON_PRESSED,
30
+ PLAYBACK,
31
+ BluetoothCommand,
32
+ BluetoothState,
33
+ Button,
34
+ PlaybackStatus,
35
+ )
36
+ from tinyjukebox.volumemanager import AlsaVolumeControl
37
+
38
+ __all__ = ["Bluetooth"]
39
+
40
+ START_TIMEOUT = 15.0 # seconds to wait for the event loop at the start
41
+ STOP_TIMEOUT = 10.0 # seconds to wait for the event loop at the stop
42
+ RETRY_INTERVAL = 30.0 # seconds until BlueZ is tried again if it is not available
43
+ MIXER_RETRY = 0.5 # seconds until the mixer of the headset is opened again - it appears a moment after the headset
44
+
45
+
46
+ class Bluetooth(ADriver):
47
+ """The box as bluetooth sender (like a tv set) for exactly one headset - see
48
+ :class:`~tinyjukebox.devices.bluetooth.link.HeadsetLink` for what it does.
49
+
50
+ * publishes :class:`~tinyjukebox.messages.BluetoothState` on :data:`~tinyjukebox.messages.BLUETOOTH_STATE` and the
51
+ buttons of the headset on :data:`~tinyjukebox.messages.BUTTON_PRESSED`,
52
+ * receives :class:`~tinyjukebox.messages.BluetoothCommand` on :data:`~tinyjukebox.messages.BLUETOOTH_COMMAND` and
53
+ the playback status on :data:`~tinyjukebox.messages.PLAYBACK`.
54
+
55
+ BlueZ is used via D-Bus in an event loop (asyncio) in a thread of its own - the handlers only pass the messages
56
+ on to it. Needs the services bluetooth and bluealsa (``bluez-alsa-utils``) and the groups bluetooth and audio.
57
+ Without BlueZ (service not running yet, no adapter) the box works without headset; the driver tries again every
58
+ :data:`RETRY_INTERVAL` seconds.
59
+
60
+ Example::
61
+
62
+ driver:
63
+ type: bluetooth
64
+ adapter: hci0 # optional
65
+ avrcp: player # optional - player | input: how the buttons of the headset arrive
66
+ play-pause-toggle: false # optional - true: play and pause both switch between play and pause
67
+ """
68
+
69
+ # seconds - see tinyjukebox.devices.bluetooth.link (shorter in tests)
70
+ reconnect_interval = RECONNECT_INTERVAL
71
+ search_time = SEARCH_TIME
72
+ collect_time = COLLECT_TIME
73
+ retry_interval = RETRY_INTERVAL
74
+
75
+ def __init__(
76
+ self,
77
+ config: Mapping[str, Any],
78
+ pubsub_client: PubSubClient,
79
+ logger: logging.Logger,
80
+ bluez: Bluez | None = None,
81
+ mixer_factory: MixerFactory | None = None,
82
+ ) -> None:
83
+ """``bluez``, ``mixer_factory``: replace BlueZ and the BlueALSA mixer (tests)."""
84
+ super().__init__(config, pubsub_client, logger)
85
+ self._bluez = bluez
86
+ self._mixer_factory = mixer_factory
87
+ self._avrcp_input = self._config.get("avrcp", "player") == "input"
88
+ self._toggle = bool(self._config.get("play-pause-toggle", False))
89
+ self._loop: asyncio.AbstractEventLoop | None = None
90
+ self._stop_event: asyncio.Event | None = None
91
+ self._thread: LoggerThread | None = None
92
+ self._link: HeadsetLink | None = None
93
+ self._active_bluez: Bluez | None = None
94
+ self._input: AvrcpInput | None = None
95
+ self._started = threading.Event()
96
+ self._playback = PlaybackStatus.STOPPED # the last one - a new link starts with it
97
+ self._add_ui_command("state", self._cmd_state)
98
+
99
+ def _mixer(self, address: str) -> Mixer:
100
+ # the mixer of BlueALSA for one headset ("single device mode"): its control is just 'A2DP' and the mixer
101
+ # exists only while the headset is connected
102
+ return AlsaVolumeControl("A2DP", self._logger, device=f"bluealsa:{address}", watch_retry=MIXER_RETRY)
103
+
104
+ def _publish_button(self, button: Button) -> None:
105
+ self._publish_value(BUTTON_PRESSED, button)
106
+
107
+ def _publish_state(self, state: BluetoothState) -> None:
108
+ self._publish_value(BLUETOOTH_STATE, state)
109
+
110
+ def _input_command(self, command: str) -> None:
111
+ """A button read from the input device (reader thread)."""
112
+ link = self._link
113
+ if link is not None:
114
+ button = button_of(command, link.playback, self._toggle)
115
+ if button is not None:
116
+ self._publish_button(button)
117
+
118
+ def _cmd_state(self, args: str) -> None:
119
+ """state - the devices BlueZ knows: STATE"""
120
+ bluez = self._active_bluez
121
+ try:
122
+ devices = bluez.devices() if bluez is not None else []
123
+ except RuntimeError: # changed by the event loop meanwhile
124
+ devices = []
125
+ print(f"[{self._name}] busy - try again.")
126
+ for device in devices:
127
+ print(
128
+ f"[{self._name}] {device.address} '{device.name}' paired: {device.paired}, connected: "
129
+ f"{device.connected}, audio: {device.audio}, battery: {device.battery}"
130
+ )
131
+ if not devices:
132
+ print(f"[{self._name}] no devices.")
133
+
134
+ # --- event loop thread ----------------------------------------------------------------------------------------
135
+
136
+ def _run(self) -> None:
137
+ loop = asyncio.new_event_loop()
138
+ try:
139
+ loop.run_until_complete(self._main(loop))
140
+ finally:
141
+ self._started.set()
142
+ self._loop = None
143
+ pending = asyncio.all_tasks(loop) # nothing is left behind - whatever happened
144
+ for task in pending:
145
+ task.cancel()
146
+ if pending:
147
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
148
+ loop.close()
149
+
150
+ async def _main(self, loop: asyncio.AbstractEventLoop) -> None:
151
+ stop = self._stop_event = asyncio.Event()
152
+ self._loop = loop
153
+ self._started.set()
154
+ failure: str | None = None
155
+ while not stop.is_set():
156
+ bluez = self._bluez or DbusBluez(self._logger, self._config.get("adapter", "hci0"))
157
+ link = HeadsetLink(
158
+ bluez,
159
+ self._mixer_factory or self._mixer,
160
+ self._publish_state,
161
+ self._publish_button,
162
+ self._logger,
163
+ play_pause_toggle=self._toggle,
164
+ register_player=not self._avrcp_input,
165
+ reconnect_interval=self.reconnect_interval,
166
+ search_time=self.search_time,
167
+ collect_time=self.collect_time,
168
+ )
169
+ link.playback_status(self._playback)
170
+ self._active_bluez, self._link = bluez, link
171
+ try:
172
+ await link.run(stop)
173
+ failure = None
174
+ except (BluezError, OSError, EOFError, TimeoutError) as e:
175
+ # e.g. the service bluetooth is not running (yet) - the box plays on its speaker meanwhile
176
+ level = logging.DEBUG if str(e) == failure else logging.ERROR # the same reason only once
177
+ failure = str(e)
178
+ self._logger.log(
179
+ level, "Bluetooth - not available: %s - trying again every %s s", e, self.retry_interval
180
+ )
181
+ with contextlib.suppress(TimeoutError):
182
+ await asyncio.wait_for(stop.wait(), self.retry_interval)
183
+ except Exception as e:
184
+ # what nobody expected (an answer of BlueZ in another form, a refused authentication of D-Bus) ends
185
+ # the headset no more for the rest of the run: reported, and tried again as above
186
+ self._logger.exception("Bluetooth - failed - trying again in %s s", self.retry_interval)
187
+ unexpected.report(e, "bluetooth driver")
188
+ failure = None
189
+ with contextlib.suppress(TimeoutError):
190
+ await asyncio.wait_for(stop.wait(), self.retry_interval)
191
+ finally:
192
+ self._link = None
193
+
194
+ def _in_loop(self, callback: Any, *args: Any) -> None:
195
+ loop = self._loop
196
+ if loop is None:
197
+ self._logger.warning("Bluetooth - not running, dropped %s", args)
198
+ return
199
+ loop.call_soon_threadsafe(callback, *args)
200
+
201
+ def _command_handler(self, message: object) -> None:
202
+ """Handler for :data:`~tinyjukebox.messages.BLUETOOTH_COMMAND`."""
203
+ link = self._link
204
+ if isinstance(message, BluetoothCommand) and link is not None:
205
+ self._in_loop(link.command, message)
206
+ else:
207
+ self._logger.warning("Bluetooth - cannot process %r (bluetooth available: %s)", message, link is not None)
208
+
209
+ def _playback_handler(self, message: object) -> None:
210
+ """Handler for :data:`~tinyjukebox.messages.PLAYBACK`."""
211
+ if not isinstance(message, PlaybackStatus):
212
+ return
213
+ self._playback = message
214
+ link = self._link
215
+ if link is not None:
216
+ self._in_loop(link.playback_status, message)
217
+
218
+ # --- life cycle -----------------------------------------------------------------------------------------------
219
+
220
+ def _driver_start(self) -> None:
221
+ self._started.clear()
222
+ self._thread = LoggerThread(target=self._run, name="bluetooth", logger=self._logger, daemon=True)
223
+ self._thread.start()
224
+ self._started.wait(START_TIMEOUT)
225
+ self._pubsub_client.subscribe(BLUETOOTH_COMMAND, self._command_handler)
226
+ self._pubsub_client.subscribe(PLAYBACK, self._playback_handler)
227
+ if self._avrcp_input:
228
+ self._input = AvrcpInput(self._input_command, self._logger)
229
+ self._input.start()
230
+
231
+ def _driver_stop(self) -> None:
232
+ self._pubsub_client.unsubscribe(BLUETOOTH_COMMAND, self._command_handler, ignore_not_found=True)
233
+ self._pubsub_client.unsubscribe(PLAYBACK, self._playback_handler, ignore_not_found=True)
234
+ if self._input is not None:
235
+ self._input.stop()
236
+ self._input = None
237
+ loop, stop_event = self._loop, self._stop_event
238
+ if loop is not None and stop_event is not None:
239
+ loop.call_soon_threadsafe(stop_event.set)
240
+ if self._thread is not None:
241
+ # a call of the mixer that hangs in alsa-lib must not hold up the stop of the box: the thread is a daemon
242
+ self._thread.join(STOP_TIMEOUT)
243
+ if self._thread.is_alive():
244
+ self._logger.error(
245
+ "Bluetooth - the thread does not end within %s s; the box stops without it", STOP_TIMEOUT
246
+ )
247
+ self._thread = None
248
+ self._link = None
249
+
250
+ def healthy(self) -> bool:
251
+ """The driver runs, and so does its thread with the connection to BlueZ."""
252
+ thread = self._thread
253
+ return super().healthy() and thread is not None and thread.is_alive()
254
+
255
+ @classmethod
256
+ def _get_schema(cls) -> dict[str, Any]:
257
+ return get_schema()
@@ -0,0 +1,165 @@
1
+ """GPIO input of a button of the box."""
2
+
3
+ import logging
4
+ import threading
5
+ import time
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from RPi import GPIO
10
+
11
+ from tinyjukebox.core.pubsub import PubSubClient
12
+ from tinyjukebox.devices.baseclasses.adriver import ADriver
13
+ from tinyjukebox.devices.baseclasses.aevents import AEvents
14
+ from tinyjukebox.devices.schema.input import get_schema
15
+ from tinyjukebox.messages import BUTTON_HELD, BUTTON_PRESSED, BUTTON_RELEASED, Button
16
+
17
+ __all__ = ["Input"]
18
+
19
+ _EVENTS = {BUTTON_PRESSED: "pressed", BUTTON_HELD: "held", BUTTON_RELEASED: "released"}
20
+
21
+
22
+ class Input(ADriver, AEvents):
23
+ """Publishes the edges of a button (internal pull-up: pressed = low) with the configured
24
+ :class:`~tinyjukebox.messages.Button` as payload:
25
+
26
+ * pressed: :data:`~tinyjukebox.messages.BUTTON_PRESSED`; after ``hold_timeout`` seconds
27
+ :data:`~tinyjukebox.messages.BUTTON_HELD` - with ``hold_repeat`` again every ``hold_timeout`` seconds for as long
28
+ as the button stays pressed
29
+ * released: :data:`~tinyjukebox.messages.BUTTON_RELEASED`; cancels a pending hold event
30
+
31
+ Example::
32
+
33
+ driver:
34
+ type: input
35
+ pin: 21
36
+ hold_timeout: 1
37
+ hold_repeat: false # optional
38
+ button: volume-up # volume-up, volume-down, next, prev
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ config: Mapping[str, Any],
44
+ pubsub_client: PubSubClient,
45
+ logger: logging.Logger,
46
+ ) -> None:
47
+ ADriver.__init__(self, config, pubsub_client, logger)
48
+ AEvents.__init__(self, self._config, self._logger)
49
+
50
+ self._pin = int(self._config["pin"])
51
+ self._hold_timeout = float(self._config["hold_timeout"])
52
+ self._hold_repeat = bool(self._config.get("hold_repeat", False))
53
+ self._button = Button(self._config["button"])
54
+ self._hold_timer: threading.Timer | None = None
55
+ self._hold_lock = threading.Lock()
56
+ self._holds = False # hold events only between _driver_start and _driver_stop (guarded by _hold_lock)
57
+ self._last_event: tuple[str, float] | None = None # what was published last and when (time.time())
58
+ self._presses = 0
59
+ self._add_event(self._pin, self._callback_pin)
60
+ self._add_ui_command("gpio_state", self._cmd_gpio_state)
61
+
62
+ def _cmd_gpio_state(self, args: str) -> None:
63
+ """gpio_state - displays if the gpio is closed or open: GPIO_STATE"""
64
+ state = "open" if GPIO.input(self._pin) else "closed"
65
+ print(f"[{self._name}] gpio {self._pin} is {state}")
66
+
67
+ def _publish_event(self, topic: str) -> None:
68
+ self._last_event = (_EVENTS[topic], time.time())
69
+ if topic == BUTTON_PRESSED:
70
+ self._presses += 1
71
+ self._publish_value(topic, self._button)
72
+
73
+ def _driver_state(self) -> dict[str, Any]:
74
+ """``pressed``: the level of the pin right now (``None``: it cannot be read) - next to the last event it
75
+ tells a button that does not arrive at the pin from one whose edges get lost."""
76
+ pressed: bool | None = None
77
+ if self.is_started:
78
+ try:
79
+ pressed = not GPIO.input(self._pin)
80
+ except Exception as e: # the view must not fail with the pin
81
+ self._logger.debug("Input._driver_state - gpio %s cannot be read: %s", self._pin, e)
82
+ event, at = self._last_event or (None, None)
83
+ return {
84
+ "button": self._button.value,
85
+ "pin": self._pin,
86
+ "pressed": pressed,
87
+ "hold_timeout": self._hold_timeout,
88
+ "hold_repeat": self._hold_repeat,
89
+ "last_event": event,
90
+ "last_event_at": at,
91
+ "presses": self._presses,
92
+ }
93
+
94
+ def _new_hold_timer(self) -> threading.Timer:
95
+ timer = threading.Timer(self._hold_timeout, self._hold_event)
96
+ timer.name = f"input.{self._name}.hold"
97
+ timer.daemon = True
98
+ timer.start()
99
+ return timer
100
+
101
+ def _start_hold_timer(self) -> None:
102
+ with self._hold_lock:
103
+ if self._hold_timer is not None:
104
+ self._hold_timer.cancel()
105
+ # an edge callback may still run while the driver stops
106
+ self._hold_timer = self._new_hold_timer() if self._holds else None
107
+
108
+ def _cancel_hold_timer(self) -> None:
109
+ with self._hold_lock:
110
+ if self._hold_timer is not None:
111
+ self._hold_timer.cancel()
112
+ self._hold_timer = None
113
+
114
+ def _hold_event(self) -> None:
115
+ """Called by the hold timer ``hold_timeout`` seconds after the button has been pressed (``hold_repeat``:
116
+ and after the previous hold event)."""
117
+ with self._hold_lock:
118
+ if self._hold_timer is not threading.current_thread(): # released, pressed again or stopped meanwhile
119
+ return
120
+ if GPIO.input(self._pin):
121
+ # the edge of the release has been lost (e.g. within the bounce time of the press) - without this check
122
+ # a repeating button would never stop
123
+ self._logger.info("Input._hold_event - not pressed any more.")
124
+ self._cancel_hold_timer()
125
+ self._publish_event(BUTTON_RELEASED)
126
+ return
127
+ self._logger.debug("Input._hold_event - pressed for longer than %s seconds.", self._hold_timeout)
128
+ self._publish_event(BUTTON_HELD)
129
+ if self._hold_repeat:
130
+ with self._hold_lock:
131
+ if self._hold_timer is threading.current_thread(): # neither released nor pressed again meanwhile
132
+ self._hold_timer = self._new_hold_timer()
133
+
134
+ def _callback_pin(self, channel: int) -> None:
135
+ """Edge callback of the GPIO library."""
136
+ state = GPIO.input(self._pin)
137
+ self._logger.info("Input._callback_pin - received event. pin state: %s.", state)
138
+ if not state:
139
+ self._start_hold_timer()
140
+ self._publish_event(BUTTON_PRESSED)
141
+ else:
142
+ self._cancel_hold_timer()
143
+ self._publish_event(BUTTON_RELEASED)
144
+
145
+ def _driver_start(self) -> None:
146
+ GPIO.setmode(GPIO.BCM)
147
+ GPIO.setup(self._pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
148
+ with self._hold_lock:
149
+ self._holds = True
150
+ self._register_events()
151
+
152
+ def _driver_stop(self) -> None:
153
+ self._unregister_events()
154
+ with self._hold_lock:
155
+ self._holds = False
156
+ timer, self._hold_timer = self._hold_timer, None
157
+ if timer is not None:
158
+ timer.cancel()
159
+ if timer is not threading.current_thread():
160
+ timer.join() # a hold event that runs right now reads the pin before it is released
161
+ GPIO.cleanup(self._pin)
162
+
163
+ @classmethod
164
+ def _get_schema(cls) -> dict[str, Any]:
165
+ return get_schema()