IS-Matrix-Forge 1.0.0.dev1__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.
- is_matrix_forge/__init__.py +8 -0
- is_matrix_forge/assets/__init__.py +8 -0
- is_matrix_forge/assets/audio/__init__.py +36 -0
- is_matrix_forge/assets/audio/files/__init__.py +53 -0
- is_matrix_forge/assets/audio/files/plugged.wav +0 -0
- is_matrix_forge/assets/audio/files/unplugged.wav +0 -0
- is_matrix_forge/assets/char_map.json +2150 -0
- is_matrix_forge/assets/font_map.py +119 -0
- is_matrix_forge/common/__init__.py +0 -0
- is_matrix_forge/common/decorators/__init__.py +152 -0
- is_matrix_forge/common/dirs.py +20 -0
- is_matrix_forge/common/helpers/__init__.py +132 -0
- is_matrix_forge/common/helpers/github_api.py +67 -0
- is_matrix_forge/designer_gui/__init__.py +8 -0
- is_matrix_forge/designer_gui/main_window/__init__.py +252 -0
- is_matrix_forge/designer_gui/main_window/layout.py +117 -0
- is_matrix_forge/dev_tools/__init__.py +8 -0
- is_matrix_forge/dev_tools/debug.py +82 -0
- is_matrix_forge/dev_tools/presets.py +47 -0
- is_matrix_forge/inputmodule/__init__.py +164 -0
- is_matrix_forge/inputmodule/b1display.py +160 -0
- is_matrix_forge/inputmodule/c1minimal.py +35 -0
- is_matrix_forge/inputmodule/font.py +2166 -0
- is_matrix_forge/inputmodule/helpers/__init__.py +16 -0
- is_matrix_forge/inputmodule/ledmatrix.py +516 -0
- is_matrix_forge/led_matrix/Scripts/__init__.py +4 -0
- is_matrix_forge/led_matrix/Scripts/battery_monitor.py +152 -0
- is_matrix_forge/led_matrix/Scripts/install_presets/__init__.py +2 -0
- is_matrix_forge/led_matrix/Scripts/install_presets/arguments.py +84 -0
- is_matrix_forge/led_matrix/Scripts/install_presets/main.py +192 -0
- is_matrix_forge/led_matrix/__init__.py +7 -0
- is_matrix_forge/led_matrix/commands/__init__.py +84 -0
- is_matrix_forge/led_matrix/commands/map.py +46 -0
- is_matrix_forge/led_matrix/constants.py +82 -0
- is_matrix_forge/led_matrix/controller/__init__.py +26 -0
- is_matrix_forge/led_matrix/controller/controller.py +635 -0
- is_matrix_forge/led_matrix/controller/helpers/__init__.py +7 -0
- is_matrix_forge/led_matrix/controller/helpers/threading.py +41 -0
- is_matrix_forge/led_matrix/controller/multiton.py +32 -0
- is_matrix_forge/led_matrix/controller/threaded.py +72 -0
- is_matrix_forge/led_matrix/display/__init__.py +13 -0
- is_matrix_forge/led_matrix/display/animations/__init__.py +29 -0
- is_matrix_forge/led_matrix/display/animations/animation.py +593 -0
- is_matrix_forge/led_matrix/display/animations/effects/__init__.py +17 -0
- is_matrix_forge/led_matrix/display/animations/errors.py +23 -0
- is_matrix_forge/led_matrix/display/animations/font/__init__.py +17 -0
- is_matrix_forge/led_matrix/display/animations/font/font.py +4 -0
- is_matrix_forge/led_matrix/display/animations/font/map_builder.py +123 -0
- is_matrix_forge/led_matrix/display/animations/frame/__init__.py +47 -0
- is_matrix_forge/led_matrix/display/animations/frame/base.py +245 -0
- is_matrix_forge/led_matrix/display/animations/frame/helpers.py +67 -0
- is_matrix_forge/led_matrix/display/animations/keep_awake.py +1 -0
- is_matrix_forge/led_matrix/display/animations/plugged.py +0 -0
- is_matrix_forge/led_matrix/display/animations/text_scroller.py +104 -0
- is_matrix_forge/led_matrix/display/effects/__init__.py +17 -0
- is_matrix_forge/led_matrix/display/effects/breather.py +206 -0
- is_matrix_forge/led_matrix/display/effects/fade.py +0 -0
- is_matrix_forge/led_matrix/display/effects/scroller/__init__.py +92 -0
- is_matrix_forge/led_matrix/display/effects/scroller/base.py +76 -0
- is_matrix_forge/led_matrix/display/effects/scroller/helpers.py +62 -0
- is_matrix_forge/led_matrix/display/grid/__init__.py +9 -0
- is_matrix_forge/led_matrix/display/grid/grid.py +208 -0
- is_matrix_forge/led_matrix/display/grid/helpers.py +130 -0
- is_matrix_forge/led_matrix/display/grid/presets/__init__.py +64 -0
- is_matrix_forge/led_matrix/display/grid/presets/manifest.py +172 -0
- is_matrix_forge/led_matrix/display/helpers/__init__.py +110 -0
- is_matrix_forge/led_matrix/display/helpers/columns.py +33 -0
- is_matrix_forge/led_matrix/display/media.py +199 -0
- is_matrix_forge/led_matrix/display/patterns/__init__.py +59 -0
- is_matrix_forge/led_matrix/display/patterns/built_in/__init__.py +91 -0
- is_matrix_forge/led_matrix/display/patterns/built_in/commands.py +3 -0
- is_matrix_forge/led_matrix/display/patterns/built_in/constants.py +29 -0
- is_matrix_forge/led_matrix/display/patterns/built_in/stencils.py +70 -0
- is_matrix_forge/led_matrix/display/patterns/utils.py +2 -0
- is_matrix_forge/led_matrix/display/text/__init__.py +66 -0
- is_matrix_forge/led_matrix/display/text/scroller.py +136 -0
- is_matrix_forge/led_matrix/errors/__init__.py +4 -0
- is_matrix_forge/led_matrix/errors/base.py +14 -0
- is_matrix_forge/led_matrix/errors/grid.py +37 -0
- is_matrix_forge/led_matrix/errors/matrix.py +68 -0
- is_matrix_forge/led_matrix/errors/misc.py +36 -0
- is_matrix_forge/led_matrix/fonts.py +20 -0
- is_matrix_forge/led_matrix/hardware.py +172 -0
- is_matrix_forge/led_matrix/helpers/__init__.py +165 -0
- is_matrix_forge/led_matrix/helpers/core/__init__.py +17 -0
- is_matrix_forge/led_matrix/helpers/core/lazy_mod_load.py +84 -0
- is_matrix_forge/led_matrix/helpers/device.py +118 -0
- is_matrix_forge/led_matrix/helpers/status_handler.py +14 -0
- is_matrix_forge/led_matrix/helpers/stream/__init__.py +70 -0
- is_matrix_forge/led_matrix/helpers/stream/errors.py +17 -0
- is_matrix_forge/led_matrix/led_matrix.py +25 -0
- is_matrix_forge/led_matrix/status.py +8 -0
- is_matrix_forge/led_matrix/tools/__init__.py +8 -0
- is_matrix_forge/led_matrix/tools/identify.py +27 -0
- is_matrix_forge/led_matrix/utils/__init__.py +41 -0
- is_matrix_forge/log_engine.py +25 -0
- is_matrix_forge/monitor/__init__.py +68 -0
- is_matrix_forge/monitor/errors.py +35 -0
- is_matrix_forge/monitor/events.py +78 -0
- is_matrix_forge/monitor/gui/__init__.py +0 -0
- is_matrix_forge/monitor/gui/errors.py +35 -0
- is_matrix_forge/monitor/gui/event/__init__.py +8 -0
- is_matrix_forge/monitor/gui/event/collection.py +153 -0
- is_matrix_forge/monitor/gui/event/errors.py +58 -0
- is_matrix_forge/monitor/gui/event/event.py +226 -0
- is_matrix_forge/monitor/gui/layout/__init__.py +8 -0
- is_matrix_forge/monitor/gui/layout/base.py +147 -0
- is_matrix_forge/monitor/gui/metaclasses/__init__.py +20 -0
- is_matrix_forge/monitor/gui/windows/__init__.py +0 -0
- is_matrix_forge/monitor/gui/windows/base.py +325 -0
- is_matrix_forge/monitor/gui/windows/main/__init__.py +77 -0
- is_matrix_forge/monitor/gui/windows/main/layout.py +21 -0
- is_matrix_forge/monitor/helpers.py +55 -0
- is_matrix_forge/monitor/monitor.py +431 -0
- is_matrix_forge/notify/__init__.py +0 -0
- is_matrix_forge/notify/sounds/__init__.py +6 -0
- is_matrix_forge/notify/sounds/base.py +106 -0
- is_matrix_forge/progress.py +99 -0
- is_matrix_forge-1.0.0.dev1.dist-info/LICENSE.md +19 -0
- is_matrix_forge-1.0.0.dev1.dist-info/METADATA +190 -0
- is_matrix_forge-1.0.0.dev1.dist-info/RECORD +123 -0
- is_matrix_forge-1.0.0.dev1.dist-info/WHEEL +4 -0
- is_matrix_forge-1.0.0.dev1.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from typing import List, Dict, Optional, Union, Any
|
|
3
|
+
from time import sleep # Potentially used by Frame.play()
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from serial.tools.list_ports_common import ListPortInfo
|
|
8
|
+
|
|
9
|
+
from is_matrix_forge.led_matrix import LEDMatrixController
|
|
10
|
+
from is_matrix_forge.led_matrix.display.animations.frame.base import Frame
|
|
11
|
+
from is_matrix_forge.led_matrix.helpers import get_json_from_file
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Animation:
|
|
15
|
+
"""
|
|
16
|
+
Represents a sequence of Frames with playback controls.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
frame_data: Optional[List[Dict[str, Any]]] = None,
|
|
22
|
+
fallback_frame_duration: float = 0.33,
|
|
23
|
+
loop: bool = False,
|
|
24
|
+
thread_safe: bool = False,
|
|
25
|
+
breathe_on_pause: bool = False,
|
|
26
|
+
devices: Optional[List[ListPortInfo]] = None
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Initialize a new Animation instance.
|
|
30
|
+
|
|
31
|
+
Parameters:
|
|
32
|
+
frame_data: list of dict or Frame to seed the animation.
|
|
33
|
+
fallback_frame_duration: default duration for frames.
|
|
34
|
+
loop: whether to loop at end.
|
|
35
|
+
thread_safe: run in a separate thread if True.
|
|
36
|
+
breathe_on_pause: breathe effect when paused.
|
|
37
|
+
devices: list of ListPortInfo LED devices.
|
|
38
|
+
"""
|
|
39
|
+
# 1) set all attributes to defaults
|
|
40
|
+
self.__lock = None
|
|
41
|
+
self.__thread_lock = None
|
|
42
|
+
self.__pause_event = None
|
|
43
|
+
self.__breathing_thread = None
|
|
44
|
+
self.__making_thread_safe = False
|
|
45
|
+
self.__breathe_on_pause = False
|
|
46
|
+
self.__thread_safe = False
|
|
47
|
+
self.__cursor = 0
|
|
48
|
+
self.__playing = False
|
|
49
|
+
self.__frames: List[Frame] = []
|
|
50
|
+
self.__devices: List[ListPortInfo] = []
|
|
51
|
+
|
|
52
|
+
# 2) configure devices & threading
|
|
53
|
+
self._configure_devices(devices, thread_safe, breathe_on_pause)
|
|
54
|
+
|
|
55
|
+
# 3) apply high-level settings
|
|
56
|
+
self.fallback_frame_duration = fallback_frame_duration
|
|
57
|
+
self.loop = loop
|
|
58
|
+
|
|
59
|
+
# 4) load any provided frames
|
|
60
|
+
if frame_data:
|
|
61
|
+
self._load_frames(frame_data)
|
|
62
|
+
|
|
63
|
+
# 5) reset cursor into valid range
|
|
64
|
+
self._reset_cursor()
|
|
65
|
+
|
|
66
|
+
self.breathe_on_pause = breathe_on_pause
|
|
67
|
+
|
|
68
|
+
def _configure_devices(
|
|
69
|
+
self,
|
|
70
|
+
devices: Optional[List[ListPortInfo]],
|
|
71
|
+
thread_safe: bool,
|
|
72
|
+
breathe_on_pause: bool
|
|
73
|
+
) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Assign devices list, and if requested, make this animation thread-safe.
|
|
76
|
+
"""
|
|
77
|
+
if devices:
|
|
78
|
+
self.devices = devices
|
|
79
|
+
|
|
80
|
+
if thread_safe:
|
|
81
|
+
# note: property setter for breathe_on_pause handles validation
|
|
82
|
+
self.breathe_on_pause = breathe_on_pause
|
|
83
|
+
self.make_thread_safe()
|
|
84
|
+
|
|
85
|
+
def _load_frames(self, frame_data: List[Union[Dict[str, Any], Frame]]) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Normalize incoming frame_data (dicts or Frame instances) into self.__frames.
|
|
88
|
+
"""
|
|
89
|
+
# detect if they already handed us Frame objects
|
|
90
|
+
first = frame_data[0]
|
|
91
|
+
if isinstance(first, Frame):
|
|
92
|
+
print('received list of frames')
|
|
93
|
+
self.__frames = list(frame_data) # shallow copy
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
# otherwise expect a list of dicts
|
|
97
|
+
for f_dict in frame_data:
|
|
98
|
+
frame_obj = Frame.from_dict(f_dict)
|
|
99
|
+
|
|
100
|
+
# if they left duration at DEFAULT, apply our fallback
|
|
101
|
+
if frame_obj.duration == Frame.DEFAULT_DURATION:
|
|
102
|
+
frame_obj.duration = self.fallback_frame_duration
|
|
103
|
+
|
|
104
|
+
self.__frames.append(frame_obj)
|
|
105
|
+
|
|
106
|
+
def _reset_cursor(self) -> None:
|
|
107
|
+
"""Ensure cursor is valid (zero if no frames, else within range)."""
|
|
108
|
+
if not self.__frames:
|
|
109
|
+
self.__cursor = 0
|
|
110
|
+
else:
|
|
111
|
+
self.__cursor %= len(self.__frames)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def breathe_on_pause(self) -> bool:
|
|
115
|
+
"""Get whether the animation breathes on pause."""
|
|
116
|
+
return self.__breathe_on_pause
|
|
117
|
+
|
|
118
|
+
@breathe_on_pause.setter
|
|
119
|
+
def breathe_on_pause(self, new_value: bool) -> None:
|
|
120
|
+
if not isinstance(new_value, bool):
|
|
121
|
+
raise TypeError("Breathe on pause must be a boolean value.")
|
|
122
|
+
self.__breathe_on_pause = new_value
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def cursor(self) -> int:
|
|
126
|
+
"""Get the current position (index) in the animation sequence."""
|
|
127
|
+
return self.__cursor
|
|
128
|
+
|
|
129
|
+
@cursor.setter
|
|
130
|
+
def cursor(self, new_value: int) -> None:
|
|
131
|
+
"""
|
|
132
|
+
Set the current position in the animation sequence.
|
|
133
|
+
|
|
134
|
+
Parameters:
|
|
135
|
+
new_value (int):
|
|
136
|
+
The new cursor position.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
TypeError:
|
|
140
|
+
If the new value is not an integer.
|
|
141
|
+
|
|
142
|
+
ValueError:
|
|
143
|
+
If trying to set `cursor` on an animation with no frames (unless new_value is 0).
|
|
144
|
+
|
|
145
|
+
IndexError:
|
|
146
|
+
If the new value is out of bounds for existing frames.
|
|
147
|
+
"""
|
|
148
|
+
if not isinstance(new_value, int):
|
|
149
|
+
raise TypeError("Cursor must be an integer.")
|
|
150
|
+
|
|
151
|
+
if not self.__frames:
|
|
152
|
+
if new_value == 0:
|
|
153
|
+
self.__cursor = 0 # Allow setting to 0 if no frames
|
|
154
|
+
return
|
|
155
|
+
raise ValueError("Cannot set cursor on an animation with no frames.")
|
|
156
|
+
|
|
157
|
+
if not (0 <= new_value < len(self.__frames)):
|
|
158
|
+
raise IndexError(f"Cursor out of bounds. Must be between 0 and {len(self.__frames) - 1}.")
|
|
159
|
+
|
|
160
|
+
self.__cursor = new_value
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def devices(self) -> List[Any]:
|
|
164
|
+
"""Get the list of devices used by the animation."""
|
|
165
|
+
return self.__devices
|
|
166
|
+
|
|
167
|
+
@devices.setter
|
|
168
|
+
def devices(self, value: Union[Any, List[Any]]):
|
|
169
|
+
"""
|
|
170
|
+
Set the devices used by the animation.
|
|
171
|
+
|
|
172
|
+
Parameters:
|
|
173
|
+
value (Union[Any, List[Any]]): A single ListPortInfo or a list of them.
|
|
174
|
+
"""
|
|
175
|
+
self.__devices = value if isinstance(value, list) else [value]
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def fallback_frame_duration(self) -> float:
|
|
179
|
+
"""Get the default duration for frames that don't specify one."""
|
|
180
|
+
return self.__fallback_frame_duration
|
|
181
|
+
|
|
182
|
+
@fallback_frame_duration.setter
|
|
183
|
+
def fallback_frame_duration(self, new_value: Union[float, int]) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Set the default duration for frames that don't specify one.
|
|
186
|
+
This duration is applied to new frames if they don't have their own.
|
|
187
|
+
It does not retroactively change durations of existing frames that
|
|
188
|
+
were already assigned a duration (either specific or a previous fallback).
|
|
189
|
+
Use `set_all_frame_durations()` for that purpose.
|
|
190
|
+
"""
|
|
191
|
+
if not isinstance(new_value, (float, int)):
|
|
192
|
+
raise TypeError("Fallback frame duration must be a float or integer.")
|
|
193
|
+
if new_value < 0:
|
|
194
|
+
raise ValueError("Fallback frame duration must be non-negative.")
|
|
195
|
+
self.__fallback_frame_duration = float(new_value)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def frames(self) -> List[Frame]:
|
|
199
|
+
"""Get the list of frames that make up the animation."""
|
|
200
|
+
return self.__frames # Returns a reference; could return a copy if immutability is desired
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def loop(self) -> bool:
|
|
204
|
+
"""Get whether the animation should loop when it reaches the end."""
|
|
205
|
+
return self.__loop
|
|
206
|
+
|
|
207
|
+
@loop.setter
|
|
208
|
+
def loop(self, new_value: bool) -> None:
|
|
209
|
+
"""Set whether the animation should loop."""
|
|
210
|
+
if not isinstance(new_value, bool):
|
|
211
|
+
raise TypeError("Loop must be a boolean value.")
|
|
212
|
+
self.__loop = new_value
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def is_empty(self) -> bool:
|
|
216
|
+
"""Check if the animation has any frames."""
|
|
217
|
+
return not self.__frames
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def is_playing(self) -> bool:
|
|
221
|
+
"""Check if the animation is currently playing."""
|
|
222
|
+
return self.__playing
|
|
223
|
+
|
|
224
|
+
@is_playing.setter
|
|
225
|
+
def is_playing(self, new: bool) -> None:
|
|
226
|
+
"""
|
|
227
|
+
Set the playing state of the animation.
|
|
228
|
+
|
|
229
|
+
Parameters:
|
|
230
|
+
new (bool):
|
|
231
|
+
The new playing state.
|
|
232
|
+
"""
|
|
233
|
+
if not isinstance(new, bool):
|
|
234
|
+
raise TypeError("Playing state must be a boolean value.")
|
|
235
|
+
self.__playing = new
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def is_thread_safe(self) -> bool:
|
|
239
|
+
"""Check if the animation is thread-safe."""
|
|
240
|
+
return self.__thread_safe
|
|
241
|
+
|
|
242
|
+
@is_thread_safe.setter
|
|
243
|
+
def is_thread_safe(self, new: bool) -> None:
|
|
244
|
+
"""
|
|
245
|
+
Set the thread safety of the animation.
|
|
246
|
+
|
|
247
|
+
Parameters:
|
|
248
|
+
new (bool):
|
|
249
|
+
The new thread safety state.
|
|
250
|
+
"""
|
|
251
|
+
if not isinstance(new, bool):
|
|
252
|
+
raise TypeError("Thread safety must be a boolean value.")
|
|
253
|
+
|
|
254
|
+
if new and not self.__thread_safe and not self.__making_thread_safe:
|
|
255
|
+
self.make_thread_safe()
|
|
256
|
+
|
|
257
|
+
self.__thread_safe = new
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def thread_lock(self):
|
|
261
|
+
if not self.is_thread_safe:
|
|
262
|
+
raise RuntimeError("Thread lock is not available in non-thread-safe mode.")
|
|
263
|
+
|
|
264
|
+
return self.__thread_lock
|
|
265
|
+
|
|
266
|
+
def __check_ready(self) -> bool:
|
|
267
|
+
if self.is_empty:
|
|
268
|
+
raise ValueError("Cannot play an animation with no frames.")
|
|
269
|
+
|
|
270
|
+
if not self.__cursor:
|
|
271
|
+
self.__cursor = 0
|
|
272
|
+
|
|
273
|
+
def __clear_screen(self, devices: Optional[List] = None):
|
|
274
|
+
devices = devices if devices is not None else self.devices
|
|
275
|
+
|
|
276
|
+
for dev in devices:
|
|
277
|
+
dev.clear()
|
|
278
|
+
|
|
279
|
+
def __normalize_devices(self, devices):
|
|
280
|
+
|
|
281
|
+
if devices is None:
|
|
282
|
+
if self.devices is None:
|
|
283
|
+
raise ValueError("You need to provide a device!")
|
|
284
|
+
else:
|
|
285
|
+
devices = self.devices
|
|
286
|
+
|
|
287
|
+
if not isinstance(devices, list):
|
|
288
|
+
devices = [devices]
|
|
289
|
+
|
|
290
|
+
normalized = []
|
|
291
|
+
|
|
292
|
+
for device in devices:
|
|
293
|
+
if isinstance(device, LEDMatrixController):
|
|
294
|
+
normalized.append(device)
|
|
295
|
+
elif isinstance(device, ListPortInfo):
|
|
296
|
+
normalized.append(LEDMatrixController(device))
|
|
297
|
+
else:
|
|
298
|
+
raise TypeError("Devices must be either LEDMatrixController or ListPortInfo objects.")
|
|
299
|
+
|
|
300
|
+
return normalized
|
|
301
|
+
|
|
302
|
+
def make_thread_safe(self, breathe_on_pause: bool = False) -> None:
|
|
303
|
+
if not self.is_thread_safe:
|
|
304
|
+
self.__making_thread_safe = True
|
|
305
|
+
self.__thread_lock = threading.Lock()
|
|
306
|
+
self.__pause_event = threading.Event()
|
|
307
|
+
self.__breathe_on_pause = breathe_on_pause
|
|
308
|
+
self.is_thread_safe = True
|
|
309
|
+
|
|
310
|
+
def pause(self, keep_frame_displayed: bool = True) -> None:
|
|
311
|
+
"""Pauses animation playback and keeps the current frame displayed."""
|
|
312
|
+
if not self.is_thread_safe:
|
|
313
|
+
raise RuntimeError("pause() requires thread-safe mode to be enabled.")
|
|
314
|
+
|
|
315
|
+
with self.thread_lock:
|
|
316
|
+
self.__playing = False
|
|
317
|
+
self.__pause_event.clear()
|
|
318
|
+
|
|
319
|
+
if keep_frame_displayed and not self.__breathing_thread:
|
|
320
|
+
self.__breathing_thread = threading.Thread(
|
|
321
|
+
target=self._paused_display_loop,
|
|
322
|
+
daemon=True
|
|
323
|
+
)
|
|
324
|
+
self.__breathing_thread.start()
|
|
325
|
+
|
|
326
|
+
def play(self, devices: Optional[List[LEDMatrixController]] = None, skip_clear_screen: bool = False) -> None:
|
|
327
|
+
"""
|
|
328
|
+
Play the animation on the LED matrix.
|
|
329
|
+
|
|
330
|
+
Plays all frames, starting from the current cursor position.
|
|
331
|
+
If `loop` is True, this animation repeats indefinitely.
|
|
332
|
+
|
|
333
|
+
Parameters:
|
|
334
|
+
devices (Optional[List[LEDMatrixController]]):
|
|
335
|
+
Device to play on (passed to `Frame.play()`).
|
|
336
|
+
|
|
337
|
+
skip_clear_screen (Optional[bool]):
|
|
338
|
+
Skip clearing screen before playing.
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
ValueError:
|
|
342
|
+
If the animation has no frames.
|
|
343
|
+
"""
|
|
344
|
+
self.__check_ready()
|
|
345
|
+
devices = self.__normalize_devices(devices)
|
|
346
|
+
|
|
347
|
+
# The loop below will handle cursor advancement.
|
|
348
|
+
self.is_playing = True
|
|
349
|
+
while self.is_playing:
|
|
350
|
+
# Iterate from current cursor to the end of frames
|
|
351
|
+
for i in range(self.__cursor, len(self.__frames)):
|
|
352
|
+
try:
|
|
353
|
+
self.play_frame(i, devices) # Frame.play() is responsible for its own duration (e.g., sleep)
|
|
354
|
+
except KeyboardInterrupt:
|
|
355
|
+
self.is_playing = False
|
|
356
|
+
print('Received keyboard interrupt!')
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
if self.__loop:
|
|
360
|
+
self.rewind()
|
|
361
|
+
else:
|
|
362
|
+
# If not looping, we've played to the end from the initial cursor.
|
|
363
|
+
self.is_playing = False # Stop the while loop
|
|
364
|
+
|
|
365
|
+
if not skip_clear_screen:
|
|
366
|
+
self.__clear_screen(devices)
|
|
367
|
+
|
|
368
|
+
def play_frame(self, frame_index: Optional[int] = None, devices: Any = None) -> None:
|
|
369
|
+
"""
|
|
370
|
+
Play a single frame of the animation.
|
|
371
|
+
|
|
372
|
+
Parameters:
|
|
373
|
+
frame_index (Optional[int]):
|
|
374
|
+
Index of the frame to play.
|
|
375
|
+
If None, play the current frame.
|
|
376
|
+
|
|
377
|
+
devices (Any, optional):
|
|
378
|
+
Devices to play on.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
ValueError:
|
|
382
|
+
If the animation has no frames.
|
|
383
|
+
|
|
384
|
+
IndexError:
|
|
385
|
+
If frame_index is out of bounds.
|
|
386
|
+
"""
|
|
387
|
+
if self.is_empty:
|
|
388
|
+
raise ValueError("Cannot play a frame from an animation with no frames.")
|
|
389
|
+
|
|
390
|
+
if frame_index is not None:
|
|
391
|
+
if not (0 <= frame_index < len(self.__frames)):
|
|
392
|
+
raise IndexError(f"Frame index {frame_index} out of bounds (0-{len(self.__frames) - 1}).")
|
|
393
|
+
self.__cursor = frame_index
|
|
394
|
+
|
|
395
|
+
# If frame_index was None, self.cursor is already the current frame.
|
|
396
|
+
# If frame_index was valid, self.cursor is now updated.
|
|
397
|
+
try:
|
|
398
|
+
for device in devices:
|
|
399
|
+
self.frames[self.cursor].play(device)
|
|
400
|
+
|
|
401
|
+
except KeyboardInterrupt:
|
|
402
|
+
self.is_playing = False
|
|
403
|
+
print('Received keyboard interrupt!')
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def resume(self) -> None:
|
|
408
|
+
"""Resumes playback from paused state."""
|
|
409
|
+
if not self.is_thread_safe:
|
|
410
|
+
raise RuntimeError("resume() requires thread-safe mode to be enabled.")
|
|
411
|
+
|
|
412
|
+
with self.thread_lock:
|
|
413
|
+
self.__playing = True
|
|
414
|
+
self.__pause_event.set()
|
|
415
|
+
|
|
416
|
+
if self.__breathing_thread and self.__breathing_thread.is_alive():
|
|
417
|
+
self.__breathing_thread.join(timeout=0.1)
|
|
418
|
+
self.__breathing_thread = None
|
|
419
|
+
|
|
420
|
+
def rewind(self, pos=None):
|
|
421
|
+
if pos:
|
|
422
|
+
if not isinstance(pos, int):
|
|
423
|
+
raise TypeError('"pos" must be an integer!')
|
|
424
|
+
|
|
425
|
+
if pos not in range(len(self.frames) + 1):
|
|
426
|
+
raise ValueError('Frame out of range')
|
|
427
|
+
|
|
428
|
+
if pos > self.cursor:
|
|
429
|
+
raise ValueError('Maybe you meant "fast_forward"... Cursor target position greater than current...')
|
|
430
|
+
|
|
431
|
+
self.cursor = pos if pos is not None else 0
|
|
432
|
+
|
|
433
|
+
def _advance_cursor(self, step: int) -> bool:
|
|
434
|
+
"""
|
|
435
|
+
Internal helper to advance or rewind the cursor.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
- :bool:`True` if `Animation.cursor` changed and is valid, False otherwise (e.g., at the end and not looping).
|
|
439
|
+
"""
|
|
440
|
+
if self.is_empty:
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
new_cursor = self.__cursor + step
|
|
444
|
+
|
|
445
|
+
if 0 <= new_cursor < len(self.__frames):
|
|
446
|
+
self.__cursor = new_cursor
|
|
447
|
+
return True
|
|
448
|
+
elif self.__loop:
|
|
449
|
+
if new_cursor >= len(self.__frames):
|
|
450
|
+
self.__cursor = 0 # Wrap to beginning
|
|
451
|
+
else:
|
|
452
|
+
self.__cursor = len(self.__frames) - 1 # Wrap to end
|
|
453
|
+
return True
|
|
454
|
+
else:
|
|
455
|
+
# Not looping and new_cursor is out of bounds
|
|
456
|
+
return False
|
|
457
|
+
|
|
458
|
+
def _paused_display_loop(self):
|
|
459
|
+
"""Keeps redrawing the current frame while paused. Adds breathing effect if enabled."""
|
|
460
|
+
breath_phase = 0.0
|
|
461
|
+
while not self.__pause_event.is_set():
|
|
462
|
+
current_frame = self.__frames[self.cursor]
|
|
463
|
+
for device in self.devices:
|
|
464
|
+
current_frame.play(device)
|
|
465
|
+
if self.breathe_on_pause:
|
|
466
|
+
# Placeholder: replace with real brightness method for your device
|
|
467
|
+
brightness = 0.5 + 0.5 * abs((time.time() % 2) - 1) # triangle wave
|
|
468
|
+
device.set_brightness(brightness)
|
|
469
|
+
sleep(1) # re-draw interval
|
|
470
|
+
|
|
471
|
+
def next_frame(self, device: Any = None) -> None:
|
|
472
|
+
"""
|
|
473
|
+
Advance to and play the next frame. Wraps if looping.
|
|
474
|
+
|
|
475
|
+
Parameters:
|
|
476
|
+
device (Any, optional): Device to play on.
|
|
477
|
+
Raises:
|
|
478
|
+
ValueError: If the animation is empty.
|
|
479
|
+
"""
|
|
480
|
+
if self.is_empty:
|
|
481
|
+
raise ValueError("Cannot play next frame: animation is empty.")
|
|
482
|
+
if self._advance_cursor(1):
|
|
483
|
+
self.__frames[self.__cursor].play(device)
|
|
484
|
+
|
|
485
|
+
def previous_frame(self, device: Any = None) -> None:
|
|
486
|
+
"""
|
|
487
|
+
Move to and play the previous frame. Wraps if looping.
|
|
488
|
+
|
|
489
|
+
Parameters:
|
|
490
|
+
device (Any, optional): Device to play on.
|
|
491
|
+
Raises:
|
|
492
|
+
ValueError: If the animation is empty.
|
|
493
|
+
"""
|
|
494
|
+
if self.is_empty:
|
|
495
|
+
raise ValueError("Cannot play previous frame: animation is empty.")
|
|
496
|
+
if self._advance_cursor(-1):
|
|
497
|
+
self.__frames[self.__cursor].play(device)
|
|
498
|
+
|
|
499
|
+
def set_all_frame_durations(self, duration: Union[float, int]) -> None:
|
|
500
|
+
"""
|
|
501
|
+
Set the duration for all frames in the animation.
|
|
502
|
+
|
|
503
|
+
Parameters:
|
|
504
|
+
duration (Union[float, int]):
|
|
505
|
+
New duration for all frames in seconds.
|
|
506
|
+
|
|
507
|
+
Raises:
|
|
508
|
+
TypeError:
|
|
509
|
+
If duration is not float or int.
|
|
510
|
+
|
|
511
|
+
ValueError:
|
|
512
|
+
If duration is negative, or if animation is empty.
|
|
513
|
+
"""
|
|
514
|
+
if not isinstance(duration, (float, int)):
|
|
515
|
+
raise TypeError("Duration must be a float or integer.")
|
|
516
|
+
if duration < 0:
|
|
517
|
+
raise ValueError("Duration must be non-negative.")
|
|
518
|
+
|
|
519
|
+
if self.is_empty:
|
|
520
|
+
# Or just do nothing, depends on desired behavior
|
|
521
|
+
raise ValueError("Cannot set frame durations: animation is empty.")
|
|
522
|
+
|
|
523
|
+
for frame in self.__frames:
|
|
524
|
+
frame.duration = float(duration)
|
|
525
|
+
|
|
526
|
+
@classmethod
|
|
527
|
+
def from_file(
|
|
528
|
+
cls,
|
|
529
|
+
filename: Union[str, Path],
|
|
530
|
+
fallback_frame_duration: float = 0.33,
|
|
531
|
+
loop: bool = False
|
|
532
|
+
) -> 'Animation':
|
|
533
|
+
"""
|
|
534
|
+
Create an Animation instance from a JSON file.
|
|
535
|
+
|
|
536
|
+
The file should contain a JSON array. Each element can be:
|
|
537
|
+
1. A 2D grid (list of lists of 0s and 1s) - duration will be this method's
|
|
538
|
+
`fallback_frame_duration`.
|
|
539
|
+
2. A dictionary with a 'grid' key and an optional 'duration' key.
|
|
540
|
+
|
|
541
|
+
Parameters:
|
|
542
|
+
filename: Path to the JSON file.
|
|
543
|
+
fallback_frame_duration: Default duration for the Animation instance,
|
|
544
|
+
and also used for raw grids from the file.
|
|
545
|
+
loop: Whether the created animation should loop.
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
Animation: A new Animation instance.
|
|
549
|
+
|
|
550
|
+
Raises:
|
|
551
|
+
FileNotFoundError, IsADirectoryError: If file issues.
|
|
552
|
+
ValueError: If file content is not a valid JSON array or frame data is invalid.
|
|
553
|
+
"""
|
|
554
|
+
raw_data = get_json_from_file(filename) # Expected to raise FileNotFoundError etc.
|
|
555
|
+
|
|
556
|
+
if not isinstance(raw_data, list):
|
|
557
|
+
raise ValueError(f"File '{filename}' does not contain a valid JSON array of frames.")
|
|
558
|
+
|
|
559
|
+
processed_frame_data: List[Dict[str, Any]] = []
|
|
560
|
+
for i, item in enumerate(raw_data):
|
|
561
|
+
if isinstance(item, dict) and 'grid' in item:
|
|
562
|
+
# Item is already a frame dictionary (may or may not have 'duration')
|
|
563
|
+
processed_frame_data.append(item)
|
|
564
|
+
elif isinstance(item, list): # Assuming it's a raw grid
|
|
565
|
+
# Validate if it looks like a grid (list of lists)
|
|
566
|
+
# A more thorough validation would be inside Frame.from_dict or Grid class
|
|
567
|
+
if not all(isinstance(row, list) for row in item):
|
|
568
|
+
raise ValueError(
|
|
569
|
+
f"Invalid raw grid data at index {i} in file '{filename}'. Expected list of lists.")
|
|
570
|
+
|
|
571
|
+
# Convert raw grid to a frame dictionary.
|
|
572
|
+
# The 'duration' here will be used by Frame.from_dict if present.
|
|
573
|
+
# If Frame.from_dict finds 'duration', it uses it. If not, it uses Frame.DEFAULT_DURATION.
|
|
574
|
+
# Then Animation.__init__ applies its own fallback if it sees Frame.DEFAULT_DURATION.
|
|
575
|
+
# To ensure this fallback_frame_duration is used for raw grids:
|
|
576
|
+
processed_frame_data.append({
|
|
577
|
+
'grid': item,
|
|
578
|
+
'duration': fallback_frame_duration # Explicitly set duration for raw grids
|
|
579
|
+
})
|
|
580
|
+
else:
|
|
581
|
+
raise ValueError(
|
|
582
|
+
f"Invalid frame data type at index {i} in file '{filename}'. Expected dict or list (grid).")
|
|
583
|
+
|
|
584
|
+
# Pass the processed list of frame dictionaries and other parameters to __init__
|
|
585
|
+
return cls(
|
|
586
|
+
frame_data=processed_frame_data,
|
|
587
|
+
fallback_frame_duration=fallback_frame_duration,
|
|
588
|
+
loop=loop
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
def __len__(self) -> int:
|
|
592
|
+
"""Return the number of frames in the animation."""
|
|
593
|
+
return len(self.__frames)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from inspyre_toolbox.exceptional import CustomRootException
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GridDefinitionError(CustomRootException, ValueError):
|
|
5
|
+
default_message = 'An error has occurred within the grid definition!'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MalformedGridError(GridDefinitionError):
|
|
9
|
+
"""
|
|
10
|
+
Raised when the grid is malformed.
|
|
11
|
+
|
|
12
|
+
Class Attributes:
|
|
13
|
+
default_message (str):
|
|
14
|
+
**Read-Only**. The default message for the error.
|
|
15
|
+
"""
|
|
16
|
+
default_message = 'The grid is malformed!'
|
|
17
|
+
|
|
18
|
+
def __init__(self, more_info: str = None, **kwargs) -> None:
|
|
19
|
+
if more_info is not None:
|
|
20
|
+
self.default_message = f"{self.default_message}\n\n Additional information from caller:\n {more_info}"
|
|
21
|
+
|
|
22
|
+
super().__init__(message=self.default_message, **kwargs)
|
|
23
|
+
|