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