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,297 @@
|
|
|
1
|
+
"""RFID reader RC522 - publishes the tag that the helper program ``rfid-reader`` reports."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import selectors
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from tinyjukebox.core.pubsub import PubSubClient
|
|
14
|
+
from tinyjukebox.core.threads import LoggerThread
|
|
15
|
+
from tinyjukebox.devices.baseclasses.adriver import ADriver
|
|
16
|
+
from tinyjukebox.devices.schema.rfid import SPI_PINS, get_schema
|
|
17
|
+
from tinyjukebox.messages import RFID_POLL, RFID_STATE, RfidState
|
|
18
|
+
|
|
19
|
+
__all__ = ["RFID"]
|
|
20
|
+
|
|
21
|
+
DEFAULT_COMMAND = ["sudo", "-n", "/usr/local/bin/rfid-reader"]
|
|
22
|
+
"""``rfid-reader`` needs root (bcm2835 maps the SPI registers via /dev/mem) - tinyjukebox itself does not."""
|
|
23
|
+
|
|
24
|
+
RESTART_INTERVAL = 5.0 # seconds until rfid-reader is started again after it has ended
|
|
25
|
+
STOP_TIMEOUT = 2.0 # seconds rfid-reader gets to end after SIGTERM
|
|
26
|
+
ALIVE_INTERVAL = 10.0 # seconds between two "alive" of rfid-reader - its default (option --alive)
|
|
27
|
+
ALIVE_TIMEOUT = 3 # intervals without any line until rfid-reader counts as stuck
|
|
28
|
+
READ_TIMEOUT = 0.1 # seconds the thread waits for output before it looks at the stop and the watchdog again
|
|
29
|
+
MAX_SAID = 100 # messages of rfid-reader that are remembered as logged until it works
|
|
30
|
+
|
|
31
|
+
_READY = "ready"
|
|
32
|
+
_REMOVED = "removed"
|
|
33
|
+
_ALIVE = "alive"
|
|
34
|
+
_TAG = re.compile(r"tag ([0-9A-F]{2}(?::[0-9A-F]{2})*)")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RFID(ADriver):
|
|
38
|
+
"""Driver for the RC522 RFID reader.
|
|
39
|
+
|
|
40
|
+
The reader is not read by this process: the driver starts the helper program ``rfid-reader`` (C++,
|
|
41
|
+
``cpp/rfid-reader`` of the repository) and reads its output - one line per change::
|
|
42
|
+
|
|
43
|
+
ready the reader is initialized, no tag has been seen yet
|
|
44
|
+
tag 04:1A:2B:3C:4D:5E:6F a tag has been placed (its complete uid, 4 or 7 bytes)
|
|
45
|
+
removed the tag has been removed
|
|
46
|
+
alive the program still runs (at its start, then every 10 s)
|
|
47
|
+
|
|
48
|
+
``rfid-reader`` is the read loop of the old box (rfid-2-mqtt): only there the presence detection of the RC522
|
|
49
|
+
has proven itself. Every change of the tag is published as :class:`~tinyjukebox.messages.RfidState` to
|
|
50
|
+
:data:`~tinyjukebox.messages.RFID_STATE`; a message on :data:`~tinyjukebox.messages.RFID_POLL` publishes the current
|
|
51
|
+
state in any case.
|
|
52
|
+
|
|
53
|
+
If ``rfid-reader`` cannot be started or ends, the state is "no tag" (published if a tag was there) and it is
|
|
54
|
+
started again every :data:`RESTART_INTERVAL` seconds. A program that has written ``alive`` once and then nothing
|
|
55
|
+
for :data:`ALIVE_TIMEOUT` intervals is stuck: the driver ends it and starts it again the same way. A program that
|
|
56
|
+
never writes ``alive`` is never ended.
|
|
57
|
+
|
|
58
|
+
Example::
|
|
59
|
+
|
|
60
|
+
driver:
|
|
61
|
+
type: rfid
|
|
62
|
+
pins: # BCM numbers
|
|
63
|
+
pin_rst: 26
|
|
64
|
+
pin_cs: 8 # SPI0 CE0
|
|
65
|
+
command: [sudo, -n, /usr/local/bin/rfid-reader] # optional - this is the default
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
restart_interval = RESTART_INTERVAL
|
|
69
|
+
stop_timeout = STOP_TIMEOUT
|
|
70
|
+
alive_interval = ALIVE_INTERVAL
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
config: Mapping[str, Any],
|
|
75
|
+
pubsub_client: PubSubClient,
|
|
76
|
+
logger: logging.Logger,
|
|
77
|
+
) -> None:
|
|
78
|
+
ADriver.__init__(self, config, pubsub_client, logger)
|
|
79
|
+
|
|
80
|
+
pins = self._config["pins"]
|
|
81
|
+
_check_pins(pins["pin_rst"], pins["pin_cs"])
|
|
82
|
+
self._command = [
|
|
83
|
+
*self._config.get("command", DEFAULT_COMMAND),
|
|
84
|
+
"--pin-rst",
|
|
85
|
+
str(pins["pin_rst"]),
|
|
86
|
+
"--pin-cs",
|
|
87
|
+
str(pins["pin_cs"]),
|
|
88
|
+
]
|
|
89
|
+
self._lock = threading.RLock() # the reader thread and the handler of RFID_POLL publish the state
|
|
90
|
+
self._uid: str | None = None
|
|
91
|
+
self._error: str | None = "rfid-reader has not been started"
|
|
92
|
+
self._published = False
|
|
93
|
+
self._changed_at: float | None = None # when the uid or the error changed last (time.time())
|
|
94
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
95
|
+
self._stop_reader = threading.Event() # one per start: a thread of an earlier start sees its own
|
|
96
|
+
self._thread: LoggerThread | None = None
|
|
97
|
+
self._said: set[str] = set() # messages logged as warning since rfid-reader worked last (only its thread)
|
|
98
|
+
|
|
99
|
+
# --- state ----------------------------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def _set_state(self, uid: str | None, error: str | None = None) -> None:
|
|
102
|
+
"""Publish the state if the uid has changed (the first state is always published), with the error of that
|
|
103
|
+
moment. An error that comes or goes while the uid stays is in :meth:`state` only: ``STMOperation`` takes every
|
|
104
|
+
``None`` for "no tag", and a tag placed on the web page must not end because rfid-reader starts again."""
|
|
105
|
+
with self._lock:
|
|
106
|
+
changed = uid != self._uid or not self._published
|
|
107
|
+
if changed or error != self._error:
|
|
108
|
+
self._changed_at = time.time()
|
|
109
|
+
self._uid, self._error = uid, error
|
|
110
|
+
if changed:
|
|
111
|
+
self._publish()
|
|
112
|
+
|
|
113
|
+
def _publish(self) -> None:
|
|
114
|
+
with self._lock:
|
|
115
|
+
self._published = True
|
|
116
|
+
self._publish_value(RFID_STATE, RfidState(self._uid, self._error))
|
|
117
|
+
|
|
118
|
+
def _poll_handler(self, message: object) -> None:
|
|
119
|
+
"""Handler for :data:`~tinyjukebox.messages.RFID_POLL`: publish the current state in any case."""
|
|
120
|
+
self._publish()
|
|
121
|
+
|
|
122
|
+
def _process_line(self, line: str) -> None:
|
|
123
|
+
tag = _TAG.fullmatch(line)
|
|
124
|
+
if line in (_READY, _REMOVED) or tag is not None:
|
|
125
|
+
self._said.clear() # rfid-reader works: a message is news again
|
|
126
|
+
if tag is not None:
|
|
127
|
+
self._set_state(tag.group(1))
|
|
128
|
+
elif line == _REMOVED:
|
|
129
|
+
self._set_state(None)
|
|
130
|
+
elif line == _READY:
|
|
131
|
+
self._logger.info("RFID - rfid-reader is ready")
|
|
132
|
+
self._set_state(None)
|
|
133
|
+
elif line and line != _ALIVE:
|
|
134
|
+
# everything else is a message of rfid-reader, of its launcher or of sudo (stderr is read as well). One that
|
|
135
|
+
# comes with every start - e.g. the launcher refuses the program of the slot - is a warning once
|
|
136
|
+
level = logging.DEBUG if line in self._said else logging.WARNING
|
|
137
|
+
if len(self._said) >= MAX_SAID:
|
|
138
|
+
self._said.clear()
|
|
139
|
+
self._said.add(line)
|
|
140
|
+
self._logger.log(level, "RFID - rfid-reader: %s", line)
|
|
141
|
+
|
|
142
|
+
def _reader_runs(self) -> bool:
|
|
143
|
+
with self._lock:
|
|
144
|
+
return self._process is not None and self._process.poll() is None
|
|
145
|
+
|
|
146
|
+
def _driver_state(self) -> dict[str, Any]:
|
|
147
|
+
"""``reader``: rfid-reader runs; ``error``: why it does not (``None`` while it runs)."""
|
|
148
|
+
with self._lock:
|
|
149
|
+
return {
|
|
150
|
+
"pins": dict(self._config["pins"]),
|
|
151
|
+
"reader": self._reader_runs(),
|
|
152
|
+
"uid": self._uid,
|
|
153
|
+
"error": self._error,
|
|
154
|
+
"changed_at": self._changed_at,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# --- rfid-reader ----------------------------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def _read(self, stop: threading.Event) -> str:
|
|
160
|
+
"""Start rfid-reader and process its lines until it ends - returns the reason of the end. rfid-reader has
|
|
161
|
+
ended when this returns, also if processing a line fails."""
|
|
162
|
+
with self._lock:
|
|
163
|
+
if stop.is_set():
|
|
164
|
+
return "stopped"
|
|
165
|
+
try:
|
|
166
|
+
process = subprocess.Popen(
|
|
167
|
+
self._command,
|
|
168
|
+
stdin=subprocess.DEVNULL,
|
|
169
|
+
stdout=subprocess.PIPE,
|
|
170
|
+
stderr=subprocess.STDOUT,
|
|
171
|
+
bufsize=0,
|
|
172
|
+
)
|
|
173
|
+
except OSError as e:
|
|
174
|
+
return f"cannot start {self._command[0]}: {e}"
|
|
175
|
+
self._process = process
|
|
176
|
+
ended = False
|
|
177
|
+
reason = None
|
|
178
|
+
try:
|
|
179
|
+
reason = self._read_lines(process, stop)
|
|
180
|
+
ended = reason is None
|
|
181
|
+
finally:
|
|
182
|
+
returncode = self._end(process, ended)
|
|
183
|
+
with self._lock:
|
|
184
|
+
self._process = None
|
|
185
|
+
return reason or f"rfid-reader has ended (exit code {returncode})"
|
|
186
|
+
|
|
187
|
+
def _read_lines(self, process: subprocess.Popen[bytes], stop: threading.Event) -> str | None:
|
|
188
|
+
"""Process the lines of rfid-reader until its output ends (``None``), the driver stops (``"stopped"``) or the
|
|
189
|
+
program is stuck (the reason). The pipe is read in pieces with a timeout, so the thread never blocks: a reader
|
|
190
|
+
that outlives its ``sudo`` keeps the pipe open."""
|
|
191
|
+
assert process.stdout is not None # noqa: S101 - stdout=PIPE
|
|
192
|
+
fd = process.stdout.fileno()
|
|
193
|
+
rest = b""
|
|
194
|
+
heard: float | None = None # when rfid-reader wrote last - from its first "alive" on: the watchdog is armed
|
|
195
|
+
timeout = ALIVE_TIMEOUT * self.alive_interval
|
|
196
|
+
with selectors.DefaultSelector() as selector:
|
|
197
|
+
selector.register(fd, selectors.EVENT_READ)
|
|
198
|
+
while not stop.is_set():
|
|
199
|
+
if not selector.select(READ_TIMEOUT):
|
|
200
|
+
if heard is not None and time.monotonic() - heard > timeout:
|
|
201
|
+
return f"rfid-reader is stuck: no output for {timeout:g} s"
|
|
202
|
+
continue
|
|
203
|
+
data = os.read(fd, 4096)
|
|
204
|
+
if not data:
|
|
205
|
+
if rest:
|
|
206
|
+
self._process_line(rest.decode(errors="replace").strip())
|
|
207
|
+
return None
|
|
208
|
+
*lines, rest = (rest + data).split(b"\n")
|
|
209
|
+
for raw in lines:
|
|
210
|
+
line = raw.decode(errors="replace").strip()
|
|
211
|
+
if heard is not None or line == _ALIVE:
|
|
212
|
+
heard = time.monotonic()
|
|
213
|
+
self._process_line(line)
|
|
214
|
+
return "stopped"
|
|
215
|
+
|
|
216
|
+
def _end(self, process: subprocess.Popen[bytes], ended: bool) -> int:
|
|
217
|
+
"""Close the pipe and end rfid-reader - SIGTERM unless its output has ``ended`` already, SIGKILL if it has not
|
|
218
|
+
ended after :attr:`stop_timeout` - and return its exit code.
|
|
219
|
+
|
|
220
|
+
``sudo`` passes SIGTERM on to rfid-reader, SIGKILL reaches only ``sudo``: a reader that is left behind then ends
|
|
221
|
+
by itself once nobody reads its output - the pipe is therefore closed first."""
|
|
222
|
+
assert process.stdout is not None # noqa: S101 - stdout=PIPE
|
|
223
|
+
process.stdout.close()
|
|
224
|
+
steps = [process.terminate, process.kill]
|
|
225
|
+
if ended:
|
|
226
|
+
steps.insert(0, lambda: None) # its output has ended: it is ending by itself - give it the time first
|
|
227
|
+
for step in steps:
|
|
228
|
+
step()
|
|
229
|
+
try:
|
|
230
|
+
return process.wait(self.stop_timeout)
|
|
231
|
+
except subprocess.TimeoutExpired:
|
|
232
|
+
pass
|
|
233
|
+
return process.wait()
|
|
234
|
+
|
|
235
|
+
def _run(self, stop: threading.Event, previous: threading.Thread | None) -> None:
|
|
236
|
+
if previous is not None:
|
|
237
|
+
previous.join() # the thread of the last start ends its rfid-reader first - two would disturb each other
|
|
238
|
+
failure = None
|
|
239
|
+
while not stop.is_set():
|
|
240
|
+
reason = self._read(stop)
|
|
241
|
+
if stop.is_set():
|
|
242
|
+
break
|
|
243
|
+
with self._lock:
|
|
244
|
+
worked = self._error is None # rfid-reader has reported in between: the same reason is news again
|
|
245
|
+
level = logging.DEBUG if reason == failure and not worked else logging.ERROR # a repeated reason only once
|
|
246
|
+
failure = reason
|
|
247
|
+
self._logger.log(
|
|
248
|
+
level,
|
|
249
|
+
"RFID - %s (%s) - trying again every %s s",
|
|
250
|
+
reason,
|
|
251
|
+
" ".join(self._command),
|
|
252
|
+
self.restart_interval,
|
|
253
|
+
)
|
|
254
|
+
self._set_state(None, reason)
|
|
255
|
+
stop.wait(self.restart_interval)
|
|
256
|
+
|
|
257
|
+
# --- life cycle -----------------------------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def healthy(self) -> bool:
|
|
260
|
+
"""The driver runs, and so does rfid-reader, the program that reads the tags."""
|
|
261
|
+
return bool(self.is_started) and self._reader_runs()
|
|
262
|
+
|
|
263
|
+
def _driver_start(self) -> None:
|
|
264
|
+
previous = self._thread if self._thread is not None and self._thread.is_alive() else None
|
|
265
|
+
self._stop_reader = threading.Event()
|
|
266
|
+
self._published = False
|
|
267
|
+
self._thread = LoggerThread(
|
|
268
|
+
target=self._run, args=(self._stop_reader, previous), name="rfid", logger=self._logger, daemon=True
|
|
269
|
+
)
|
|
270
|
+
self._thread.start()
|
|
271
|
+
self._pubsub_client.subscribe(RFID_POLL, self._poll_handler)
|
|
272
|
+
|
|
273
|
+
def _driver_stop(self) -> None:
|
|
274
|
+
# (ignore_not_found: also after a start that failed)
|
|
275
|
+
self._pubsub_client.unsubscribe(RFID_POLL, self._poll_handler, ignore_not_found=True)
|
|
276
|
+
self._stop_reader.set()
|
|
277
|
+
thread = self._thread
|
|
278
|
+
if thread is not None:
|
|
279
|
+
# the thread ends rfid-reader (SIGTERM, SIGKILL after stop_timeout) and closes its end of the pipe. If it
|
|
280
|
+
# takes longer, it is kept: the next start waits for it before it starts another rfid-reader
|
|
281
|
+
thread.join(2 * self.stop_timeout + 2 * READ_TIMEOUT)
|
|
282
|
+
if not thread.is_alive():
|
|
283
|
+
self._thread = None
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def _get_schema(cls) -> dict[str, Any]:
|
|
287
|
+
return get_schema()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _check_pins(pin_rst: int, pin_cs: int) -> None:
|
|
291
|
+
"""The pins of rfid-reader: two different ones, none of the data and clock lines of SPI0 (the schema checks
|
|
292
|
+
those too). :raises ValueError: otherwise."""
|
|
293
|
+
if pin_rst == pin_cs:
|
|
294
|
+
raise ValueError(f"rfid: pin_rst and pin_cs must be two different pins (both are {pin_rst}).")
|
|
295
|
+
for name, pin in (("pin_rst", pin_rst), ("pin_cs", pin_cs)):
|
|
296
|
+
if pin in SPI_PINS:
|
|
297
|
+
raise ValueError(f"rfid: {name} must not be {pin} - GPIO 9, 10, 11 are MISO, MOSI and SCLK of SPI0.")
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""RGB LED with three GPIO outputs: static colors and blinking between two colors."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from RPi import GPIO
|
|
9
|
+
|
|
10
|
+
from tinyjukebox.core.pubsub import PubSubClient
|
|
11
|
+
from tinyjukebox.core.threads import LoggerThread
|
|
12
|
+
from tinyjukebox.core.ui.tools import parse
|
|
13
|
+
from tinyjukebox.devices.baseclasses.adriver import ADriver
|
|
14
|
+
from tinyjukebox.devices.baseclasses.gpiolevel import closed_level
|
|
15
|
+
from tinyjukebox.devices.schema.rgbled import COLOR_NAMES, get_schema
|
|
16
|
+
from tinyjukebox.messages import LED, RGB, Color, LedPattern
|
|
17
|
+
|
|
18
|
+
__all__ = ["RGBLed"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RGBLed(ADriver):
|
|
22
|
+
"""RGB LED on three GPIO outputs, shows the :class:`~tinyjukebox.messages.LedPattern` of the messages on
|
|
23
|
+
:data:`~tinyjukebox.messages.LED`: one color or alternating colors.
|
|
24
|
+
|
|
25
|
+
Blinking starts with ``color_a`` and runs in a separate thread until the next command.
|
|
26
|
+
|
|
27
|
+
Example::
|
|
28
|
+
|
|
29
|
+
driver:
|
|
30
|
+
type: rgbled
|
|
31
|
+
pin-red: 21
|
|
32
|
+
pin-green: 22
|
|
33
|
+
pin-blue: 23
|
|
34
|
+
initial-color: GREEN
|
|
35
|
+
physical-closed-red: high # level that switches the color on (low/high)
|
|
36
|
+
physical-closed-green: high
|
|
37
|
+
physical-closed-blue: high
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
config: Mapping[str, Any],
|
|
43
|
+
pubsub_client: PubSubClient,
|
|
44
|
+
logger: logging.Logger,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(config, pubsub_client, logger)
|
|
47
|
+
self._pins = tuple(int(self._config[f"pin-{color}"]) for color in ("red", "green", "blue"))
|
|
48
|
+
self._closed_levels = tuple(
|
|
49
|
+
closed_level(self._config[f"physical-closed-{color}"], f"physical-closed-{color}")
|
|
50
|
+
for color in ("red", "green", "blue")
|
|
51
|
+
)
|
|
52
|
+
self._initial_color = Color[self._config["initial-color"]]
|
|
53
|
+
|
|
54
|
+
self._pattern_lock = threading.Lock()
|
|
55
|
+
self._shows = False # patterns only between _driver_start and _driver_stop (guarded by _pattern_lock)
|
|
56
|
+
self._blink_stop = threading.Event()
|
|
57
|
+
self._blink_thread: LoggerThread | None = None
|
|
58
|
+
self._pattern = LedPattern.static(self._initial_color)
|
|
59
|
+
|
|
60
|
+
self._add_ui_command("gpio_color", self._cmd_gpio_color)
|
|
61
|
+
self._add_ui_command("gpio_rgb", self._cmd_gpio_rgb)
|
|
62
|
+
self._add_ui_command("gpio_blink", self._cmd_gpio_blink)
|
|
63
|
+
self._add_ui_command("gpio_state", self._cmd_gpio_state)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def pattern(self) -> LedPattern:
|
|
67
|
+
"""The pattern that is currently shown."""
|
|
68
|
+
return self._pattern
|
|
69
|
+
|
|
70
|
+
def _driver_state(self) -> dict[str, Any]:
|
|
71
|
+
"""``colors``/``delays``: the pattern that is shown (a name of :class:`~tinyjukebox.messages.Color` each);
|
|
72
|
+
``lit``: which of the three lights is on right now, read from the pins (``None``: they cannot be read)."""
|
|
73
|
+
pattern = self._pattern
|
|
74
|
+
names = ("red", "green", "blue")
|
|
75
|
+
lit: dict[str, bool] | None = None
|
|
76
|
+
if self.is_started:
|
|
77
|
+
try:
|
|
78
|
+
lit = {
|
|
79
|
+
name: GPIO.input(pin) == closed
|
|
80
|
+
for name, pin, closed in zip(names, self._pins, self._closed_levels, strict=True)
|
|
81
|
+
}
|
|
82
|
+
except Exception as e: # the view must not fail with the pins
|
|
83
|
+
self._logger.debug("RGBLed._driver_state - gpios cannot be read: %s", e)
|
|
84
|
+
return {
|
|
85
|
+
"pins": dict(zip(names, self._pins, strict=True)),
|
|
86
|
+
"colors": [Color(tuple(rgb)).name for rgb in pattern.colors],
|
|
87
|
+
"delays": list(pattern.delays),
|
|
88
|
+
"lit": lit,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# --- output -------------------------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _set_pins(self, rgb: RGB) -> None:
|
|
94
|
+
for pin, closed, on in zip(self._pins, self._closed_levels, rgb, strict=True):
|
|
95
|
+
GPIO.output(pin, closed if on else 1 - closed)
|
|
96
|
+
|
|
97
|
+
def _blink(self, pattern: LedPattern, stop: threading.Event) -> None:
|
|
98
|
+
index = 0
|
|
99
|
+
while True:
|
|
100
|
+
self._set_pins(pattern.colors[index])
|
|
101
|
+
if stop.wait(pattern.delays[index]):
|
|
102
|
+
return
|
|
103
|
+
index = (index + 1) % len(pattern.colors)
|
|
104
|
+
|
|
105
|
+
def _stop_blinking(self) -> None:
|
|
106
|
+
if self._blink_thread is not None:
|
|
107
|
+
self._blink_stop.set()
|
|
108
|
+
self._blink_thread.join()
|
|
109
|
+
self._blink_thread = None
|
|
110
|
+
|
|
111
|
+
def show(self, pattern: LedPattern) -> None:
|
|
112
|
+
"""Show ``pattern`` - replaces the current pattern and returns immediately."""
|
|
113
|
+
self._logger.info("RGBLed.show - %s", pattern)
|
|
114
|
+
with self._pattern_lock:
|
|
115
|
+
if not self._shows: # e.g. a message that is dispatched while the driver stops: its pins are released
|
|
116
|
+
self._logger.info("RGBLed.show - the driver is stopped")
|
|
117
|
+
return
|
|
118
|
+
self._stop_blinking()
|
|
119
|
+
self._pattern = pattern
|
|
120
|
+
if len(pattern.colors) == 1:
|
|
121
|
+
self._set_pins(pattern.colors[0])
|
|
122
|
+
else:
|
|
123
|
+
self._blink_stop = threading.Event()
|
|
124
|
+
self._blink_thread = LoggerThread(
|
|
125
|
+
target=self._blink,
|
|
126
|
+
args=(pattern, self._blink_stop),
|
|
127
|
+
name=f"rgbled.{self._name}.blink",
|
|
128
|
+
logger=self._logger,
|
|
129
|
+
daemon=True,
|
|
130
|
+
)
|
|
131
|
+
self._blink_thread.start()
|
|
132
|
+
|
|
133
|
+
def _message_handler(self, message: object) -> None:
|
|
134
|
+
"""Handler for :data:`~tinyjukebox.messages.LED`."""
|
|
135
|
+
self._logger.info("received message %r on topic '%s'.", message, LED)
|
|
136
|
+
if isinstance(message, LedPattern):
|
|
137
|
+
self.show(message)
|
|
138
|
+
else:
|
|
139
|
+
self._logger.warning("RGBLed._message_handler - expected a LedPattern, received %r.", message)
|
|
140
|
+
|
|
141
|
+
# --- console commands ---------------------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _parse_color(name: str) -> Color | None:
|
|
145
|
+
return Color.__members__.get(name.upper())
|
|
146
|
+
|
|
147
|
+
def _cmd_gpio_color(self, args: str) -> None:
|
|
148
|
+
"""gpio_color - sets a color (BLACK, WHITE, RED, GREEN, BLUE, YELLOW, AQUA, MAGENTA): GPIO_COLOR color"""
|
|
149
|
+
arguments = parse(args)
|
|
150
|
+
color = self._parse_color(arguments[0]) if len(arguments) == 1 else None
|
|
151
|
+
if color is None:
|
|
152
|
+
print(f"Wrong arguments: {arguments}. expected 'GPIO_COLOR [{'|'.join(COLOR_NAMES)}]'.")
|
|
153
|
+
return
|
|
154
|
+
print(f"Setting color to: {color.name}.")
|
|
155
|
+
self.show(LedPattern.static(color))
|
|
156
|
+
|
|
157
|
+
def _cmd_gpio_rgb(self, args: str) -> None:
|
|
158
|
+
"""gpio_rgb - sets the rgb-led to the boolean values: GPIO_RGB Red Green Blue"""
|
|
159
|
+
arguments = [argument.lower() for argument in parse(args)]
|
|
160
|
+
if len(arguments) != 3 or any(argument not in ("true", "false") for argument in arguments):
|
|
161
|
+
print(f"Wrong arguments: {arguments}. expected 'GPIO_RGB Red Green Blue' with values True/False.")
|
|
162
|
+
return
|
|
163
|
+
red, green, blue = (argument == "true" for argument in arguments)
|
|
164
|
+
print(f"Setting rgb to: {red} {green} {blue}.")
|
|
165
|
+
self.show(LedPattern.static((red, green, blue)))
|
|
166
|
+
|
|
167
|
+
def _cmd_gpio_blink(self, args: str) -> None:
|
|
168
|
+
"""gpio_blink - alternates between two colors: GPIO_BLINK color_a color_b delay [delay_b]"""
|
|
169
|
+
arguments = parse(args)
|
|
170
|
+
expected = f"GPIO_BLINK [{'|'.join(COLOR_NAMES)}] [{'|'.join(COLOR_NAMES)}] delay [delay_b]"
|
|
171
|
+
if len(arguments) not in (3, 4):
|
|
172
|
+
print(f"Wrong arguments: {arguments}. expected '{expected}'.")
|
|
173
|
+
return
|
|
174
|
+
color_a, color_b = self._parse_color(arguments[0]), self._parse_color(arguments[1])
|
|
175
|
+
try:
|
|
176
|
+
delays = [float(argument) for argument in arguments[2:]]
|
|
177
|
+
except ValueError:
|
|
178
|
+
delays = []
|
|
179
|
+
if color_a is None or color_b is None or not delays or min(delays) <= 0:
|
|
180
|
+
print(f"Wrong arguments: {arguments}. expected '{expected}' with delays > 0.")
|
|
181
|
+
return
|
|
182
|
+
pattern = LedPattern.blink(color_a, color_b, delays[0], delays[-1])
|
|
183
|
+
print(f"Setting blink to {pattern}.")
|
|
184
|
+
self.show(pattern)
|
|
185
|
+
|
|
186
|
+
def _cmd_gpio_state(self, args: str) -> None:
|
|
187
|
+
"""gpio_state - reads the state of the gpios: GPIO_STATE"""
|
|
188
|
+
states = [
|
|
189
|
+
f"{name} {pin} is {'on' if GPIO.input(pin) == closed else 'off'}"
|
|
190
|
+
for name, pin, closed in zip(("red", "green", "blue"), self._pins, self._closed_levels, strict=True)
|
|
191
|
+
]
|
|
192
|
+
print(f"[{self._name}] gpios: {', '.join(states)}.")
|
|
193
|
+
|
|
194
|
+
# --- life cycle ---------------------------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def _driver_start(self) -> None:
|
|
197
|
+
GPIO.setmode(GPIO.BCM)
|
|
198
|
+
for pin in self._pins:
|
|
199
|
+
GPIO.setup(pin, GPIO.OUT)
|
|
200
|
+
with self._pattern_lock:
|
|
201
|
+
self._shows = True
|
|
202
|
+
self.show(LedPattern.static(self._initial_color))
|
|
203
|
+
self._pubsub_client.subscribe(LED, self._message_handler)
|
|
204
|
+
|
|
205
|
+
def _driver_stop(self) -> None:
|
|
206
|
+
self._pubsub_client.unsubscribe(LED, self._message_handler)
|
|
207
|
+
with self._pattern_lock:
|
|
208
|
+
self._shows = False
|
|
209
|
+
self._stop_blinking()
|
|
210
|
+
for pin in self._pins:
|
|
211
|
+
GPIO.cleanup(pin)
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def _get_schema(cls) -> dict[str, Any]:
|
|
215
|
+
return get_schema()
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""JSON schemas of the driver config sections."""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Schema builder for the config section ``driver`` - used by the schema modules of all drivers."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
__all__ = ["add_schema_part", "get_schema"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_schema(driver_type: str, driver_specific_properties: Mapping[str, Any]) -> dict[str, Any]:
|
|
10
|
+
"""Schema ``{"driver": {...}}`` of a driver config section.
|
|
11
|
+
|
|
12
|
+
All driver specific properties are required. Unknown keys are rejected. Topics and payloads are no config
|
|
13
|
+
entries - they are defined in :mod:`tinyjukebox.messages`.
|
|
14
|
+
"""
|
|
15
|
+
driver: dict[str, Any] = {
|
|
16
|
+
"description": f"Driver configuration '{driver_type}'.",
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"name": {"description": "name of driver", "type": "string"},
|
|
20
|
+
"type": {"description": "type of driver", "type": "string", "enum": [driver_type]},
|
|
21
|
+
},
|
|
22
|
+
"required": ["type"],
|
|
23
|
+
"additionalProperties": False,
|
|
24
|
+
}
|
|
25
|
+
schema = {"driver": driver}
|
|
26
|
+
add_schema_part(schema, {"properties": driver_specific_properties, "required": list(driver_specific_properties)})
|
|
27
|
+
return schema
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def add_schema_part(schema: dict[str, Any], schema_part: Mapping[str, Any]) -> None:
|
|
31
|
+
"""Add the ``properties`` and ``required`` entries of ``schema_part`` to ``schema["driver"]``."""
|
|
32
|
+
schema["driver"]["properties"].update(schema_part["properties"])
|
|
33
|
+
schema["driver"]["required"].extend(schema_part["required"])
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Schema of the driver ``bluetooth``."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tinyjukebox.devices.schema import adriver
|
|
6
|
+
|
|
7
|
+
__all__ = ["AVRCP_MODES", "get_schema"]
|
|
8
|
+
|
|
9
|
+
AVRCP_MODES = ["player", "input"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_schema() -> dict[str, Any]:
|
|
13
|
+
schema = adriver.get_schema("bluetooth", {})
|
|
14
|
+
schema["driver"]["properties"].update(
|
|
15
|
+
{
|
|
16
|
+
"adapter": {"description": "bluetooth adapter (optional, default: hci0)", "type": "string"},
|
|
17
|
+
"avrcp": {
|
|
18
|
+
"description": "how the buttons of the headset arrive (optional, default: player) - player: BlueZ "
|
|
19
|
+
"calls the media player the box registers; input: BlueZ passes them to the linux input device "
|
|
20
|
+
"'<headset> (AVRCP)' (the user needs the group input)",
|
|
21
|
+
"type": "string",
|
|
22
|
+
"enum": AVRCP_MODES,
|
|
23
|
+
},
|
|
24
|
+
"play-pause-toggle": {
|
|
25
|
+
"description": "play and pause of the headset both switch between play and pause (optional, default: "
|
|
26
|
+
"false) - for headsets that send the same button for both",
|
|
27
|
+
"type": "boolean",
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
return schema
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Schema of the config section ``drivers`` (DriverManager)."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from tinyjukebox.devices.drivers import DRIVERS
|
|
7
|
+
|
|
8
|
+
__all__ = ["get_driver_schema", "get_schema"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_driver_schema(driver_type: str) -> dict[str, Any]:
|
|
12
|
+
"""Schema ``{"driver": {...}}`` of a single driver (from module ``tinyjukebox.devices.schema.<driver_type>``)."""
|
|
13
|
+
module = importlib.import_module(f"tinyjukebox.devices.schema.{driver_type}")
|
|
14
|
+
schema: dict[str, Any] = module.get_schema()
|
|
15
|
+
return schema
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_schema() -> dict[str, Any]:
|
|
19
|
+
"""Schema of the list ``drivers``: each entry is a driver section plus ``name`` (required) and ``active``.
|
|
20
|
+
|
|
21
|
+
The entries are validated with ``if type == ... then ...`` instead of ``oneOf``; errors thus name the actual
|
|
22
|
+
wrong key instead of "not valid under any of the given schemas".
|
|
23
|
+
"""
|
|
24
|
+
conditions = []
|
|
25
|
+
for driver_type in DRIVERS:
|
|
26
|
+
driver = get_driver_schema(driver_type)["driver"]
|
|
27
|
+
driver["properties"]["active"] = {"description": "if set to false, the driver is not loaded", "type": "boolean"}
|
|
28
|
+
driver["required"].extend(["name", "active"])
|
|
29
|
+
conditions.append({"if": {"properties": {"type": {"const": driver_type}}}, "then": driver})
|
|
30
|
+
return {
|
|
31
|
+
"drivers": {
|
|
32
|
+
"description": "Drivermanager configuration.",
|
|
33
|
+
"type": "array",
|
|
34
|
+
"items": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {"type": {"description": "type of driver", "enum": list(DRIVERS)}},
|
|
37
|
+
"required": ["type"],
|
|
38
|
+
"allOf": conditions,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Schema of the driver ``input``."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tinyjukebox.devices.schema import adriver
|
|
6
|
+
from tinyjukebox.messages import Button
|
|
7
|
+
|
|
8
|
+
__all__ = ["get_schema"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_schema() -> dict[str, Any]:
|
|
12
|
+
driver_specific_properties = {
|
|
13
|
+
"pin": {"description": "gpio pin", "type": "integer"},
|
|
14
|
+
"hold_timeout": {"description": "hold time in seconds until the hold event is triggered", "type": "number"},
|
|
15
|
+
"button": {"description": "which button of the box", "type": "string", "enum": [b.value for b in Button]},
|
|
16
|
+
}
|
|
17
|
+
schema = adriver.get_schema("input", driver_specific_properties)
|
|
18
|
+
schema["driver"]["properties"]["hold_repeat"] = {
|
|
19
|
+
"description": "repeat the hold event every hold_timeout seconds while the button stays pressed (optional, "
|
|
20
|
+
"default: false)",
|
|
21
|
+
"type": "boolean",
|
|
22
|
+
}
|
|
23
|
+
return schema
|