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,256 @@
|
|
|
1
|
+
"""All targets (``targets.json``): albums, radio stations and entries of Spotify."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from tinyjukebox.core.logging import get_child
|
|
11
|
+
from tinyjukebox.data.common import Listener, Mirror, TargetType, UpdateJson, is_unmapped
|
|
12
|
+
from tinyjukebox.data.jsondata import JsonData
|
|
13
|
+
from tinyjukebox.data.target import Target, TargetAlbum, TargetRadio, TargetSpotify
|
|
14
|
+
from tinyjukebox.schema.targets import schema
|
|
15
|
+
|
|
16
|
+
__all__ = ["LAST_ID_SUFFIX", "Targets", "create_target_factory", "load_target_factory"]
|
|
17
|
+
|
|
18
|
+
LAST_ID_SUFFIX = "-last-id.json"
|
|
19
|
+
"""``targets.json`` -> ``targets-last-id.json``: the highest album id given so far."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_number(target_id: str | None) -> bool:
|
|
23
|
+
return target_id is not None and target_id.isascii() and target_id.isdigit()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check_target_dict(target: dict[str, Any] | None, logger: logging.Logger) -> dict[str, Any]:
|
|
27
|
+
if target is None:
|
|
28
|
+
logger.error("target not provided")
|
|
29
|
+
raise ValueError("target not provided")
|
|
30
|
+
if "target-id" not in target:
|
|
31
|
+
logger.error("provided target has no attribute target-id")
|
|
32
|
+
raise ValueError("provided target has no attribute target-id")
|
|
33
|
+
if is_unmapped(target["target-id"]):
|
|
34
|
+
logger.error("target-id must be set.")
|
|
35
|
+
raise ValueError("target-id must be set.")
|
|
36
|
+
if "target-type" not in target:
|
|
37
|
+
raise ValueError("provided target has no attribute target-type")
|
|
38
|
+
if target["target-type"] not in (TargetType.album.name, TargetType.radio.name, TargetType.spotify.name):
|
|
39
|
+
logger.error("unknown target type: %s.", target["target-type"])
|
|
40
|
+
raise ValueError(f"unknown target type: {target['target-type']}.")
|
|
41
|
+
return target
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_target_factory(
|
|
45
|
+
target: dict[str, Any] | None, root_folder: str, update_json: UpdateJson | None, logger: logging.Logger
|
|
46
|
+
) -> Target:
|
|
47
|
+
"""A new target - checked for sanity (e.g. files exist, valid url). :raises ValueError: otherwise."""
|
|
48
|
+
factory_logger = get_child(logger, "create_target_factory")
|
|
49
|
+
target = _check_target_dict(target, factory_logger)
|
|
50
|
+
if target["target-type"] == TargetType.radio.name:
|
|
51
|
+
return TargetRadio.create_target(target, logger, update_json)
|
|
52
|
+
if target["target-type"] == TargetType.spotify.name:
|
|
53
|
+
return TargetSpotify.create_target(target, logger, update_json)
|
|
54
|
+
return TargetAlbum.create_target(target, root_folder, logger, update_json)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_target_factory(
|
|
58
|
+
target: dict[str, Any] | None, root_folder: str, update_json: UpdateJson, logger: logging.Logger
|
|
59
|
+
) -> Target:
|
|
60
|
+
"""A target from the json file - not checked for sanity (a missing album must not prevent the start)."""
|
|
61
|
+
factory_logger = get_child(logger, "load_target_factory")
|
|
62
|
+
target = _check_target_dict(target, factory_logger)
|
|
63
|
+
if target["target-type"] == TargetType.radio.name:
|
|
64
|
+
return TargetRadio(target, logger, update_json)
|
|
65
|
+
if target["target-type"] == TargetType.spotify.name:
|
|
66
|
+
return TargetSpotify(target, logger, update_json)
|
|
67
|
+
return TargetAlbum(target, root_folder, logger, update_json)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Targets:
|
|
71
|
+
"""Targets by id. Changes are written to the json file immediately (see :class:`~tinyjukebox.data.tags.Tags`
|
|
72
|
+
for the locking rules).
|
|
73
|
+
|
|
74
|
+
Albums, radio stations and entries of Spotify share the ids. New ids count up and are never given twice - also
|
|
75
|
+
not after the target with the highest id has been deleted: the new target would inherit everything that still
|
|
76
|
+
refers to the old id (e.g. the cover cached by the browser). The highest id given so far is kept in a file of its
|
|
77
|
+
own (:data:`LAST_ID_SUFFIX`) - older versions would reject an additional entry in the targets json. Without that
|
|
78
|
+
file, new ids follow the highest existing id.
|
|
79
|
+
|
|
80
|
+
With ``id_source`` the ids come from there: the library assigns them for all boxes, and a box that works with a
|
|
81
|
+
library creates a target under a provisional id (:mod:`tinyjukebox.sync`).
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, filename: str, root_folder: str, logger: logging.Logger) -> None:
|
|
85
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
86
|
+
self._lock = threading.RLock()
|
|
87
|
+
self._json = JsonData(filename, filename + "_", schema, self._logger, entries=True)
|
|
88
|
+
self._last_id_file = os.path.splitext(filename)[0] + LAST_ID_SUFFIX
|
|
89
|
+
self.root_folder = root_folder
|
|
90
|
+
self.id_source: Callable[[], str] | None = None
|
|
91
|
+
self._mirror = Mirror()
|
|
92
|
+
self.targets: dict[str, Target] = {}
|
|
93
|
+
for key, target in list(self._json.data.items()):
|
|
94
|
+
try:
|
|
95
|
+
self.targets[key] = load_target_factory(target, root_folder, self._update_json, self._logger)
|
|
96
|
+
except ValueError as e: # a target this version cannot use does not keep the box from starting
|
|
97
|
+
self._json.set_aside(key, target, str(e))
|
|
98
|
+
|
|
99
|
+
def _to_dict(self) -> dict[str, Any]:
|
|
100
|
+
return {key: target.to_dict() for key, target in self.targets.items()}
|
|
101
|
+
|
|
102
|
+
def _update_json(self) -> None:
|
|
103
|
+
with self._lock:
|
|
104
|
+
data = self._to_dict()
|
|
105
|
+
self._json.store(data)
|
|
106
|
+
self._mirror.changed(data)
|
|
107
|
+
|
|
108
|
+
def listen(self, listener: Listener) -> None:
|
|
109
|
+
"""``listener(target_id, entry)`` is called for every target that changes from now on (``None``: deleted)."""
|
|
110
|
+
with self._lock:
|
|
111
|
+
self._mirror.listen(listener, self._to_dict())
|
|
112
|
+
|
|
113
|
+
def sync(self, get: Callable[[], dict[str, Any]], renamed: dict[str, str] | None = None) -> bool:
|
|
114
|
+
"""Make the targets what ``get()`` returns (the content of ``targets.json``), keeping the targets that stay
|
|
115
|
+
- also one whose id changed from a key of ``renamed`` to its value. The listener is not called. Returns
|
|
116
|
+
whether something changed."""
|
|
117
|
+
with self._lock:
|
|
118
|
+
data, aside = self._json.split(get())
|
|
119
|
+
if data == self._to_dict() and aside == self._json.aside:
|
|
120
|
+
return False
|
|
121
|
+
for old, new in (renamed or {}).items():
|
|
122
|
+
if old in self.targets and new in data and new not in self.targets:
|
|
123
|
+
self.targets[new] = self.targets.pop(old)
|
|
124
|
+
for key in [key for key in self.targets if key not in data]:
|
|
125
|
+
del self.targets[key]
|
|
126
|
+
for key, entry in data.items():
|
|
127
|
+
target = self.targets.get(key)
|
|
128
|
+
try:
|
|
129
|
+
if (
|
|
130
|
+
target is not None
|
|
131
|
+
and target.target_type is not None
|
|
132
|
+
and target.target_type.name == entry.get("target-type")
|
|
133
|
+
):
|
|
134
|
+
target.load(entry)
|
|
135
|
+
else:
|
|
136
|
+
self.targets[key] = load_target_factory(
|
|
137
|
+
entry, self.root_folder, self._update_json, self._logger
|
|
138
|
+
)
|
|
139
|
+
except ValueError as e:
|
|
140
|
+
self._logger.warning("sync - target '%s' set aside: %s", key, e)
|
|
141
|
+
self.targets.pop(key, None)
|
|
142
|
+
aside[key] = entry
|
|
143
|
+
self._remember_id(key)
|
|
144
|
+
data = self._to_dict()
|
|
145
|
+
self._json.store(data, aside)
|
|
146
|
+
self._mirror.replaced(data)
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
def all(self) -> list[Target]:
|
|
150
|
+
"""Snapshot of all targets."""
|
|
151
|
+
with self._lock:
|
|
152
|
+
return list(self.targets.values())
|
|
153
|
+
|
|
154
|
+
def albums(self) -> list[TargetAlbum]:
|
|
155
|
+
return [target for target in self.all() if isinstance(target, TargetAlbum)]
|
|
156
|
+
|
|
157
|
+
def radios(self) -> list[TargetRadio]:
|
|
158
|
+
return [target for target in self.all() if isinstance(target, TargetRadio)]
|
|
159
|
+
|
|
160
|
+
def spotify_entries(self) -> list[TargetSpotify]:
|
|
161
|
+
return [target for target in self.all() if isinstance(target, TargetSpotify)]
|
|
162
|
+
|
|
163
|
+
def get_target(self, target_id: str) -> Target:
|
|
164
|
+
""":raises ValueError: for unknown ids."""
|
|
165
|
+
target = self.targets.get(target_id)
|
|
166
|
+
if target is None:
|
|
167
|
+
self._logger.warning("get_target - unknown target_id %s.", target_id)
|
|
168
|
+
raise ValueError(f"get_target - unknown target_id {target_id}.")
|
|
169
|
+
return target
|
|
170
|
+
|
|
171
|
+
def get_targets_of_type(self, target_type: TargetType) -> list[Target]:
|
|
172
|
+
return [target for target in self.all() if target.target_type == target_type]
|
|
173
|
+
|
|
174
|
+
def add_target(self, target: dict[str, Any] | None, create_id: bool = True) -> Target:
|
|
175
|
+
"""Add a target (``create_id``: with a new id). :raises ValueError: if it is invalid or the id exists."""
|
|
176
|
+
if target is None:
|
|
177
|
+
self._logger.warning("add_target - target is None.")
|
|
178
|
+
raise ValueError("add_target - target is None.")
|
|
179
|
+
with self._lock:
|
|
180
|
+
if create_id:
|
|
181
|
+
target["target-id"] = self.id_source() if self.id_source else str(self._highest_id() + 1)
|
|
182
|
+
self._logger.info("add_target - target: %s.", target)
|
|
183
|
+
if str(target.get("target-id")) in self.targets or str(target.get("target-id")) in self._json.aside:
|
|
184
|
+
self._logger.warning("add_target - target_id %s exists already.", target["target-id"])
|
|
185
|
+
raise ValueError(f"add_target - target_id {target['target-id']} exists already.")
|
|
186
|
+
new_target = create_target_factory(target, self.root_folder, self._update_json, self._logger)
|
|
187
|
+
target_id = new_target.target_id
|
|
188
|
+
assert target_id is not None # noqa: S101 - set by the constructor
|
|
189
|
+
self._remember_id(target_id)
|
|
190
|
+
self.targets[target_id] = new_target
|
|
191
|
+
self._update_json()
|
|
192
|
+
return new_target
|
|
193
|
+
|
|
194
|
+
def delete_target(self, target_id: str | None) -> None:
|
|
195
|
+
if target_id is None:
|
|
196
|
+
self._logger.warning("delete_target - target_id is None.")
|
|
197
|
+
raise ValueError("delete_target - target_id is None.")
|
|
198
|
+
with self._lock:
|
|
199
|
+
if target_id not in self.targets:
|
|
200
|
+
self._logger.warning("delete_target - unknown target_id %s.", target_id)
|
|
201
|
+
raise ValueError(f"delete_target - unknown target_id {target_id}.")
|
|
202
|
+
self._remember_id(target_id) # e.g. the file does not exist yet - the id would be given again
|
|
203
|
+
del self.targets[target_id]
|
|
204
|
+
self._update_json()
|
|
205
|
+
|
|
206
|
+
def sanity_check(self) -> tuple[bool, str]:
|
|
207
|
+
errors = []
|
|
208
|
+
for key, target in list(self.targets.items()):
|
|
209
|
+
if key != target.target_id:
|
|
210
|
+
message = f"sanity_check - key '{key}' != target.target_id '{target.target_id}'"
|
|
211
|
+
self._logger.warning(message)
|
|
212
|
+
errors.append(message)
|
|
213
|
+
passed, error = target.sanity_check()
|
|
214
|
+
if not passed:
|
|
215
|
+
errors.append(error)
|
|
216
|
+
return not errors, "\n".join(errors)
|
|
217
|
+
|
|
218
|
+
def to_text(self) -> str:
|
|
219
|
+
return "\n".join(f"key: {key}, target: {target.to_text()}" for key, target in list(self.targets.items()))
|
|
220
|
+
|
|
221
|
+
def _highest_id(self) -> int:
|
|
222
|
+
"""The highest id given so far (0: none) - stored or, if higher, of the existing targets."""
|
|
223
|
+
keys = [*self.targets, *self._json.aside] # a target set aside keeps its id, too
|
|
224
|
+
return max([self._read_last_id(), *(int(key) for key in keys if _is_number(key))])
|
|
225
|
+
|
|
226
|
+
def _remember_id(self, target_id: str) -> None:
|
|
227
|
+
"""Raise the stored last id to ``target_id`` - before the targets json is written: a crash leaves a gap,
|
|
228
|
+
never an id that is given twice."""
|
|
229
|
+
if _is_number(target_id) and int(target_id) > self._read_last_id():
|
|
230
|
+
self._write_last_id(int(target_id))
|
|
231
|
+
|
|
232
|
+
def _read_last_id(self) -> int:
|
|
233
|
+
try:
|
|
234
|
+
with open(self._last_id_file, encoding="utf-8") as f:
|
|
235
|
+
last_id = json.load(f)["last-id"]
|
|
236
|
+
except FileNotFoundError:
|
|
237
|
+
return 0
|
|
238
|
+
except (OSError, ValueError, KeyError, TypeError) as e:
|
|
239
|
+
self._logger.warning(
|
|
240
|
+
"cannot read '%s' (%s) - new ids follow the highest existing id.", self._last_id_file, e
|
|
241
|
+
)
|
|
242
|
+
return 0
|
|
243
|
+
if not isinstance(last_id, int) or isinstance(last_id, bool):
|
|
244
|
+
self._logger.warning(
|
|
245
|
+
"'%s': last-id must be a number - new ids follow the highest existing id.", self._last_id_file
|
|
246
|
+
)
|
|
247
|
+
return 0
|
|
248
|
+
return last_id
|
|
249
|
+
|
|
250
|
+
def _write_last_id(self, last_id: int) -> None:
|
|
251
|
+
temp = self._last_id_file + ".tmp"
|
|
252
|
+
with open(temp, "w", encoding="utf-8") as f:
|
|
253
|
+
json.dump({"last-id": last_id}, f)
|
|
254
|
+
f.flush()
|
|
255
|
+
os.fsync(f.fileno())
|
|
256
|
+
os.replace(temp, self._last_id_file)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Start value and maximum of the volumes, changed at runtime (``volume.json`` next to ``tags.json``)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from tinyjukebox.core.logging import get_child
|
|
10
|
+
from tinyjukebox.data.common import Listener
|
|
11
|
+
from tinyjukebox.data.jsondata import JsonData
|
|
12
|
+
from tinyjukebox.schema.volume import schema
|
|
13
|
+
|
|
14
|
+
__all__ = ["VolumeLimits"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VolumeLimits:
|
|
18
|
+
"""``{"speaker": {"default-volume": 50, "max-volume": 80}, "headphones": {...}}`` - a profile that has never been
|
|
19
|
+
changed is missing: the config applies to it. A missing or broken file is not fatal (the config applies)."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, filename: str, logger: logging.Logger) -> None:
|
|
22
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
self._listener: Listener | None = None
|
|
25
|
+
backup = filename + "_"
|
|
26
|
+
if not os.path.exists(filename) and not os.path.exists(backup):
|
|
27
|
+
self._logger.info("__init__ - no volume file yet - the config applies until a limit is changed.")
|
|
28
|
+
self._create(filename)
|
|
29
|
+
try:
|
|
30
|
+
self._json = JsonData(filename, backup, schema, self._logger)
|
|
31
|
+
except FileNotFoundError:
|
|
32
|
+
self._logger.warning("__init__ - no usable volume file - the config applies.")
|
|
33
|
+
self._create(filename)
|
|
34
|
+
self._json = JsonData(filename, backup, schema, self._logger)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _create(filename: str) -> None:
|
|
38
|
+
with open(filename, "w", encoding="utf-8") as outfile:
|
|
39
|
+
outfile.write("{}")
|
|
40
|
+
|
|
41
|
+
def apply(self, profile: str, config: Mapping[str, Any]) -> dict[str, Any]:
|
|
42
|
+
"""``config`` (keys ``default-volume``, ``max-volume``, ...) with the stored limits of ``profile``."""
|
|
43
|
+
with self._lock:
|
|
44
|
+
return {**config, **self._json.data.get(profile, {})}
|
|
45
|
+
|
|
46
|
+
def store(self, profile: str, default_volume: float, max_volume: float) -> None:
|
|
47
|
+
with self._lock:
|
|
48
|
+
data = dict(self._json.data)
|
|
49
|
+
data[profile] = {"default-volume": default_volume, "max-volume": max_volume}
|
|
50
|
+
self._json.store(data)
|
|
51
|
+
if self._listener is not None:
|
|
52
|
+
self._listener(profile, data[profile])
|
|
53
|
+
|
|
54
|
+
def listen(self, listener: Listener) -> None:
|
|
55
|
+
"""``listener(profile, limits)`` is called whenever the limits of a profile are changed."""
|
|
56
|
+
self._listener = listener
|
|
57
|
+
|
|
58
|
+
def all(self) -> dict[str, Any]:
|
|
59
|
+
with self._lock:
|
|
60
|
+
return dict(self._json.data)
|
|
61
|
+
|
|
62
|
+
def sync(self, data: dict[str, Any]) -> bool:
|
|
63
|
+
"""Make the stored limits ``data``; they apply from the next start. The listener is not called."""
|
|
64
|
+
with self._lock:
|
|
65
|
+
if data == self._json.data:
|
|
66
|
+
return False
|
|
67
|
+
self._json.store(dict(data))
|
|
68
|
+
return True
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Starting with one folder (``--data DIR``, docs/developer/configuration.md, section 1): library and box without a
|
|
2
|
+
config file of their own and without a users file made by hand - for the Docker image and the package from PyPI.
|
|
3
|
+
|
|
4
|
+
Everything lies below the folder, in the folders library and box use anyway. A file ``DIR/config.yaml``, if there is
|
|
5
|
+
one, changes single values. What the first start needs comes from environment variables, and counts at the first
|
|
6
|
+
start only; afterwards the folder is the truth, and passwords are changed on the page. A password can also come from
|
|
7
|
+
a file: ``TINYJUKEBOX_ADMIN_PASSWORD_FILE`` names it (a secret of Docker), and the variable itself stays empty.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import getpass
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from tinyjukebox.core.config import read_config
|
|
19
|
+
from tinyjukebox.web.auth import MIN_PASSWORD_LENGTH, UsersFile, hash_password
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ADMIN_VARIABLE",
|
|
23
|
+
"BOOTSTRAP_VARIABLE",
|
|
24
|
+
"LIBRARY_VARIABLE",
|
|
25
|
+
"SLOTS_VARIABLE",
|
|
26
|
+
"box_config",
|
|
27
|
+
"first_admin",
|
|
28
|
+
"library_config",
|
|
29
|
+
"link_library",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
ADMIN_VARIABLE = "TINYJUKEBOX_ADMIN_PASSWORD" # the password of the user admin of the page, at the first start
|
|
33
|
+
BOOTSTRAP_VARIABLE = "TINYJUKEBOX_BOOTSTRAP_PASSWORD" # a library: the one new boxes prove; a box: the one it proves
|
|
34
|
+
LIBRARY_VARIABLE = "TINYJUKEBOX_LIBRARY" # a box: the address of its library, at the first start
|
|
35
|
+
SLOTS_VARIABLE = "TINYJUKEBOX_SLOTS" # a library: the folder with the slot it brings along (the Docker image)
|
|
36
|
+
REPOSITORY = "tgd1975/tinyjukebox" # where a library finds new versions for its boxes
|
|
37
|
+
PLATFORM = "aarch64-debian-trixie" # the platform of the boxes
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _secret(variable: str) -> str:
|
|
41
|
+
"""The value of ``variable`` - or the content of the file that ``<variable>_FILE`` names, without the line end."""
|
|
42
|
+
filename = os.environ.get(variable + "_FILE", "")
|
|
43
|
+
if filename:
|
|
44
|
+
try:
|
|
45
|
+
with open(filename, encoding="utf-8") as infile:
|
|
46
|
+
return infile.read().rstrip("\r\n")
|
|
47
|
+
except OSError as error:
|
|
48
|
+
raise SystemExit(f"{variable}_FILE: {filename} cannot be read ({error.strerror or error})") from None
|
|
49
|
+
return os.environ.get(variable, "")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _merged(base: dict[str, Any], change: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
result = copy.deepcopy(base)
|
|
54
|
+
for key, value in change.items():
|
|
55
|
+
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
56
|
+
result[key] = _merged(result[key], value)
|
|
57
|
+
else:
|
|
58
|
+
result[key] = value
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _with_changes(folder: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
63
|
+
changes = os.path.join(folder, "config.yaml")
|
|
64
|
+
return _merged(config, read_config(changes)) if os.path.isfile(changes) else config
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _logger(folder: str, name: str) -> dict[str, Any]:
|
|
68
|
+
return {
|
|
69
|
+
"log-level": "INFO",
|
|
70
|
+
"log-file": os.path.join(folder, "log", f"{name}.log"),
|
|
71
|
+
"log-rotation": {"maxbytes": 1048576, "backupcount": 4},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _rest_api(folder: str, port: int, name: str) -> dict[str, Any]:
|
|
76
|
+
return {
|
|
77
|
+
"port": port,
|
|
78
|
+
"certificate": os.path.join(folder, "ssl", f"{name}.crt"),
|
|
79
|
+
"private-key": os.path.join(folder, "ssl", f"{name}.key"),
|
|
80
|
+
"users-file": os.path.join(folder, "ssl", "users.yaml"),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _folders(folder: str, *names: str) -> None:
|
|
85
|
+
for name in names:
|
|
86
|
+
os.makedirs(os.path.join(folder, name), exist_ok=True)
|
|
87
|
+
os.chmod(os.path.join(folder, "ssl"), 0o700) # private key, password hashes
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def library_config(folder: str) -> dict[str, Any]:
|
|
91
|
+
"""The config of a library below ``folder``."""
|
|
92
|
+
folder = os.path.abspath(os.path.expanduser(folder))
|
|
93
|
+
_folders(folder, "data", "albums", "images", "ssl", "log")
|
|
94
|
+
library: dict[str, Any] = {
|
|
95
|
+
"data-folder": os.path.join(folder, "data"),
|
|
96
|
+
"albums-folder": os.path.join(folder, "albums"),
|
|
97
|
+
"images-folder": os.path.join(folder, "images"),
|
|
98
|
+
"backups-folder": os.path.join(folder, "backups"),
|
|
99
|
+
"playlist-filename": "playlist.m3u",
|
|
100
|
+
"platform": PLATFORM,
|
|
101
|
+
"releases": {"repository": REPOSITORY},
|
|
102
|
+
"rest-api": _rest_api(folder, 8866, "library"),
|
|
103
|
+
}
|
|
104
|
+
if _secret(BOOTSTRAP_VARIABLE):
|
|
105
|
+
# taken over at the first start and whenever it names another one (library.md, section 4.14)
|
|
106
|
+
library["bootstrap-password"] = _secret(BOOTSTRAP_VARIABLE)
|
|
107
|
+
if os.environ.get(SLOTS_VARIABLE):
|
|
108
|
+
library["slots-folder"] = os.environ[SLOTS_VARIABLE]
|
|
109
|
+
return _with_changes(folder, {"logger": _logger(folder, "library"), "library": library})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def box_config(folder: str) -> dict[str, Any]:
|
|
113
|
+
"""The config of a box without hardware below ``folder``, as ``deploy/config/tinyjukebox-sim.yaml`` has it."""
|
|
114
|
+
folder = os.path.abspath(os.path.expanduser(folder))
|
|
115
|
+
_folders(folder, "albums", "json", "ssl", "log")
|
|
116
|
+
json_files = {}
|
|
117
|
+
for key, name in (("tags", "tags"), ("targets", "targets"), ("activities", "activity")):
|
|
118
|
+
json_files[key] = os.path.join(folder, "json", f"{name}.json")
|
|
119
|
+
if not os.path.exists(json_files[key]):
|
|
120
|
+
with open(json_files[key], "w", encoding="utf-8") as outfile:
|
|
121
|
+
outfile.write("{}\n")
|
|
122
|
+
box = {
|
|
123
|
+
"albums-folder": os.path.join(folder, "albums"),
|
|
124
|
+
"playlist-filename": "playlist.m3u",
|
|
125
|
+
"volume": {"max-volume": 100, "default-volume": 40, "volume-step": 5},
|
|
126
|
+
"albumplayer": {"rewind-max-time": 10, "update-schedule": 5, "pause-between-tracks": 0},
|
|
127
|
+
"json": json_files,
|
|
128
|
+
"rest-api": _rest_api(folder, 8443, "tinyjukebox"),
|
|
129
|
+
}
|
|
130
|
+
return _with_changes(folder, {"logger": _logger(folder, "tinyjukebox"), "tinyjukebox": box})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def link_library(folder: str, logger: logging.Logger) -> None:
|
|
134
|
+
"""A box at its first start: the library of ``TINYJUKEBOX_LIBRARY`` with its bootstrap password - as if they had
|
|
135
|
+
been entered on the page (library.md, section 4.2). Later the page changes them."""
|
|
136
|
+
address = os.environ.get(LIBRARY_VARIABLE, "").strip().rstrip("/")
|
|
137
|
+
identity = os.path.join(os.path.abspath(os.path.expanduser(folder)), "identity")
|
|
138
|
+
if not address or os.path.exists(os.path.join(identity, "library")):
|
|
139
|
+
return
|
|
140
|
+
os.makedirs(identity, mode=0o700, exist_ok=True)
|
|
141
|
+
with open(os.path.join(identity, "library"), "w", encoding="utf-8") as outfile:
|
|
142
|
+
outfile.write(address + "\n")
|
|
143
|
+
password = _secret(BOOTSTRAP_VARIABLE)
|
|
144
|
+
if password:
|
|
145
|
+
descriptor = os.open(os.path.join(identity, "bootstrap"), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
146
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as outfile:
|
|
147
|
+
outfile.write(password + "\n")
|
|
148
|
+
logger.info("linked to the library %s at the first start", address)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def first_admin(users_file: str, logger: logging.Logger) -> None:
|
|
152
|
+
"""The user ``admin`` of the page, if the page has no user yet: its password from ``TINYJUKEBOX_ADMIN_PASSWORD``
|
|
153
|
+
(or the file of ``TINYJUKEBOX_ADMIN_PASSWORD_FILE``), asked on the terminal, or - without a terminal - generated
|
|
154
|
+
and printed once on the error output (``docker logs``), never into the log file."""
|
|
155
|
+
users = UsersFile.read(users_file)
|
|
156
|
+
if users:
|
|
157
|
+
return
|
|
158
|
+
password = _secret(ADMIN_VARIABLE)
|
|
159
|
+
generated = False
|
|
160
|
+
if not password and sys.stdin.isatty():
|
|
161
|
+
while True:
|
|
162
|
+
password = getpass.getpass(f"password of the user admin of the page (at least {MIN_PASSWORD_LENGTH}): ")
|
|
163
|
+
if len(password) >= MIN_PASSWORD_LENGTH and getpass.getpass("again: ") == password:
|
|
164
|
+
break
|
|
165
|
+
print("too short, or the two do not match", file=sys.stderr)
|
|
166
|
+
if not password:
|
|
167
|
+
password = secrets.token_urlsafe(12)
|
|
168
|
+
generated = True
|
|
169
|
+
if len(password) < MIN_PASSWORD_LENGTH:
|
|
170
|
+
raise SystemExit(f"{ADMIN_VARIABLE}: the password needs at least {MIN_PASSWORD_LENGTH} characters")
|
|
171
|
+
UsersFile.write(users_file, {"admin": {"password-hash": hash_password(password), "role": "admin"}})
|
|
172
|
+
if generated:
|
|
173
|
+
print(f"the page has the user admin with the password {password} - change it on the page (tab System)",
|
|
174
|
+
file=sys.stderr) # fmt: skip
|
|
175
|
+
logger.warning(
|
|
176
|
+
"the page has the user admin with a generated password: it was printed once on the error output "
|
|
177
|
+
"(docker logs) - change it on the page (tab System)"
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
logger.info("the page has the user admin")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Device drivers of the box (RFID reader, buttons, RGB LED) and the DriverManager."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Base classes and helpers for drivers."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Base class of all drivers."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from abc import ABCMeta, abstractmethod
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
import tinyjukebox
|
|
9
|
+
from tinyjukebox.core.microservice import AbstractMicroservice
|
|
10
|
+
from tinyjukebox.core.pubsub import PubSubClient
|
|
11
|
+
from tinyjukebox.core.ui import Command
|
|
12
|
+
|
|
13
|
+
__all__ = ["ADriver"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ADriver(AbstractMicroservice, metaclass=ABCMeta):
|
|
17
|
+
"""Base class of all drivers: a component with the config section ``driver``.
|
|
18
|
+
|
|
19
|
+
* applies the common driver config entries ``type`` and ``name``; topics and payloads are defined in
|
|
20
|
+
:mod:`tinyjukebox.messages`,
|
|
21
|
+
* start/stop via :meth:`_driver_start` / :meth:`_driver_stop`,
|
|
22
|
+
* drivers are created and started by the :class:`~tinyjukebox.devices.drivermanager.drivermanager.DriverManager`
|
|
23
|
+
(config section ``drivers``); they use its pubsub client and logger and have no command line of their own.
|
|
24
|
+
|
|
25
|
+
Driver specific console commands are added to ``self._ui_commands`` (or via :meth:`_add_ui_command`) within
|
|
26
|
+
``__init__``; :attr:`driver_ui_commands` provides them to the DriverManager.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_version: ClassVar[str] = tinyjukebox.__version__
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
config: Mapping[str, Any],
|
|
34
|
+
pubsub_client: PubSubClient,
|
|
35
|
+
logger: logging.Logger,
|
|
36
|
+
logger_name: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(config, "driver", pubsub_client, logger, logger_name or type(self).__name__, no_gui=True)
|
|
39
|
+
|
|
40
|
+
self._type = type(self).__name__
|
|
41
|
+
if self._type.upper() != self._config["type"].upper():
|
|
42
|
+
message = f"Type of class ({self._type.upper()}) is different than type of config "
|
|
43
|
+
message += f"({self._config['type'].upper()})."
|
|
44
|
+
self._logger.error("ADriver.__init__ - %s", message)
|
|
45
|
+
raise TypeError(message)
|
|
46
|
+
|
|
47
|
+
self._name: str = self._config.get("name", self._type)
|
|
48
|
+
self._logger = self._logger.getChild(self._name)
|
|
49
|
+
|
|
50
|
+
self._base_ui_commands = frozenset(self._ui_commands)
|
|
51
|
+
|
|
52
|
+
# --- public -------------------------------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def name(self) -> str:
|
|
56
|
+
return self._name
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def driver_type(self) -> str:
|
|
60
|
+
return self._type
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def driver_ui_commands(self) -> dict[str, Command]:
|
|
64
|
+
"""The driver specific console commands (without the generic commands of the microservice)."""
|
|
65
|
+
return {name: command for name, command in self._ui_commands.items() if name not in self._base_ui_commands}
|
|
66
|
+
|
|
67
|
+
def get_name(self) -> str:
|
|
68
|
+
return self._name
|
|
69
|
+
|
|
70
|
+
def get_short_info(self) -> str:
|
|
71
|
+
return f"[{self._name}] type:{self._type} is_stopped:{self.is_stopped}"
|
|
72
|
+
|
|
73
|
+
def state(self) -> dict[str, Any]:
|
|
74
|
+
"""What the driver knows about its hardware right now - the technical view of the web page shows it.
|
|
75
|
+
``type`` is the one of the config (``input``, ``rgbled``, ...); drivers add theirs with
|
|
76
|
+
:meth:`_driver_state`."""
|
|
77
|
+
return {
|
|
78
|
+
"name": self._name,
|
|
79
|
+
"type": str(self._config["type"]).lower(),
|
|
80
|
+
"running": bool(self.is_started),
|
|
81
|
+
**self._driver_state(),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# --- helpers for drivers ------------------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _publish_value(self, topic: str, value: Any) -> None:
|
|
87
|
+
"""Publish ``value`` to ``topic`` (see :mod:`tinyjukebox.messages`)."""
|
|
88
|
+
self._pubsub_client.publish(topic, value)
|
|
89
|
+
self._logger.info("ADriver._publish_value - %s: %s", topic, value)
|
|
90
|
+
|
|
91
|
+
# --- life cycle ---------------------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _start(self) -> None:
|
|
94
|
+
self._driver_start()
|
|
95
|
+
self._logger.warning("Driver '%s.%s' started", self._type, self._name)
|
|
96
|
+
|
|
97
|
+
def _stop(self) -> None:
|
|
98
|
+
self._driver_stop()
|
|
99
|
+
self._logger.warning("Driver '%s.%s' stopped", self._type, self._name)
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def _driver_start(self) -> None:
|
|
103
|
+
"""Set up the hardware, subscribe to topics, start threads."""
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
def _driver_stop(self) -> None:
|
|
107
|
+
"""Reverse of :meth:`_driver_start`."""
|
|
108
|
+
|
|
109
|
+
def _driver_state(self) -> dict[str, Any]:
|
|
110
|
+
"""The driver specific part of :meth:`state` - called by any thread, also while the driver is stopped."""
|
|
111
|
+
return {}
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def _get_description(cls) -> str:
|
|
115
|
+
return f"Device driver '{cls.__name__}'."
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Mixin for drivers that react to edges on GPIO pins."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
|
+
from typing import Any, NamedTuple
|
|
6
|
+
|
|
7
|
+
from RPi import GPIO
|
|
8
|
+
|
|
9
|
+
__all__ = ["AEvents", "EdgeCallback"]
|
|
10
|
+
|
|
11
|
+
EdgeCallback = Callable[[int], None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Event(NamedTuple):
|
|
15
|
+
callback: EdgeCallback
|
|
16
|
+
bounce_time: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AEvents:
|
|
20
|
+
"""Mixin for drivers that must react to changing pin states (``GPIO.setmode(GPIO.BCM)``).
|
|
21
|
+
|
|
22
|
+
Events are registered for both edges. The callback receives the channel and has to read the pin state itself to
|
|
23
|
+
distinguish rising and falling edges. Callbacks run in the callback thread of the GPIO library.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, config: Mapping[str, Any], logger: logging.Logger) -> None:
|
|
27
|
+
self._events: dict[int, _Event] = {}
|
|
28
|
+
self._logger_aevents = logger
|
|
29
|
+
GPIO.setmode(GPIO.BCM)
|
|
30
|
+
|
|
31
|
+
def _add_event(self, pin: int, func: EdgeCallback, bounce_time: int = 50) -> None:
|
|
32
|
+
"""Remember an edge callback for ``pin``; it becomes active with :meth:`_register_events`."""
|
|
33
|
+
self._logger_aevents.info("AEvents._add_event - pin:%s, func:%s, bounce:%s.", pin, func, bounce_time)
|
|
34
|
+
self._events[int(pin)] = _Event(func, bounce_time)
|
|
35
|
+
|
|
36
|
+
def _register_events(self) -> None:
|
|
37
|
+
"""Activate all edge callbacks - usually in ``_driver_start``."""
|
|
38
|
+
GPIO.setmode(GPIO.BCM)
|
|
39
|
+
for pin, event in self._events.items():
|
|
40
|
+
self._logger_aevents.debug("AEvents._register_events - pin:%s, bounce_time:%s", pin, event.bounce_time)
|
|
41
|
+
GPIO.add_event_detect(pin, GPIO.BOTH, callback=event.callback, bouncetime=event.bounce_time)
|
|
42
|
+
|
|
43
|
+
def _unregister_events(self) -> None:
|
|
44
|
+
"""Deactivate all edge callbacks - usually in ``_driver_stop``."""
|
|
45
|
+
for pin in self._events:
|
|
46
|
+
self._logger_aevents.debug("AEvents._unregister_events - pin:%s", pin)
|
|
47
|
+
GPIO.remove_event_detect(pin)
|