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