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