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,44 @@
|
|
|
1
|
+
"""Schema of the driver ``rfid``."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tinyjukebox.devices.schema import adriver
|
|
6
|
+
|
|
7
|
+
__all__ = ["SPI_PINS", "get_schema"]
|
|
8
|
+
|
|
9
|
+
SPI_PINS = (9, 10, 11)
|
|
10
|
+
"""MISO, MOSI and SCLK of SPI0 - rfid-reader would make them outputs, and SPI would not work until the next boot"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _pin(description: str) -> dict[str, Any]:
|
|
14
|
+
return {
|
|
15
|
+
"description": description,
|
|
16
|
+
"type": "integer",
|
|
17
|
+
"minimum": 0,
|
|
18
|
+
"maximum": 53,
|
|
19
|
+
"not": {"enum": list(SPI_PINS)},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_schema() -> dict[str, Any]:
|
|
24
|
+
driver_specific_properties = {
|
|
25
|
+
"pins": {
|
|
26
|
+
"description": "rfid reader pins (BCM numbers); two different ones, none of 9, 10, 11 (SPI0)",
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {
|
|
29
|
+
"pin_rst": _pin("reset pin"),
|
|
30
|
+
"pin_cs": _pin("chip select - rfid-reader drives it itself, any free pin; e.g. 8 for SPI0 CE0"),
|
|
31
|
+
},
|
|
32
|
+
"required": ["pin_rst", "pin_cs"],
|
|
33
|
+
"additionalProperties": False,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
schema = adriver.get_schema("rfid", driver_specific_properties)
|
|
37
|
+
schema["driver"]["properties"]["command"] = {
|
|
38
|
+
"description": "command that starts rfid-reader (optional, default: sudo -n /usr/local/bin/rfid-reader); "
|
|
39
|
+
"the pins are appended as arguments",
|
|
40
|
+
"type": "array",
|
|
41
|
+
"items": {"type": "string"},
|
|
42
|
+
"minItems": 1,
|
|
43
|
+
}
|
|
44
|
+
return schema
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Schema of the driver ``rgbled``."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tinyjukebox.devices.schema import adriver
|
|
6
|
+
from tinyjukebox.messages import Color
|
|
7
|
+
|
|
8
|
+
__all__ = ["COLOR_NAMES", "get_schema"]
|
|
9
|
+
|
|
10
|
+
COLOR_NAMES = [color.name for color in Color]
|
|
11
|
+
|
|
12
|
+
PHYSICAL_CLOSED = {
|
|
13
|
+
"description": "low/high - gpio level that switches the color on",
|
|
14
|
+
"type": "string",
|
|
15
|
+
"enum": ["low", "high"],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_schema() -> dict[str, Any]:
|
|
20
|
+
driver_specific_properties: dict[str, Any] = {
|
|
21
|
+
"initial-color": {"description": "color at startup", "type": "string", "enum": COLOR_NAMES},
|
|
22
|
+
}
|
|
23
|
+
for color in ("red", "green", "blue"):
|
|
24
|
+
driver_specific_properties[f"pin-{color}"] = {"description": f"gpio pin {color}", "type": "integer"}
|
|
25
|
+
driver_specific_properties[f"physical-closed-{color}"] = PHYSICAL_CLOSED
|
|
26
|
+
return adriver.get_schema("rgbled", driver_specific_properties)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Central diagnostics (docs/developer/library.md, section 4.22): what the box logs that is worth a look from the
|
|
2
|
+
library - warnings and errors, and the structured events that a component marks with :func:`diagnostic`.
|
|
3
|
+
|
|
4
|
+
The records are collected by a log handler that never waits: it redacts the record, puts it into a bounded buffer and
|
|
5
|
+
returns. The synchronization takes them from there in its own thread and delivers them to the library (``take`` and
|
|
6
|
+
``delivered``); without a connection they wait. Under pressure the buffer drops INFO before WARNING before ERROR and
|
|
7
|
+
counts what it dropped. The log file of the box stays the full record; the library gets what helps to find a fault.
|
|
8
|
+
|
|
9
|
+
Nothing that identifies a person or opens a door leaves the box: :func:`redact` removes passwords, keys, tokens,
|
|
10
|
+
certificates, the query of addresses, tag UIDs, bluetooth addresses and the ids of Spotify.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import collections
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import traceback
|
|
19
|
+
from collections.abc import Callable, Mapping
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from tinyjukebox.sync.util import timestamp
|
|
23
|
+
|
|
24
|
+
__all__ = ["CAPACITY", "Collector", "diagnostic", "redact"]
|
|
25
|
+
|
|
26
|
+
CAPACITY = 2000 # records a box keeps for the library at most
|
|
27
|
+
MAX_MESSAGE = 2000 # characters of a message
|
|
28
|
+
MAX_TRACEBACK = 8000
|
|
29
|
+
STARTED = time.monotonic()
|
|
30
|
+
|
|
31
|
+
_REDACTIONS = [
|
|
32
|
+
# certificates and keys
|
|
33
|
+
(re.compile(r"-----BEGIN [A-Z ]+-----.*?-----END [A-Z ]+-----", re.S), "<pem>"),
|
|
34
|
+
# Authorization: Box id:secret, Bearer ..., token ...
|
|
35
|
+
(
|
|
36
|
+
re.compile(r"(?i)\b(authorization|bearer|token|box)\b(\s*[:=]?\s*)(\S+:)?[A-Za-z0-9_\-.~+/]{16,}=*"),
|
|
37
|
+
r"\1\2<secret>",
|
|
38
|
+
),
|
|
39
|
+
# password=..., "secret": "...", key: ...
|
|
40
|
+
(
|
|
41
|
+
re.compile(
|
|
42
|
+
r"""(?i)(["']?\b(?:password|passwd|secret|key|token|bootstrap|client_secret)["']?\s*[:=]\s*)(["']?)"""
|
|
43
|
+
r"""[^\s"',;}]+"""
|
|
44
|
+
),
|
|
45
|
+
r"\1\2<secret>",
|
|
46
|
+
),
|
|
47
|
+
# the query of an address may carry a token or who listens
|
|
48
|
+
(re.compile(r"(https?://[^\s?#'\"]+)\?[^\s'\"]*"), r"\1?<query>"),
|
|
49
|
+
# user:password@ in an address
|
|
50
|
+
(re.compile(r"(https?://)[^\s/@'\"]+@"), r"\1<user>@"),
|
|
51
|
+
# ids of Spotify (the kind stays)
|
|
52
|
+
(re.compile(r"\bspotify:(album|playlist|track|episode|show|audiobook|artist):[A-Za-z0-9]+"), r"spotify:\1:<id>"),
|
|
53
|
+
(re.compile(r"(open\.spotify\.com/(?:[a-z]+/)?)[A-Za-z0-9]{10,}"), r"\1<id>"),
|
|
54
|
+
# hex bytes with colons, never a part of a longer run: six are the address of a bluetooth device, four to ten (four,
|
|
55
|
+
# seven or ten for a tag) the UID of a tag
|
|
56
|
+
(re.compile(r"(?<![0-9A-Fa-f:])[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}(?![0-9A-Fa-f:])"), "<mac>"),
|
|
57
|
+
(re.compile(r"(?<![0-9A-Fa-f:])[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){3,9}(?![0-9A-Fa-f:])"), "<uid>"),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def redact(text: str) -> str:
|
|
62
|
+
"""``text`` without credentials, certificate material, queries of addresses, tag UIDs, bluetooth addresses and ids
|
|
63
|
+
of Spotify."""
|
|
64
|
+
for pattern, replacement in _REDACTIONS:
|
|
65
|
+
text = pattern.sub(replacement, text)
|
|
66
|
+
return text
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def diagnostic(code: str, text: str | None = None, **params: Any) -> dict[str, Any]:
|
|
70
|
+
"""``extra`` for a log call that is a structured event: a stable code and safe parameters, e.g.
|
|
71
|
+
``logger.info("radio started in %d ms", ms, extra=diagnostic("radio.startup", resolve_ms=12))``. The library gets
|
|
72
|
+
such a record whatever its level - with ``text`` instead of the message of the log file, if that says more than
|
|
73
|
+
the library should know (e.g. the whole address of a stream)."""
|
|
74
|
+
return {"diagnostic": {"code": code, "params": params, "text": text}}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_PRIORITY = {"DEBUG": 0, "INFO": 0, "WARNING": 1, "ERROR": 2, "CRITICAL": 2}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Collector(logging.Handler):
|
|
81
|
+
"""Collects records for the library. ``level``: from which level an ordinary record is collected; ``urgent()`` is
|
|
82
|
+
called for an error (it must not block - e.g. it wakes the synchronization)."""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
level: int = logging.WARNING,
|
|
87
|
+
capacity: int = CAPACITY,
|
|
88
|
+
urgent: Callable[[], None] | None = None,
|
|
89
|
+
root: str = "",
|
|
90
|
+
) -> None:
|
|
91
|
+
super().__init__(logging.DEBUG)
|
|
92
|
+
self._level = level
|
|
93
|
+
self._capacity = capacity
|
|
94
|
+
self.urgent = urgent
|
|
95
|
+
self._root = root + "." if root else ""
|
|
96
|
+
self._lock = threading.Lock()
|
|
97
|
+
self._records: collections.deque[dict[str, Any]] = collections.deque()
|
|
98
|
+
self._number = 0
|
|
99
|
+
self._taken = 0 # the highest number handed out by take(): such a record is not changed any more
|
|
100
|
+
self.dropped: dict[str, int] = collections.defaultdict(int) # by level, since the last delivery
|
|
101
|
+
|
|
102
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
103
|
+
marked = getattr(record, "diagnostic", None)
|
|
104
|
+
if record.levelno < self._level and marked is None:
|
|
105
|
+
return
|
|
106
|
+
try:
|
|
107
|
+
entry = self._entry(record, marked)
|
|
108
|
+
except Exception: # a broken log call must not break the logging - nor the box
|
|
109
|
+
return
|
|
110
|
+
with self._lock:
|
|
111
|
+
last = self._records[-1] if self._records else None
|
|
112
|
+
if (
|
|
113
|
+
last is not None
|
|
114
|
+
and last["seq"] > self._taken
|
|
115
|
+
and (last["code"], last["message"], last["level"]) == (entry["code"], entry["message"], entry["level"])
|
|
116
|
+
):
|
|
117
|
+
last["repeated"] = last.get("repeated", 1) + 1 # a fault that repeats is one record
|
|
118
|
+
last["last"] = entry["time"]
|
|
119
|
+
else:
|
|
120
|
+
self._number += 1
|
|
121
|
+
self._records.append({"seq": self._number, **entry})
|
|
122
|
+
if len(self._records) > self._capacity:
|
|
123
|
+
self._drop()
|
|
124
|
+
if record.levelno >= logging.ERROR and self.urgent is not None:
|
|
125
|
+
self.urgent()
|
|
126
|
+
|
|
127
|
+
def _drop(self) -> None:
|
|
128
|
+
"""The oldest record of the lowest priority goes; what is dropped is counted."""
|
|
129
|
+
lowest = min(_PRIORITY.get(entry["level"], 0) for entry in self._records)
|
|
130
|
+
for index, entry in enumerate(self._records):
|
|
131
|
+
if _PRIORITY.get(entry["level"], 0) == lowest:
|
|
132
|
+
del self._records[index]
|
|
133
|
+
self.dropped[entry["level"]] += entry.get("repeated", 1)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
def _entry(self, record: logging.LogRecord, marked: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
137
|
+
component = record.name[len(self._root) :] if record.name.startswith(self._root) else record.name
|
|
138
|
+
message = redact(marked["text"] if marked and marked.get("text") else record.getMessage())[:MAX_MESSAGE]
|
|
139
|
+
entry: dict[str, Any] = {
|
|
140
|
+
"time": timestamp(), "uptime": round(record.created - time.time() + time.monotonic() - STARTED, 1),
|
|
141
|
+
"level": record.levelname, "component": component,
|
|
142
|
+
# a stable code: the one of a structured event, else the text of the call before its values
|
|
143
|
+
"code": marked["code"] if marked else redact(str(record.msg))[:200],
|
|
144
|
+
"message": message,
|
|
145
|
+
} # fmt: skip
|
|
146
|
+
if marked and marked.get("params"):
|
|
147
|
+
entry["params"] = {key: _safe(value) for key, value in marked["params"].items()}
|
|
148
|
+
if record.exc_info and record.exc_info[1] is not None:
|
|
149
|
+
entry["exception"] = type(record.exc_info[1]).__name__
|
|
150
|
+
entry["traceback"] = redact("".join(traceback.format_exception(*record.exc_info)))[-MAX_TRACEBACK:]
|
|
151
|
+
return entry
|
|
152
|
+
|
|
153
|
+
def take(self, limit: int = 200) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
|
154
|
+
"""The oldest records that wait, and what was dropped - they stay until :meth:`delivered`."""
|
|
155
|
+
with self._lock:
|
|
156
|
+
taken = [dict(entry) for entry in list(self._records)[:limit]]
|
|
157
|
+
if taken:
|
|
158
|
+
self._taken = max(self._taken, taken[-1]["seq"])
|
|
159
|
+
return taken, dict(self.dropped)
|
|
160
|
+
|
|
161
|
+
def delivered(self, last: int, dropped: Mapping[str, int]) -> None:
|
|
162
|
+
"""The library has the records up to number ``last`` and knows of what was dropped until then."""
|
|
163
|
+
with self._lock:
|
|
164
|
+
while self._records and self._records[0]["seq"] <= last:
|
|
165
|
+
self._records.popleft()
|
|
166
|
+
for level, count in dropped.items():
|
|
167
|
+
self.dropped[level] -= count
|
|
168
|
+
if self.dropped[level] <= 0:
|
|
169
|
+
del self.dropped[level]
|
|
170
|
+
|
|
171
|
+
def waiting(self) -> int:
|
|
172
|
+
with self._lock:
|
|
173
|
+
return len(self._records)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _safe(value: Any) -> Any:
|
|
177
|
+
if isinstance(value, bool | int | float) or value is None:
|
|
178
|
+
return value
|
|
179
|
+
return redact(str(value))[:500]
|
tinyjukebox/errors.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Errors of the box control (see :mod:`tinyjukebox.control`); the rest api maps them to http status codes."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ApiError",
|
|
7
|
+
"BadRequest",
|
|
8
|
+
"Conflict",
|
|
9
|
+
"Forbidden",
|
|
10
|
+
"Gone",
|
|
11
|
+
"InsufficientStorage",
|
|
12
|
+
"LengthRequired",
|
|
13
|
+
"MethodNotAllowed",
|
|
14
|
+
"NotFound",
|
|
15
|
+
"PayloadTooLarge",
|
|
16
|
+
"ServiceUnavailable",
|
|
17
|
+
"TooManyRequests",
|
|
18
|
+
"Unauthorized",
|
|
19
|
+
"Unprocessable",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApiError(Exception):
|
|
24
|
+
"""Base class - ``message`` is the English text of the error, ``details`` are added to the json error response.
|
|
25
|
+
|
|
26
|
+
An error that a user of the web page runs into has a ``code`` among its details, and the values its text needs
|
|
27
|
+
(``Conflict("tag ... exists already.", code="tag.exists", uid=uid)``): the page shows its own text for the code,
|
|
28
|
+
in its language (``error.<code>`` in ``web/web/texts``), and the message for an error without one."""
|
|
29
|
+
|
|
30
|
+
status = 500
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str, **details: Any) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.message = message
|
|
35
|
+
self.details = details
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# the names follow the http status texts - an 'Error' suffix would only add noise
|
|
39
|
+
class BadRequest(ApiError): # noqa: N818
|
|
40
|
+
status = 400
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Unauthorized(ApiError): # noqa: N818
|
|
44
|
+
status = 401
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Forbidden(ApiError): # noqa: N818
|
|
48
|
+
status = 403
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NotFound(ApiError): # noqa: N818
|
|
52
|
+
status = 404
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MethodNotAllowed(ApiError): # noqa: N818
|
|
56
|
+
status = 405
|
|
57
|
+
|
|
58
|
+
def __init__(self, allowed: list[str]) -> None:
|
|
59
|
+
super().__init__("method not allowed.", allowed=sorted(set(allowed)))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Conflict(ApiError): # noqa: N818
|
|
63
|
+
status = 409
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Gone(ApiError): # noqa: N818
|
|
67
|
+
status = 410
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class LengthRequired(ApiError): # noqa: N818
|
|
71
|
+
status = 411
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PayloadTooLarge(ApiError): # noqa: N818
|
|
75
|
+
status = 413
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Unprocessable(ApiError): # noqa: N818
|
|
79
|
+
status = 422
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TooManyRequests(ApiError): # noqa: N818
|
|
83
|
+
status = 429
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ServiceUnavailable(ApiError): # noqa: N818
|
|
87
|
+
status = 503
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class InsufficientStorage(ApiError): # noqa: N818
|
|
91
|
+
status = 507
|
tinyjukebox/headset.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""The bluetooth headset as seen by the box: output and volume follow the connection, a lost headset pauses the
|
|
2
|
+
playback, next + prev held start the pairing. Bluetooth itself is the business of the bluetooth driver (or of the
|
|
3
|
+
virtual headset) - they talk via :data:`~tinyjukebox.messages.BLUETOOTH_STATE` and
|
|
4
|
+
:data:`~tinyjukebox.messages.BLUETOOTH_COMMAND`."""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
from tinyjukebox.albumplayer.simpleplayer import AudioOutput, bluealsa_device
|
|
12
|
+
from tinyjukebox.core.logging import get_child
|
|
13
|
+
from tinyjukebox.core.pubsub import PubSubClient
|
|
14
|
+
from tinyjukebox.messages import (
|
|
15
|
+
BLUETOOTH_COMMAND,
|
|
16
|
+
BLUETOOTH_STATE,
|
|
17
|
+
BUTTON_HELD,
|
|
18
|
+
BUTTON_PRESSED,
|
|
19
|
+
BUTTON_RELEASED,
|
|
20
|
+
BluetoothAction,
|
|
21
|
+
BluetoothCommand,
|
|
22
|
+
BluetoothState,
|
|
23
|
+
Button,
|
|
24
|
+
PairingResult,
|
|
25
|
+
)
|
|
26
|
+
from tinyjukebox.rgbled import Color, RGBLed
|
|
27
|
+
from tinyjukebox.stm.stmoperation import AlbumControl
|
|
28
|
+
from tinyjukebox.volumemanager import VolumeProfile, Volumes
|
|
29
|
+
|
|
30
|
+
__all__ = ["PAIRING_BUTTONS", "BluetoothVolumeControl", "Headset"]
|
|
31
|
+
|
|
32
|
+
PAIRING_BUTTONS = frozenset({Button.NEXT, Button.PREV})
|
|
33
|
+
"""Held together (``hold_timeout`` of their input drivers): search a headset and pair it."""
|
|
34
|
+
SEARCH_LED_TIMEOUT = 120.0 # the search led ends by itself at the latest - the driver ends a search after 60 s
|
|
35
|
+
SEARCH_BLINK = 0.2
|
|
36
|
+
PAIRED_LED = 2.0 # seconds white after a successful pairing
|
|
37
|
+
FAILED_LED = 3.0 # seconds red blinking after a failed search
|
|
38
|
+
CONNECT_SETTLE = 3.0
|
|
39
|
+
"""Seconds after a connect in which a louder volume that the headset reports is the one of the last time, which
|
|
40
|
+
arrives late: the volume of the box is set again (never as loud as the last time) - unless the volume has been changed
|
|
41
|
+
on the box meanwhile."""
|
|
42
|
+
SPEAKER_AFTER_LOSS = 3.0
|
|
43
|
+
"""Seconds after the loss of the headset after which the speaker is the output at the latest - normally it is as soon
|
|
44
|
+
as the playback has paused."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _address(state: BluetoothState) -> str | None:
|
|
48
|
+
return state.device.address if state.device is not None else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BluetoothVolumeControl:
|
|
52
|
+
"""Volume of the headset: set by the bluetooth driver (:data:`~tinyjukebox.messages.BLUETOOTH_COMMAND`) - only while
|
|
53
|
+
the headset is connected."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, pubsub_client: PubSubClient, logger: logging.Logger) -> None:
|
|
56
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
57
|
+
self._pubsub_client = pubsub_client
|
|
58
|
+
self.connected = False
|
|
59
|
+
|
|
60
|
+
def set_volume(self, volume: float) -> None:
|
|
61
|
+
if self.connected:
|
|
62
|
+
self._pubsub_client.publish(BLUETOOTH_COMMAND, BluetoothCommand(BluetoothAction.VOLUME, volume))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Albums(Protocol):
|
|
66
|
+
def get_album(self) -> AlbumControl | None: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Headset:
|
|
70
|
+
"""Reacts on the :class:`~tinyjukebox.messages.BluetoothState` of the driver:
|
|
71
|
+
|
|
72
|
+
* connected: the players play on the headset (a playing track continues there), the buttons change its volume,
|
|
73
|
+
which starts at its start value,
|
|
74
|
+
* disconnected: a playing album or radio station pauses (it continues if the headset comes back within 10 min),
|
|
75
|
+
the speaker is the output again once the playback has paused - and plays only after a button press,
|
|
76
|
+
* volume changed on the headset: taken over, limited to its maximum,
|
|
77
|
+
* search: led overlay (blue blinking fast, then 2 s white or 3 s red blinking).
|
|
78
|
+
|
|
79
|
+
The handlers run in the pubsub dispatch thread - they only queue events and publish messages.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
pubsub_client: PubSubClient,
|
|
85
|
+
logger: logging.Logger,
|
|
86
|
+
rgbled: RGBLed,
|
|
87
|
+
output: AudioOutput,
|
|
88
|
+
volumes: Volumes,
|
|
89
|
+
volume_control: BluetoothVolumeControl,
|
|
90
|
+
albums: Albums,
|
|
91
|
+
) -> None:
|
|
92
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
93
|
+
self._pubsub_client = pubsub_client
|
|
94
|
+
self._rgbled = rgbled
|
|
95
|
+
self._output = output
|
|
96
|
+
self._volumes = volumes
|
|
97
|
+
self._volume_control = volume_control
|
|
98
|
+
self._albums = albums
|
|
99
|
+
self._lock = threading.RLock() # the handlers publish - a synchronous pubsub client calls them again
|
|
100
|
+
self._state: BluetoothState | None = None
|
|
101
|
+
self._held: set[Button] = set()
|
|
102
|
+
# counts the losses of the headset: a switch to the speaker asked for before a reconnect is void
|
|
103
|
+
self._losses = 0
|
|
104
|
+
self._speaker_timer: threading.Timer | None = None
|
|
105
|
+
self._connected_at = 0.0 # monotonic time of the last connect
|
|
106
|
+
# the volume the box set at the connect, or a quieter one the headset reported since - None once settled
|
|
107
|
+
self._start_volume: float | None = None
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def state(self) -> BluetoothState | None:
|
|
111
|
+
"""The last state of the driver - ``None``: there is no bluetooth driver (or it has not reported yet)."""
|
|
112
|
+
return self._state
|
|
113
|
+
|
|
114
|
+
def info(self) -> dict[str, Any]:
|
|
115
|
+
state = self._state
|
|
116
|
+
if state is None:
|
|
117
|
+
return {"available": False, "virtual": False, "device": None, "connected": False, "battery": None,
|
|
118
|
+
"volume": None, "pairing": False, "pairing_result": None} # fmt: skip
|
|
119
|
+
device = state.device
|
|
120
|
+
return {
|
|
121
|
+
"available": True,
|
|
122
|
+
"virtual": state.virtual,
|
|
123
|
+
"device": {"address": device.address, "name": device.name} if device is not None else None,
|
|
124
|
+
"connected": state.connected,
|
|
125
|
+
"battery": state.battery,
|
|
126
|
+
"volume": state.volume,
|
|
127
|
+
"pairing": state.pairing,
|
|
128
|
+
"pairing_result": state.pairing_result.value if state.pairing_result is not None else None,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# --- commands -------------------------------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def pair(self) -> None:
|
|
134
|
+
"""Search a headset in pairing mode and pair it (the driver ignores it while a search runs). Without
|
|
135
|
+
bluetooth the led shows the failure at once."""
|
|
136
|
+
if self._state is None:
|
|
137
|
+
self._logger.warning("pair - bluetooth is not available")
|
|
138
|
+
self._rgbled.show_overlay(Color.RED, Color.BLACK, SEARCH_BLINK, FAILED_LED)
|
|
139
|
+
return
|
|
140
|
+
self._logger.info("pair - starting the search")
|
|
141
|
+
self._pubsub_client.publish(BLUETOOTH_COMMAND, BluetoothCommand(BluetoothAction.PAIR))
|
|
142
|
+
|
|
143
|
+
def unpair(self) -> None:
|
|
144
|
+
self._logger.info("unpair")
|
|
145
|
+
self._pubsub_client.publish(BLUETOOTH_COMMAND, BluetoothCommand(BluetoothAction.UNPAIR))
|
|
146
|
+
|
|
147
|
+
# --- handlers -------------------------------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def _state_handler(self, message: object) -> None:
|
|
150
|
+
if not isinstance(message, BluetoothState):
|
|
151
|
+
self._logger.error("_state_handler - expected a BluetoothState, received %r", message)
|
|
152
|
+
return
|
|
153
|
+
with self._lock:
|
|
154
|
+
previous = self._state or BluetoothState()
|
|
155
|
+
self._state = message
|
|
156
|
+
self._logger.info("_state_handler - %s", message)
|
|
157
|
+
# the address counts: BlueZ may learn the name of the headset only after it has connected
|
|
158
|
+
same_device = _address(previous) == _address(message)
|
|
159
|
+
if message.connected and (not previous.connected or not same_device):
|
|
160
|
+
self._connected(message)
|
|
161
|
+
elif previous.connected and not message.connected:
|
|
162
|
+
self._disconnected()
|
|
163
|
+
elif message.connected and message.volume is not None and message.volume != previous.volume:
|
|
164
|
+
self._volume_reported(message.volume)
|
|
165
|
+
self._show_search(previous, message)
|
|
166
|
+
|
|
167
|
+
def _volume_reported(self, volume: float) -> None:
|
|
168
|
+
"""Changed on the headset (absolute volume) - or the report of an own change, which is ignored.
|
|
169
|
+
|
|
170
|
+
Just after a connect a louder volume is the one of the last time, which BlueALSA may report only after the box
|
|
171
|
+
has set its start value: the volume of the box is set again, once (:data:`CONNECT_SETTLE`)."""
|
|
172
|
+
manager = self._volumes.manager(VolumeProfile.HEADPHONES)
|
|
173
|
+
start = self._start_volume
|
|
174
|
+
if start is not None and (
|
|
175
|
+
time.monotonic() - self._connected_at >= CONNECT_SETTLE or round(manager.get_volume()) != round(start)
|
|
176
|
+
):
|
|
177
|
+
start = self._start_volume = None # settled - or changed on the box (buttons, web page)
|
|
178
|
+
if start is not None and round(volume) > round(start):
|
|
179
|
+
self._start_volume = None
|
|
180
|
+
self._logger.info("the headset reports %s%% just after it connected - the volume of the box counts",
|
|
181
|
+
round(volume)) # fmt: skip
|
|
182
|
+
manager.update_volume()
|
|
183
|
+
return
|
|
184
|
+
manager.mixer_changed(volume)
|
|
185
|
+
if start is not None:
|
|
186
|
+
self._start_volume = manager.get_volume() # a quieter one is taken over
|
|
187
|
+
|
|
188
|
+
def _connected(self, state: BluetoothState) -> None:
|
|
189
|
+
assert state.device is not None # noqa: S101 - connected implies a paired headset
|
|
190
|
+
address = state.device.address
|
|
191
|
+
self._logger.info("headset %s connected - playing on it", address)
|
|
192
|
+
self._losses += 1 # a switch to the speaker that waits for a paused playback is void now
|
|
193
|
+
self._cancel_speaker_timer()
|
|
194
|
+
self._connected_at = time.monotonic()
|
|
195
|
+
volume = self._volumes.manager(VolumeProfile.HEADPHONES)
|
|
196
|
+
self._volume_control.connected = True
|
|
197
|
+
self._volumes.use(VolumeProfile.HEADPHONES)
|
|
198
|
+
volume.reset() # never as loud as the last time
|
|
199
|
+
self._start_volume = volume.get_volume()
|
|
200
|
+
# the players open the headset with the current volume - the volume set above may arrive after the first sound
|
|
201
|
+
self._output.use_headset(None if state.virtual else lambda: bluealsa_device(address, volume.get_volume()))
|
|
202
|
+
album = self._albums.get_album()
|
|
203
|
+
if album is not None:
|
|
204
|
+
album.cmd_output_changed()
|
|
205
|
+
|
|
206
|
+
def _disconnected(self) -> None:
|
|
207
|
+
self._volume_control.connected = False
|
|
208
|
+
self._volumes.use(VolumeProfile.SPEAKER)
|
|
209
|
+
album = self._albums.get_album()
|
|
210
|
+
if album is None:
|
|
211
|
+
self._logger.info("headset disconnected - the speaker is the output again")
|
|
212
|
+
self._output.use_speaker()
|
|
213
|
+
return
|
|
214
|
+
self._logger.info("headset disconnected - pausing, the speaker is the output again once the playback paused")
|
|
215
|
+
# until the playback has paused the output stays on the headset that has gone: what starts meanwhile - a
|
|
216
|
+
# track that was due, a button pressed just before - fails there and pauses instead of playing on the speaker
|
|
217
|
+
self._losses += 1
|
|
218
|
+
loss = self._losses
|
|
219
|
+
|
|
220
|
+
def speaker() -> None:
|
|
221
|
+
self._speaker_after(loss)
|
|
222
|
+
|
|
223
|
+
self._cancel_speaker_timer()
|
|
224
|
+
timer = threading.Timer(SPEAKER_AFTER_LOSS, speaker) # if the playback ends without handling it
|
|
225
|
+
timer.name, timer.daemon = "Headset.speaker", True
|
|
226
|
+
self._speaker_timer = timer
|
|
227
|
+
timer.start()
|
|
228
|
+
album.cmd_output_lost(then=speaker)
|
|
229
|
+
|
|
230
|
+
def _speaker_after(self, loss: int) -> None:
|
|
231
|
+
"""The playback has handled the loss of the headset (or the time is up): the speaker is the output - unless
|
|
232
|
+
the headset has come back meanwhile."""
|
|
233
|
+
with self._lock:
|
|
234
|
+
if loss != self._losses:
|
|
235
|
+
return
|
|
236
|
+
self._losses += 1 # done: the other one of the two calls does nothing
|
|
237
|
+
self._cancel_speaker_timer()
|
|
238
|
+
self._output.use_speaker()
|
|
239
|
+
self._logger.info("the speaker is the output again")
|
|
240
|
+
|
|
241
|
+
def _cancel_speaker_timer(self) -> None:
|
|
242
|
+
timer, self._speaker_timer = self._speaker_timer, None
|
|
243
|
+
if timer is not None:
|
|
244
|
+
timer.cancel()
|
|
245
|
+
|
|
246
|
+
def _show_search(self, previous: BluetoothState, state: BluetoothState) -> None:
|
|
247
|
+
if state.pairing and not previous.pairing:
|
|
248
|
+
self._rgbled.show_overlay(Color.BLUE, Color.BLACK, SEARCH_BLINK, SEARCH_LED_TIMEOUT)
|
|
249
|
+
elif previous.pairing and not state.pairing:
|
|
250
|
+
if state.pairing_result is PairingResult.PAIRED:
|
|
251
|
+
self._rgbled.show_overlay(Color.WHITE, duration=PAIRED_LED)
|
|
252
|
+
else:
|
|
253
|
+
self._rgbled.show_overlay(Color.RED, Color.BLACK, SEARCH_BLINK, FAILED_LED)
|
|
254
|
+
|
|
255
|
+
def _button_handler(self, topic: str, message: object) -> None:
|
|
256
|
+
"""Next + prev held together start the search. A press or release ends the hold of a button."""
|
|
257
|
+
if not isinstance(message, Button):
|
|
258
|
+
return
|
|
259
|
+
with self._lock:
|
|
260
|
+
if topic != BUTTON_HELD:
|
|
261
|
+
self._held.discard(message)
|
|
262
|
+
return
|
|
263
|
+
self._held.add(message)
|
|
264
|
+
if not self._held >= PAIRING_BUTTONS:
|
|
265
|
+
return
|
|
266
|
+
self._held -= PAIRING_BUTTONS
|
|
267
|
+
self._logger.info("next and prev held - pairing")
|
|
268
|
+
self.pair()
|
|
269
|
+
|
|
270
|
+
def _pressed(self, message: object) -> None:
|
|
271
|
+
self._button_handler(BUTTON_PRESSED, message)
|
|
272
|
+
|
|
273
|
+
def _held_handler(self, message: object) -> None:
|
|
274
|
+
self._button_handler(BUTTON_HELD, message)
|
|
275
|
+
|
|
276
|
+
def _released(self, message: object) -> None:
|
|
277
|
+
self._button_handler(BUTTON_RELEASED, message)
|
|
278
|
+
|
|
279
|
+
# --- start / stop ---------------------------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
def start(self) -> None:
|
|
282
|
+
self._pubsub_client.subscribe(BLUETOOTH_STATE, self._state_handler)
|
|
283
|
+
self._pubsub_client.subscribe(BUTTON_PRESSED, self._pressed)
|
|
284
|
+
self._pubsub_client.subscribe(BUTTON_HELD, self._held_handler)
|
|
285
|
+
self._pubsub_client.subscribe(BUTTON_RELEASED, self._released)
|
|
286
|
+
|
|
287
|
+
def stop(self) -> None:
|
|
288
|
+
self._pubsub_client.unsubscribe(BLUETOOTH_STATE, self._state_handler, ignore_not_found=True)
|
|
289
|
+
self._pubsub_client.unsubscribe(BUTTON_PRESSED, self._pressed, ignore_not_found=True)
|
|
290
|
+
self._pubsub_client.unsubscribe(BUTTON_HELD, self._held_handler, ignore_not_found=True)
|
|
291
|
+
self._pubsub_client.unsubscribe(BUTTON_RELEASED, self._released, ignore_not_found=True)
|
|
292
|
+
with self._lock:
|
|
293
|
+
self._losses += 1 # a switch to the speaker that runs right now is void
|
|
294
|
+
self._cancel_speaker_timer()
|
|
295
|
+
self._volume_control.connected = False # nothing is published after the pubsub client has been disconnected
|
|
296
|
+
self._rgbled.clear_overlay()
|