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,769 @@
1
+ """STMOperation - the state machine of the box: reacts on rfid tags and starts/stops the album playback."""
2
+
3
+ import logging
4
+ import threading
5
+ from collections.abc import Callable
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, Protocol
9
+
10
+ from tinyjukebox.core.logging import get_child
11
+ from tinyjukebox.core.pubsub import PubSubClient
12
+ from tinyjukebox.data.activities import Activities
13
+ from tinyjukebox.data.activity import ActivityAlbum
14
+ from tinyjukebox.data.common import is_unmapped
15
+ from tinyjukebox.data.tag import Tag
16
+ from tinyjukebox.data.tags import Tags
17
+ from tinyjukebox.data.target import TargetAlbum, TargetRadio, TargetSpotify
18
+ from tinyjukebox.data.targets import Targets
19
+ from tinyjukebox.latency import ReactionTimer
20
+ from tinyjukebox.messages import BUTTON_PRESSED, JOURNAL, RFID_POLL, RFID_STATE, Button, JournalEvent, RfidState
21
+ from tinyjukebox.rgbled import WAITING, Color, RGBLed
22
+ from tinyjukebox.stm.base import State, create_machine, write_dot_file
23
+
24
+ if TYPE_CHECKING:
25
+ from tinyjukebox.sync.box import BoxSync
26
+
27
+ __all__ = [
28
+ "IDLE_BLINK_AFTER",
29
+ "RADIO_TEST_DURATION",
30
+ "AlbumControl",
31
+ "AlbumFactory",
32
+ "RadioFactory",
33
+ "STMOperation",
34
+ "Speaker",
35
+ "SpotifyFactory",
36
+ "States",
37
+ "Transitions",
38
+ ]
39
+
40
+ FORCED_POLLING_INTERVAL = 5.0
41
+ """Seconds between the requests for the current rfid state until the first rfid message has been received."""
42
+ RADIO_TEST_DURATION = 60.0
43
+ """Seconds after which a radio test (:meth:`STMOperation.test_radio`) ends by itself."""
44
+ IDLE_BLINK_AFTER = 600.0
45
+ """Seconds without tag after which the green led blinks - the box has been left switched on (as the old box)."""
46
+
47
+
48
+ class States(Enum):
49
+ """States of the STMOperation state machine."""
50
+
51
+ WAIT_FOR_TAG = "wait_for_tag" # waiting for an rfid tag
52
+ IDLE_LONG = "idle_long" # waiting for an rfid tag for a long time - the led blinks
53
+ CHECK_TAG = "check_tag" # checking the detected tag
54
+ PLAY_ALBUM = "play_album" # playing the album or radio station linked to the tag
55
+ UNMAPPED_TAG = "unknown_tag" # tag is not linked to an album or radio station
56
+ UNKNOWN_ALBUM = "unknown_album" # tag is linked to an album/radio station that does not exist
57
+ BROKEN_ALBUM = "broken_album" # album cannot be played (e.g. missing folder, missing or invalid mp3 files)
58
+ TEST_RADIO = "test_radio" # testing a radio stream or an entry of Spotify while no tag is on the box
59
+ SPOTIFY_SPEAKER = "spotify_speaker" # no tag: the Spotify app plays on the box
60
+ FETCH_ALBUM = "fetch_album" # the album is fetched from the library - it plays as soon as its track is there
61
+ BLOCKED = "blocked" # the box plays nothing: the library is too old for it
62
+ END = "end" # final state
63
+
64
+
65
+ class Transitions(Enum):
66
+ """Transitions of the STMOperation state machine."""
67
+
68
+ NO_TAG = "no_tag" # no rfid tag present
69
+ IDLE_TIMEOUT = "idle_timeout" # no rfid tag for a long time
70
+ NEW_TAG = "new_tag" # new rfid tag detected
71
+ TAG_UNMAPPED = "unknown_tag" # tag is not linked to an album
72
+ ALBUM_UNKNOWN = "unknown_album" # linked album does not exist
73
+ ALBUM_KNOWN = "play_album" # linked album exists
74
+ ALBUM_BROKEN = "broken_album" # album could not be started
75
+ TEST_RADIO = "test_radio" # start (or switch) a radio test
76
+ TEST_END = "test_end" # radio test stopped or timed out
77
+ ALBUM_FETCH = "fetch_album" # files of the linked album are missing on the box
78
+ ALBUM_READY = "album_ready" # the track that is due has arrived
79
+ FETCH_FAILED = "fetch_failed" # the album cannot be fetched
80
+ BLOCKED = "blocked" # the box does not work
81
+ SPEAKER_PLAYS = "speaker_plays" # the Spotify app plays on the box
82
+ SPEAKER_ENDS = "speaker_ends" # ... it does not any more
83
+ END = "end" # shutdown
84
+
85
+
86
+ class AlbumControl(Protocol):
87
+ """An album playback (:class:`~tinyjukebox.stm.stmplayalbum.STMPlayAlbum`) as used by STMOperation and the
88
+ box control."""
89
+
90
+ def start(self) -> None: ...
91
+
92
+ def stop(self) -> None: ...
93
+
94
+ def cmd_next_handler(self) -> None: ...
95
+
96
+ def cmd_prev_handler(self) -> None: ...
97
+
98
+ def cmd_restart_album(self) -> None: ...
99
+
100
+ def cmd_pause(self) -> None: ...
101
+
102
+ def cmd_play(self) -> None: ...
103
+
104
+ def cmd_output_lost(self, then: Callable[[], None] | None = None) -> None:
105
+ """The headset has gone: pause. ``then`` is called once the playback has handled it - in its worker, after
106
+ everything that was queued before; the headset switches the output to the speaker only then."""
107
+ ...
108
+
109
+ def cmd_output_changed(self) -> None: ...
110
+
111
+ def get_info(self) -> dict[str, Any]: ...
112
+
113
+
114
+ AlbumFactory = Callable[[TargetAlbum, ActivityAlbum], AlbumControl]
115
+ RadioFactory = Callable[[TargetRadio], AlbumControl]
116
+ SpotifyFactory = Callable[[TargetSpotify, ActivityAlbum | None], AlbumControl]
117
+ """(entry, activity) -> playback; activity None: a test"""
118
+
119
+
120
+ class Speaker(Protocol):
121
+ """The box as a Spotify speaker while no tag is on it (:class:`~tinyjukebox.spotify.engine.SpotifyEngine`)."""
122
+
123
+ def idle(self) -> None: ...
124
+
125
+ def taken(self, spotify: bool) -> None: ...
126
+
127
+ def listen(self) -> None: ...
128
+
129
+ def silence(self) -> None: ...
130
+
131
+ def button(self, button: Button) -> None:
132
+ """A button of the box steers what the phone plays - returns at once (called in the dispatch thread)."""
133
+ ...
134
+
135
+ def playback(self) -> AlbumControl: ...
136
+
137
+ on_speaker_plays: Callable[[], None]
138
+ on_speaker_ends: Callable[[], None]
139
+ on_speaker_paused: Callable[[bool], None]
140
+
141
+
142
+ UidSource = Callable[[], str | None]
143
+
144
+
145
+ class _LedState(State):
146
+ """State that only sets the led on entry."""
147
+
148
+ def __init__(self, state_id: States, logger: logging.Logger, rgbled: RGBLed, color: Color) -> None:
149
+ super().__init__(state_id, logger)
150
+ self._rgbled = rgbled
151
+ self._color = color
152
+
153
+ def _on_entry(self) -> Enum | None:
154
+ self._rgbled.set_color(self._color)
155
+ return None
156
+
157
+
158
+ class End(_LedState):
159
+ def __init__(self, logger: logging.Logger, rgbled: RGBLed) -> None:
160
+ super().__init__(States.END, logger, rgbled, Color.MAGENTA)
161
+
162
+
163
+ class WaitForTag(_LedState):
164
+ # the current uid is maintained by _rfid_handler only - resetting it here would drop a tag that has been placed
165
+ # while the "no tag" event was still queued
166
+ def __init__(self, logger: logging.Logger, rgbled: RGBLed, speaker: Speaker | None = None) -> None:
167
+ super().__init__(States.WAIT_FOR_TAG, logger, rgbled, Color.GREEN)
168
+ self._speaker = speaker
169
+
170
+ def _on_entry(self) -> Enum | None:
171
+ super()._on_entry()
172
+ if self._speaker is not None:
173
+ self._speaker.idle()
174
+ return None
175
+
176
+
177
+ class IdleLong(State):
178
+ def __init__(self, logger: logging.Logger, rgbled: RGBLed, speaker: Speaker | None = None) -> None:
179
+ super().__init__(States.IDLE_LONG, logger)
180
+ self._rgbled = rgbled
181
+ self._speaker = speaker
182
+
183
+ def _on_entry(self) -> Enum | None:
184
+ self._rgbled.set_blink_color(Color.GREEN, 1)
185
+ if self._speaker is not None:
186
+ self._speaker.idle()
187
+ return None
188
+
189
+
190
+ class SpotifySpeaker(State):
191
+ """No tag on the box, and the Spotify app plays on it: the sound is heard, the buttons steer what the phone
192
+ started. A tag or a test ends it (docs/developer/architecture.md, section 2.5)."""
193
+
194
+ def __init__(self, logger: logging.Logger, rgbled: RGBLed, speaker: Speaker, pubsub_client: PubSubClient) -> None:
195
+ super().__init__(States.SPOTIFY_SPEAKER, logger)
196
+ self._rgbled = rgbled
197
+ self._speaker = speaker
198
+ self._pubsub_client = pubsub_client
199
+
200
+ self._playback: AlbumControl | None = None
201
+
202
+ def _on_entry(self) -> Enum | None:
203
+ self._rgbled.set_color(Color.BLUE)
204
+ self._playback = self._speaker.playback()
205
+ self._playback.start()
206
+ self._pubsub_client.subscribe(BUTTON_PRESSED, self._button)
207
+ return None
208
+
209
+ def _on_exit(self) -> None:
210
+ self._pubsub_client.unsubscribe(BUTTON_PRESSED, self._button, ignore_not_found=True)
211
+ playback, self._playback = self._playback, None
212
+ if playback is not None:
213
+ playback.stop()
214
+
215
+ def get_album(self) -> AlbumControl | None:
216
+ return self._playback
217
+
218
+ def _button(self, message: object) -> None:
219
+ if isinstance(message, Button):
220
+ self._speaker.button(message)
221
+
222
+
223
+ class CheckTag(State):
224
+ def __init__(
225
+ self,
226
+ logger: logging.Logger,
227
+ tags: Tags,
228
+ targets: Targets,
229
+ get_current_uid: UidSource,
230
+ sync: "BoxSync | None" = None,
231
+ start_track: Callable[[str], int] = lambda uid: 0,
232
+ speaker: Speaker | None = None,
233
+ ) -> None:
234
+ super().__init__(States.CHECK_TAG, logger)
235
+ self._speaker = speaker
236
+ self._tags = tags
237
+ self._targets = targets
238
+ self._get_current_uid = get_current_uid
239
+ self._sync = sync
240
+ self._start_track = start_track
241
+
242
+ def _taken(self, target: object) -> None:
243
+ """Every tag on the box ends the playback of the Spotify speaker, and Soloist with it unless the tag plays an
244
+ entry of Spotify - also a new tag and a tag on a box that is out of service (docs/developer/architecture.md,
245
+ section 2.5)."""
246
+ if self._speaker is not None:
247
+ self._speaker.taken(isinstance(target, TargetSpotify))
248
+
249
+ def _on_entry(self) -> Enum | None:
250
+ uid = self._get_current_uid()
251
+ if uid is None:
252
+ return Transitions.NO_TAG
253
+ if self._sync is not None and self._sync.blocked:
254
+ self._taken(None)
255
+ return Transitions.BLOCKED
256
+ if uid not in self._tags.tags and self._sync is not None:
257
+ # an unknown tag: compare the catalog with the library before it is taken for a new one
258
+ self._sync.lookup()
259
+ if uid not in self._tags.tags and uid not in self._tags.unavailable:
260
+ self._logger.info("new tag '%s' - adding it to tags as unmapped tag.", uid)
261
+ self._tags.add_tag(uid)
262
+ self._taken(None)
263
+ return Transitions.TAG_UNMAPPED
264
+ # no tag: known, but this version cannot use what it is linked to (docs/developer/library.md, section 4.21)
265
+ tag = self._tags.tags.get(uid)
266
+ target = None if tag is None or is_unmapped(tag.target_id) else self._targets.targets.get(str(tag.target_id))
267
+ self._taken(target)
268
+ if tag is not None and is_unmapped(tag.target_id):
269
+ return Transitions.TAG_UNMAPPED
270
+ if tag is None or target is None or target.target_type != tag.target_type:
271
+ return Transitions.ALBUM_UNKNOWN
272
+ return Transitions.ALBUM_KNOWN if self._is_here(uid, target) else Transitions.ALBUM_FETCH
273
+
274
+ def _is_here(self, uid: str, target: object) -> bool:
275
+ """Whether the target can play at once: for an album of the library the track that is due must be on the
276
+ box."""
277
+ if not isinstance(target, TargetAlbum) or self._sync is None or not self._sync.attached:
278
+ return True
279
+ ready = self._sync.files.ready(str(target.target_id), self._start_track(uid))
280
+ target.incomplete = bool(self._sync.files.lacking(str(target.target_id)) or not ready)
281
+ return ready
282
+
283
+
284
+ class FetchAlbum(State):
285
+ """Files of the album are missing on the box: they are fetched from the library, beginning with the track that
286
+ is due. LED: green, aqua, blue flowing back and forth."""
287
+
288
+ def __init__(self, logger: logging.Logger, rgbled: RGBLed, fetch: Callable[[], Enum | None]) -> None:
289
+ super().__init__(States.FETCH_ALBUM, logger)
290
+ self._rgbled = rgbled
291
+ self._fetch = fetch
292
+
293
+ def _on_entry(self) -> Enum | None:
294
+ self._rgbled.set_sequence(WAITING)
295
+ return self._fetch()
296
+
297
+
298
+ class Blocked(State):
299
+ """The library is too old for this box (docs/developer/library.md, section 4.15): it plays nothing.
300
+ The led shows the alarm pattern as an overlay, with or without a tag."""
301
+
302
+ def __init__(self, logger: logging.Logger) -> None:
303
+ super().__init__(States.BLOCKED, logger)
304
+
305
+ def _on_entry(self) -> Enum | None:
306
+ self._logger.error("this box does not work: the library is too old for it")
307
+ return None
308
+
309
+
310
+ class UnmappedTag(State):
311
+ def __init__(self, logger: logging.Logger, rgbled: RGBLed, get_current_uid: UidSource) -> None:
312
+ super().__init__(States.UNMAPPED_TAG, logger)
313
+ self._rgbled = rgbled
314
+ self._get_current_uid = get_current_uid
315
+
316
+ def _on_entry(self) -> Enum | None:
317
+ self._logger.warning("unmapped tag for uid '%s'", self._get_current_uid())
318
+ self._rgbled.set_color(Color.YELLOW)
319
+ return None
320
+
321
+
322
+ class UnknownAlbum(State):
323
+ def __init__(self, logger: logging.Logger, rgbled: RGBLed, get_current_uid: UidSource) -> None:
324
+ super().__init__(States.UNKNOWN_ALBUM, logger)
325
+ self._rgbled = rgbled
326
+ self._get_current_uid = get_current_uid
327
+
328
+ def _on_entry(self) -> Enum | None:
329
+ self._logger.warning("unknown album mapped to uid '%s'", self._get_current_uid())
330
+ self._rgbled.set_alternate_colors(Color.YELLOW, Color.RED, 1)
331
+ return None
332
+
333
+
334
+ class BrokenAlbum(State):
335
+ def __init__(self, logger: logging.Logger, rgbled: RGBLed, get_current_uid: UidSource) -> None:
336
+ super().__init__(States.BROKEN_ALBUM, logger)
337
+ self._rgbled = rgbled
338
+ self._get_current_uid = get_current_uid
339
+
340
+ def _on_entry(self) -> Enum | None:
341
+ self._logger.warning("album mapped to uid '%s' cannot be played", self._get_current_uid())
342
+ self._rgbled.set_alternate_colors(Color.RED, Color.BLACK, 1)
343
+ return None
344
+
345
+
346
+ class PlayAlbum(State):
347
+ def __init__(
348
+ self,
349
+ logger: logging.Logger,
350
+ rgbled: RGBLed,
351
+ tags: Tags,
352
+ targets: Targets,
353
+ activities: Activities,
354
+ get_current_uid: UidSource,
355
+ album_factory: AlbumFactory,
356
+ radio_factory: RadioFactory | None,
357
+ spotify_factory: SpotifyFactory | None = None,
358
+ ) -> None:
359
+ super().__init__(States.PLAY_ALBUM, logger)
360
+ self._rgbled = rgbled
361
+ self._tags = tags
362
+ self._targets = targets
363
+ self._activities = activities
364
+ self._get_current_uid = get_current_uid
365
+ self._album_factory = album_factory
366
+ self._radio_factory = radio_factory
367
+ self._spotify_factory = spotify_factory
368
+ self._album: AlbumControl | None = None
369
+
370
+ def _get_activity(self, tag: Tag) -> ActivityAlbum:
371
+ """The activity of the tag - (re)created if it is missing or belongs to a previously linked target."""
372
+ activity = self._activities.activities.get(str(tag.tag_uid))
373
+ if activity is not None and str(activity.target_id) == str(tag.target_id):
374
+ return activity
375
+ if activity is not None:
376
+ self._logger.info(
377
+ "activity for tag '%s' belongs to old target '%s' - resetting it.", tag.tag_uid, activity.target_id
378
+ )
379
+ self._activities.delete_activity(tag.tag_uid)
380
+ else:
381
+ self._logger.info("no activity for tag '%s' - creating it.", tag.tag_uid)
382
+ return self._activities.add_activity(tag)
383
+
384
+ def _on_entry(self) -> Enum | None:
385
+ self._rgbled.set_color(Color.AQUA)
386
+ uid = self._get_current_uid()
387
+ if uid is None:
388
+ return Transitions.NO_TAG
389
+ tag = self._tags.get_tag(uid)
390
+ target = self._targets.get_target(str(tag.target_id))
391
+ try:
392
+ if isinstance(target, TargetRadio):
393
+ if self._radio_factory is None:
394
+ raise RuntimeError("no radio player")
395
+ album = self._radio_factory(target)
396
+ elif isinstance(target, TargetSpotify):
397
+ if self._spotify_factory is None:
398
+ raise RuntimeError("no player for Spotify")
399
+ album = self._spotify_factory(target, self._get_activity(tag))
400
+ else:
401
+ album = self._album_factory(target, self._get_activity(tag))
402
+ album.start()
403
+ except Exception as e:
404
+ self._logger.error("cannot play '%s': %s", target.to_text(), e)
405
+ return Transitions.ALBUM_BROKEN
406
+ self._album = album
407
+ return None
408
+
409
+ def _on_exit(self) -> None:
410
+ album, self._album = self._album, None
411
+ if album is not None:
412
+ album.stop()
413
+
414
+ def get_album(self) -> AlbumControl | None:
415
+ return self._album
416
+
417
+
418
+ class TestRadio(State):
419
+ """Plays a radio stream for a test - only while no tag is on the box: a tag always wins (the test ends when a
420
+ tag is placed, and a test that was queued behind a tag does not start)."""
421
+
422
+ def __init__(
423
+ self,
424
+ logger: logging.Logger,
425
+ get_current_uid: UidSource,
426
+ get_station: Callable[[], TargetRadio | TargetSpotify | None],
427
+ radio_factory: RadioFactory | None,
428
+ spotify_factory: SpotifyFactory | None = None,
429
+ speaker: Speaker | None = None,
430
+ ) -> None:
431
+ super().__init__(States.TEST_RADIO, logger)
432
+ self._get_current_uid = get_current_uid
433
+ self._get_station = get_station
434
+ self._radio_factory = radio_factory
435
+ self._spotify_factory = spotify_factory
436
+ self._speaker = speaker
437
+ self._playback: AlbumControl | None = None
438
+
439
+ def _on_entry(self) -> Enum | None:
440
+ station = self._get_station()
441
+ spotify = isinstance(station, TargetSpotify)
442
+ factory = self._spotify_factory if spotify else self._radio_factory
443
+ if self._get_current_uid() is not None or station is None or factory is None:
444
+ self._logger.info("test not started - a tag is on the box or there is nothing to test")
445
+ return Transitions.TEST_END
446
+ if self._speaker is not None:
447
+ self._speaker.taken(spotify)
448
+ self._logger.info("testing %s", station.to_text())
449
+ if isinstance(station, TargetSpotify):
450
+ assert self._spotify_factory is not None # noqa: S101 - checked above
451
+ playback = self._spotify_factory(station, None)
452
+ else:
453
+ assert self._radio_factory is not None # noqa: S101 - checked above
454
+ playback = self._radio_factory(station)
455
+ try:
456
+ playback.start()
457
+ except Exception as e:
458
+ self._logger.error("radio test failed: %s", e)
459
+ return Transitions.TEST_END
460
+ self._playback = playback
461
+ return None
462
+
463
+ def _on_exit(self) -> None:
464
+ playback, self._playback = self._playback, None
465
+ if playback is not None:
466
+ playback.stop()
467
+
468
+ def get_album(self) -> AlbumControl | None:
469
+ return self._playback
470
+
471
+
472
+ class STMOperation:
473
+ """Reacts on the :class:`~tinyjukebox.messages.RfidState` messages of the rfid reader
474
+ (:data:`~tinyjukebox.messages.RFID_STATE`).
475
+
476
+ The rfid handler only records the current uid and queues an event - it never blocks the pubsub dispatch thread.
477
+ Until the first rfid message has been received, the current rfid state is requested every
478
+ ``poll_interval`` seconds (:data:`~tinyjukebox.messages.RFID_POLL`).
479
+ """
480
+
481
+ def __init__(
482
+ self,
483
+ pubsub_client: PubSubClient,
484
+ logger: logging.Logger,
485
+ rgbled: RGBLed,
486
+ tags: Tags,
487
+ targets: Targets,
488
+ activities: Activities,
489
+ album_factory: AlbumFactory,
490
+ poll_interval: float = FORCED_POLLING_INTERVAL,
491
+ latency: ReactionTimer | None = None,
492
+ radio_factory: RadioFactory | None = None,
493
+ radio_test_duration: float = RADIO_TEST_DURATION,
494
+ idle_blink_after: float = IDLE_BLINK_AFTER,
495
+ sync: "BoxSync | None" = None,
496
+ spotify_factory: SpotifyFactory | None = None,
497
+ speaker: Speaker | None = None,
498
+ ) -> None:
499
+ """:param latency: measures tag read → playback started (default: a timer of its own).
500
+ :param radio_factory: playback of radio stations (``None``: tags linked to a station end in BROKEN_ALBUM).
501
+ :param radio_test_duration: seconds after which a radio test ends by itself.
502
+ :param idle_blink_after: seconds without tag after which the green led blinks.
503
+ :param sync: the synchronization with the library: unknown tags are looked up there, missing albums are
504
+ fetched.
505
+ :param spotify_factory: playback of entries of Spotify (``None``: such tags end in BROKEN_ALBUM).
506
+ :param speaker: the box as a Spotify speaker while no tag is on it."""
507
+ self._logger = get_child(logger, type(self).__name__)
508
+ self._sync = sync
509
+ self._tags = tags
510
+ self._activities = activities
511
+ self._fetching: tuple[str, int] | None = None # album and track that FETCH_ALBUM waits for
512
+ self._pubsub_client = pubsub_client
513
+ self._latency = latency or ReactionTimer("tag", self._logger)
514
+ self._poll_interval = poll_interval
515
+ self._current_uid: str | None = None
516
+ self._rfid_handler_lock = threading.Lock()
517
+ self._rfid_received = threading.Event()
518
+ self._polling_thread: threading.Thread | None = None
519
+ self._radio_factory = radio_factory
520
+ self._test_station: TargetRadio | TargetSpotify | None = None
521
+ self._spotify_factory = spotify_factory
522
+ self._speaker = speaker
523
+ self._rgbled = rgbled
524
+ self._radio_test_duration = radio_test_duration
525
+ self._idle_blink_after = idle_blink_after
526
+
527
+ self._machine = create_machine(self._logger)
528
+ self._play_album_state = PlayAlbum(
529
+ self._logger,
530
+ rgbled,
531
+ tags,
532
+ targets,
533
+ activities,
534
+ self.get_current_uid,
535
+ album_factory,
536
+ radio_factory,
537
+ spotify_factory,
538
+ )
539
+ self._test_radio_state = TestRadio(
540
+ self._logger, self.get_current_uid, lambda: self._test_station, radio_factory, spotify_factory, speaker
541
+ )
542
+ for state in [
543
+ self._play_album_state,
544
+ self._test_radio_state,
545
+ End(self._logger, rgbled),
546
+ CheckTag(self._logger, tags, targets, self.get_current_uid, sync, self._start_track, speaker),
547
+ FetchAlbum(self._logger, rgbled, self._fetch_album),
548
+ Blocked(self._logger),
549
+ UnknownAlbum(self._logger, rgbled, self.get_current_uid),
550
+ UnmappedTag(self._logger, rgbled, self.get_current_uid),
551
+ BrokenAlbum(self._logger, rgbled, self.get_current_uid),
552
+ WaitForTag(self._logger, rgbled, speaker),
553
+ IdleLong(self._logger, rgbled, speaker),
554
+ ]:
555
+ self._machine.addstate(state)
556
+ self._speaker_state: SpotifySpeaker | None = None
557
+ if speaker is not None:
558
+ self._speaker_state = SpotifySpeaker(self._logger, rgbled, speaker, pubsub_client)
559
+ self._machine.addstate(self._speaker_state)
560
+ speaker.on_speaker_plays = self.speaker_plays
561
+ speaker.on_speaker_ends = self.speaker_ends
562
+ speaker.on_speaker_paused = self.speaker_paused
563
+ self._add_transitions()
564
+
565
+ def _add_transitions(self) -> None:
566
+ machine = self._machine
567
+ machine.setstartstate(States.WAIT_FOR_TAG)
568
+ machine.addtransition(States.WAIT_FOR_TAG, Transitions.NEW_TAG, States.CHECK_TAG)
569
+ # no tag for a long time: only the led changes - IDLE_LONG is left like WAIT_FOR_TAG
570
+ machine.addtransition(States.WAIT_FOR_TAG, Transitions.IDLE_TIMEOUT, States.IDLE_LONG)
571
+ machine.addtimeoutevent(States.WAIT_FOR_TAG, Transitions.IDLE_TIMEOUT, self._idle_blink_after)
572
+ machine.addtransition(States.IDLE_LONG, Transitions.NEW_TAG, States.CHECK_TAG)
573
+ machine.addtransition(States.CHECK_TAG, Transitions.NO_TAG, States.WAIT_FOR_TAG)
574
+ machine.addtransition(States.CHECK_TAG, Transitions.TAG_UNMAPPED, States.UNMAPPED_TAG)
575
+ machine.addtransition(States.CHECK_TAG, Transitions.ALBUM_UNKNOWN, States.UNKNOWN_ALBUM)
576
+ machine.addtransition(States.CHECK_TAG, Transitions.ALBUM_KNOWN, States.PLAY_ALBUM)
577
+ machine.addtransition(States.PLAY_ALBUM, Transitions.ALBUM_BROKEN, States.BROKEN_ALBUM)
578
+ # the album is fetched from the library; it plays as soon as the track that is due has arrived
579
+ machine.addtransition(States.CHECK_TAG, Transitions.ALBUM_FETCH, States.FETCH_ALBUM)
580
+ machine.addtransition(States.FETCH_ALBUM, Transitions.ALBUM_READY, States.PLAY_ALBUM)
581
+ machine.addtransition(States.FETCH_ALBUM, Transitions.FETCH_FAILED, States.BROKEN_ALBUM)
582
+ machine.addtransition(States.CHECK_TAG, Transitions.BLOCKED, States.BLOCKED)
583
+ # tag removed or replaced by another tag without a "no tag" message in between
584
+ for state in [
585
+ States.PLAY_ALBUM,
586
+ States.UNMAPPED_TAG,
587
+ States.UNKNOWN_ALBUM,
588
+ States.BROKEN_ALBUM,
589
+ States.FETCH_ALBUM,
590
+ States.BLOCKED,
591
+ ]:
592
+ machine.addtransition(state, Transitions.NO_TAG, States.WAIT_FOR_TAG)
593
+ machine.addtransition(state, Transitions.NEW_TAG, States.CHECK_TAG)
594
+ # radio test: only from "no tag"; a placed tag ends it (NEW_TAG). Its end leads to "no tag": a tag that has
595
+ # been placed meanwhile has queued NEW_TAG behind it - CHECK_TAG here would start its album twice
596
+ for state in [States.WAIT_FOR_TAG, States.IDLE_LONG]:
597
+ machine.addtransition(state, Transitions.TEST_RADIO, States.TEST_RADIO)
598
+ machine.addtransition(States.TEST_RADIO, Transitions.TEST_RADIO, States.TEST_RADIO)
599
+ machine.addtransition(States.TEST_RADIO, Transitions.TEST_END, States.WAIT_FOR_TAG)
600
+ machine.addtransition(States.TEST_RADIO, Transitions.NEW_TAG, States.CHECK_TAG)
601
+ machine.addtransition(States.TEST_RADIO, Transitions.NO_TAG, States.WAIT_FOR_TAG)
602
+ machine.addtimeoutevent(States.TEST_RADIO, Transitions.TEST_END, self._radio_test_duration)
603
+ if self._speaker is not None:
604
+ # the box as a Spotify speaker: only from "no tag"; a tag or a test ends it
605
+ for state in [States.WAIT_FOR_TAG, States.IDLE_LONG]:
606
+ machine.addtransition(state, Transitions.SPEAKER_PLAYS, States.SPOTIFY_SPEAKER)
607
+ machine.addtransition(States.SPOTIFY_SPEAKER, Transitions.SPEAKER_ENDS, States.WAIT_FOR_TAG)
608
+ machine.addtransition(States.SPOTIFY_SPEAKER, Transitions.NEW_TAG, States.CHECK_TAG)
609
+ machine.addtransition(States.SPOTIFY_SPEAKER, Transitions.TEST_RADIO, States.TEST_RADIO)
610
+ for state in States:
611
+ if state != States.END and (state != States.SPOTIFY_SPEAKER or self._speaker is not None):
612
+ machine.addtransition(state, Transitions.END, States.END)
613
+
614
+ @property
615
+ def machine(self) -> object:
616
+ """The tantamount machine (diagram export, tests)."""
617
+ return self._machine
618
+
619
+ def dump_to_dot(self, filename: str | Path) -> None:
620
+ write_dot_file(self._machine, filename)
621
+
622
+ def get_current_uid(self) -> str | None:
623
+ return self._current_uid
624
+
625
+ def get_album(self) -> AlbumControl | None:
626
+ """The active album or radio playback (also a radio test) or None."""
627
+ speaker = self._speaker_state.get_album() if self._speaker_state is not None else None
628
+ return self._play_album_state.get_album() or self._test_radio_state.get_album() or speaker
629
+
630
+ def _note(self, event: str, **fields: object) -> None:
631
+ """Something for the journal of the box."""
632
+ self._pubsub_client.publish(JOURNAL, JournalEvent.of(event, **fields))
633
+
634
+ def _linked_to(self, uid: str) -> dict[str, object]:
635
+ """What the tag ``uid`` plays, for the journal: ``kind`` (album, radio) and ``target``, or nothing."""
636
+ tag = self._tags.tags.get(uid)
637
+ if tag is None or is_unmapped(tag.target_id) or tag.target_type is None:
638
+ return {}
639
+ return {"kind": tag.target_type.name, "target": str(tag.target_id)}
640
+
641
+ def _start_track(self, uid: str) -> int:
642
+ """The track at which the album of the tag ``uid`` continues."""
643
+ tag = self._tags.tags.get(uid)
644
+ activity = self._activities.activities.get(uid)
645
+ if tag is None or activity is None or str(activity.target_id) != str(tag.target_id) or activity.finished:
646
+ return 0
647
+ return max(0, activity.track_id)
648
+
649
+ def _fetch_album(self) -> Enum | None:
650
+ """Entry of FETCH_ALBUM: ask for the album of the tag on the box, the track that is due first."""
651
+ uid = self.get_current_uid()
652
+ tag = self._tags.tags.get(uid) if uid is not None else None
653
+ if uid is None or tag is None or self._sync is None:
654
+ return Transitions.NO_TAG if uid is None else Transitions.FETCH_FAILED
655
+ album, track = str(tag.target_id), self._start_track(uid)
656
+ self._fetching = (album, track)
657
+ self._sync.files.need(album, track)
658
+ return Transitions.ALBUM_READY if self._sync.files.ready(album, track) else None
659
+
660
+ def _file_arrived(self, album: str) -> None:
661
+ """A file of an album arrived, or its download ended (thread of the download) - only FETCH_ALBUM reacts."""
662
+ fetching = self._fetching
663
+ if self._sync is None or fetching is None or fetching[0] != album:
664
+ return
665
+ if self._sync.files.ready(*fetching):
666
+ self._machine.asyncoperate(Transitions.ALBUM_READY)
667
+ elif self._sync.files.failed(album) is not None:
668
+ self._machine.asyncoperate(Transitions.FETCH_FAILED)
669
+
670
+ def test_radio(self, station: TargetRadio | TargetSpotify) -> None:
671
+ """Test the stream of ``station`` (may be unsaved) or an entry of Spotify for :data:`RADIO_TEST_DURATION`
672
+ seconds - only while no tag is on the box. :raises RuntimeError: without player or if a tag is on the box."""
673
+ if (self._spotify_factory if isinstance(station, TargetSpotify) else self._radio_factory) is None:
674
+ raise RuntimeError("no radio player")
675
+ with self._rfid_handler_lock:
676
+ if self._current_uid is not None:
677
+ raise RuntimeError("a tag is on the box")
678
+ self._test_station = station
679
+ self._machine.asyncoperate(Transitions.TEST_RADIO)
680
+
681
+ def stop_radio_test(self) -> None:
682
+ self._machine.asyncoperate(Transitions.TEST_END)
683
+
684
+ def speaker_plays(self) -> None:
685
+ """The Spotify app plays on the box - heard only while no tag is on it."""
686
+ with self._rfid_handler_lock:
687
+ if self._current_uid is None:
688
+ self._machine.asyncoperate(Transitions.SPEAKER_PLAYS)
689
+
690
+ def speaker_ends(self) -> None:
691
+ self._machine.asyncoperate(Transitions.SPEAKER_ENDS)
692
+
693
+ def speaker_paused(self, paused: bool) -> None:
694
+ """LED of the speaker: white/blue while what the phone plays is paused, blue while it plays."""
695
+ if self.get_active_state() == States.SPOTIFY_SPEAKER:
696
+ if paused:
697
+ self._rgbled.set_alternate_colors(Color.WHITE, Color.BLUE, 1)
698
+ else:
699
+ self._rgbled.set_color(Color.BLUE)
700
+
701
+ def get_active_state(self) -> States:
702
+ return self._machine.get_active_state().id # type: ignore[no-any-return]
703
+
704
+ def wait_until_idle(self) -> None:
705
+ """Block until all queued events have been processed (tests)."""
706
+ self._machine.wait_until_idle()
707
+
708
+ def recheck_tag(self) -> None:
709
+ """Evaluate the present tag again - e.g. after it has been linked to another album."""
710
+ with self._rfid_handler_lock:
711
+ if self._current_uid is not None:
712
+ self._logger.info("recheck_tag - %s", self._current_uid)
713
+ self._machine.asyncoperate(Transitions.NEW_TAG)
714
+
715
+ def _forced_polling(self) -> None:
716
+ while not self._rfid_received.wait(self._poll_interval):
717
+ self._request_rfid_state()
718
+
719
+ def _request_rfid_state(self) -> None:
720
+ self._logger.info("requesting the current rfid state")
721
+ self._pubsub_client.publish(RFID_POLL, None)
722
+
723
+ def _rfid_handler(self, message: object) -> None:
724
+ self._rfid_received.set()
725
+ self._logger.info("_rfid_handler - received message %r", message)
726
+ if not isinstance(message, RfidState):
727
+ self._logger.error("_rfid_handler - cannot process message %r: no RfidState", message)
728
+ return
729
+ uid = message.uid
730
+ with self._rfid_handler_lock:
731
+ if uid is None:
732
+ if self._current_uid is not None:
733
+ self._logger.info("_rfid_handler - tag removed.")
734
+ self._note("end", uid=self._current_uid, reason="tag removed")
735
+ self._current_uid = None
736
+ self._machine.asyncoperate(Transitions.NO_TAG)
737
+ elif self._current_uid != uid:
738
+ self._logger.info("_rfid_handler - activating uid %s", uid)
739
+ if self._current_uid is not None:
740
+ self._note("end", uid=self._current_uid, reason="tag replaced")
741
+ self._note("tag", uid=uid, **self._linked_to(uid))
742
+ self._current_uid = uid
743
+ self._latency.begin(self._pubsub_client.published_at())
744
+ self._latency.step("NEW_TAG")
745
+ self._machine.asyncoperate(Transitions.NEW_TAG)
746
+ else:
747
+ self._logger.info("_rfid_handler - ignored uid (%s) - this one is already active.", uid)
748
+
749
+ def start(self) -> None:
750
+ self._machine.start()
751
+ if self._sync is not None:
752
+ self._sync.files.subscribe(self._file_arrived)
753
+ self._pubsub_client.subscribe(RFID_STATE, self._rfid_handler)
754
+ self._rfid_received.clear()
755
+ self._request_rfid_state()
756
+ self._polling_thread = threading.Thread(target=self._forced_polling, name="STMOperation.polling", daemon=True)
757
+ self._polling_thread.start()
758
+
759
+ def stop(self) -> None:
760
+ self._rfid_received.set() # ends the forced polling
761
+ if self._polling_thread is not None:
762
+ self._polling_thread.join()
763
+ self._polling_thread = None
764
+ self._pubsub_client.unsubscribe(RFID_STATE, self._rfid_handler, ignore_not_found=True)
765
+ if self._sync is not None:
766
+ self._sync.files.unsubscribe(self._file_arrived)
767
+ if self._machine._startdone:
768
+ self._machine.operate(Transitions.END)
769
+ self._machine.stop()