streamdeck-gui-ng 4.1.3__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 (62) hide show
  1. streamdeck_gui_ng-4.1.3.dist-info/METADATA +141 -0
  2. streamdeck_gui_ng-4.1.3.dist-info/RECORD +62 -0
  3. streamdeck_gui_ng-4.1.3.dist-info/WHEEL +4 -0
  4. streamdeck_gui_ng-4.1.3.dist-info/entry_points.txt +4 -0
  5. streamdeck_gui_ng-4.1.3.dist-info/licenses/LICENSE +21 -0
  6. streamdeck_ui/__init__.py +6 -0
  7. streamdeck_ui/api.py +712 -0
  8. streamdeck_ui/button.ui +1214 -0
  9. streamdeck_ui/cli/__init__.py +0 -0
  10. streamdeck_ui/cli/commands.py +191 -0
  11. streamdeck_ui/cli/server.py +292 -0
  12. streamdeck_ui/config.py +244 -0
  13. streamdeck_ui/dimmer.py +93 -0
  14. streamdeck_ui/display/__init__.py +0 -0
  15. streamdeck_ui/display/background_color_filter.py +41 -0
  16. streamdeck_ui/display/display_grid.py +265 -0
  17. streamdeck_ui/display/empty_filter.py +43 -0
  18. streamdeck_ui/display/filter.py +65 -0
  19. streamdeck_ui/display/image_filter.py +144 -0
  20. streamdeck_ui/display/keypress_filter.py +63 -0
  21. streamdeck_ui/display/pipeline.py +74 -0
  22. streamdeck_ui/display/pulse_filter.py +54 -0
  23. streamdeck_ui/display/text_filter.py +142 -0
  24. streamdeck_ui/fonts/roboto/LICENSE.txt +202 -0
  25. streamdeck_ui/fonts/roboto/Roboto-Black.ttf +0 -0
  26. streamdeck_ui/fonts/roboto/Roboto-BlackItalic.ttf +0 -0
  27. streamdeck_ui/fonts/roboto/Roboto-Bold.ttf +0 -0
  28. streamdeck_ui/fonts/roboto/Roboto-BoldItalic.ttf +0 -0
  29. streamdeck_ui/fonts/roboto/Roboto-Italic.ttf +0 -0
  30. streamdeck_ui/fonts/roboto/Roboto-Light.ttf +0 -0
  31. streamdeck_ui/fonts/roboto/Roboto-LightItalic.ttf +0 -0
  32. streamdeck_ui/fonts/roboto/Roboto-Medium.ttf +0 -0
  33. streamdeck_ui/fonts/roboto/Roboto-MediumItalic.ttf +0 -0
  34. streamdeck_ui/fonts/roboto/Roboto-Regular.ttf +0 -0
  35. streamdeck_ui/fonts/roboto/Roboto-Thin.ttf +0 -0
  36. streamdeck_ui/fonts/roboto/Roboto-ThinItalic.ttf +0 -0
  37. streamdeck_ui/gui.py +1423 -0
  38. streamdeck_ui/icons/add_page.png +0 -0
  39. streamdeck_ui/icons/cross.png +0 -0
  40. streamdeck_ui/icons/gear.png +0 -0
  41. streamdeck_ui/icons/horizontal-align.png +0 -0
  42. streamdeck_ui/icons/remove_page.png +0 -0
  43. streamdeck_ui/icons/vertical-align.png +0 -0
  44. streamdeck_ui/icons/warning_icon_button.png +0 -0
  45. streamdeck_ui/logger.py +11 -0
  46. streamdeck_ui/logo.png +0 -0
  47. streamdeck_ui/main.ui +407 -0
  48. streamdeck_ui/mock_streamdeck.py +204 -0
  49. streamdeck_ui/model.py +78 -0
  50. streamdeck_ui/modules/__init__.py +0 -0
  51. streamdeck_ui/modules/fonts.py +150 -0
  52. streamdeck_ui/modules/keyboard.py +447 -0
  53. streamdeck_ui/modules/utils/__init__.py +0 -0
  54. streamdeck_ui/modules/utils/timers.py +35 -0
  55. streamdeck_ui/resources.qrc +10 -0
  56. streamdeck_ui/resources_rc.py +324 -0
  57. streamdeck_ui/semaphore.py +38 -0
  58. streamdeck_ui/settings.ui +155 -0
  59. streamdeck_ui/stream_deck_monitor.py +157 -0
  60. streamdeck_ui/ui_button.py +421 -0
  61. streamdeck_ui/ui_main.py +267 -0
  62. streamdeck_ui/ui_settings.py +119 -0
@@ -0,0 +1,244 @@
1
+ """Defines shared configuration variables for the streamdeck_ui project"""
2
+
3
+ import json
4
+ import os
5
+ from typing import Dict
6
+
7
+ from streamdeck_ui.model import ButtonMultiState, ButtonState, DeckState, DeckStateV1
8
+
9
+ PROJECT_PATH = os.path.dirname(os.path.abspath(__file__))
10
+ APP_NAME = "StreamDeck UI"
11
+ APP_LOGO = os.path.join(PROJECT_PATH, "logo.png")
12
+ FONTS_PATH = os.path.join(PROJECT_PATH, "fonts", "roboto")
13
+ FONTS_FALLBACK_PATH = os.path.join(PROJECT_PATH, "fonts", "roboto")
14
+ DEFAULT_FONT = "Roboto-Regular.ttf"
15
+ DEFAULT_FONT_FALLBACK_PATH = os.path.join(FONTS_FALLBACK_PATH, DEFAULT_FONT)
16
+ DEFAULT_FONT_SIZE = 14
17
+ DEFAULT_FONT_COLOR = "#ffffff"
18
+ DEFAULT_BACKGROUND_COLOR = "#000000"
19
+ STATE_FILE = os.environ.get("STREAMDECK_UI_CONFIG", os.path.expanduser("~/.streamdeck_ui.json"))
20
+ LOG_FILE = os.environ.get("STREAMDECK_UI_LOG_FILE", os.path.expanduser("~/.streamdeck_ui.log"))
21
+ STATE_FILE_BACKUP = os.path.expanduser("~/.streamdeck_ui.json_old")
22
+ CONFIG_FILE_VERSION = 2
23
+ CONFIG_FILE_PREVIOUS_VERSION = 1
24
+ CONFIG_FILE_SUPPORTED_VERSIONS = [CONFIG_FILE_VERSION, CONFIG_FILE_PREVIOUS_VERSION]
25
+ WARNING_ICON = os.path.join(PROJECT_PATH, "icons", "warning_icon_button.png")
26
+
27
+
28
+ def config_file_need_migration(config_file_path: str) -> bool:
29
+ """Check if the config file need to be updated"""
30
+ if not os.path.isfile(config_file_path):
31
+ return False
32
+ with open(config_file_path, "r") as config_file:
33
+ config = json.load(config_file)
34
+ file_version = config.get("streamdeck_ui_version", CONFIG_FILE_VERSION)
35
+ return file_version != CONFIG_FILE_VERSION
36
+
37
+
38
+ def do_config_file_backup(config_file_path: str, backup_config_file_path: str) -> None:
39
+ """Make a copy of the config file"""
40
+ if os.path.isfile(config_file_path):
41
+ os.replace(config_file_path, backup_config_file_path)
42
+
43
+
44
+ def do_config_file_migration() -> None:
45
+ """Update the config file to the latest version"""
46
+ state = read_state_from_config(STATE_FILE)
47
+ do_config_file_backup(STATE_FILE, STATE_FILE_BACKUP)
48
+ write_state_to_config(STATE_FILE, state)
49
+
50
+
51
+ def read_state_from_config(config_file_path: str) -> Dict[str, DeckState]:
52
+ """Open the config file and return its content as a dict"""
53
+
54
+ with open(config_file_path, "r") as config_file:
55
+ config = json.load(config_file)
56
+ file_version = config.get("streamdeck_ui_version", 0)
57
+ if file_version not in CONFIG_FILE_SUPPORTED_VERSIONS:
58
+ raise ValueError(
59
+ f"Incompatible version of config file found: {file_version} does not match required version {CONFIG_FILE_VERSION}."
60
+ )
61
+ if file_version == CONFIG_FILE_PREVIOUS_VERSION:
62
+ return _migrate_deck_state_from_previous_version(config["state"])
63
+ state = _to_deck_states(config["state"])
64
+ validate_current_page(state)
65
+ validate_current_button_state(state)
66
+ return state
67
+
68
+
69
+ def validate_current_page(state: Dict[str, DeckState]) -> None:
70
+ """Validate that the current page is valid, if the current page is not valid, set it to the first page
71
+ of the deck"""
72
+ for _deck_id, deck_state in state.items():
73
+ if deck_state.page not in deck_state.buttons:
74
+ deck_state.page = next(iter(deck_state.buttons))
75
+
76
+
77
+ def validate_current_button_state(state: Dict[str, DeckState]) -> None:
78
+ """Validate that the current button state is valid, if the current button state is not valid, set it to the first state
79
+ of the button"""
80
+ for _deck_id, deck_state in state.items():
81
+ for _page_of_buttons_id, page_of_buttons_state in deck_state.buttons.items():
82
+ for _button_id, button_state in page_of_buttons_state.items():
83
+ if button_state.state not in button_state.states:
84
+ button_state.state = next(iter(button_state.states))
85
+
86
+
87
+ def write_state_to_config(config_file_path: str, state: Dict[str, DeckState]) -> None:
88
+ """Write the state to the config file"""
89
+ temp_file_path = config_file_path + ".tmp"
90
+ try:
91
+ with open(temp_file_path, "w") as config_file:
92
+ config = {
93
+ "state": _to_deck_config(state),
94
+ "streamdeck_ui_version": CONFIG_FILE_VERSION,
95
+ }
96
+ json.dump(config, config_file, indent=4)
97
+ except Exception as error:
98
+ raise ValueError(f"The configuration file '{config_file_path}' was not updated. Error: {error}")
99
+ else:
100
+ os.replace(temp_file_path, os.path.realpath(config_file_path))
101
+
102
+
103
+ def _to_deck_states(state: dict) -> Dict[str, DeckState]:
104
+ return {
105
+ deck_id: DeckState(
106
+ buttons={
107
+ int(page_of_buttons_id): {
108
+ int(button_id): _to_button_multi_state(button)
109
+ for button_id, button in page_of_buttons_state.items()
110
+ }
111
+ for page_of_buttons_id, page_of_buttons_state in deck_state["buttons"].items()
112
+ },
113
+ display_timeout=deck_state["display_timeout"],
114
+ brightness=deck_state["brightness"],
115
+ brightness_dimmed=deck_state["brightness_dimmed"],
116
+ rotation=deck_state["rotation"],
117
+ page=deck_state["page"],
118
+ )
119
+ for deck_id, deck_state in state.items()
120
+ }
121
+
122
+
123
+ def _migrate_deck_state_from_previous_version(state: dict) -> Dict[str, DeckState]:
124
+ deck_state = _to_deck_states_v1(state)
125
+ return {
126
+ deck_id: DeckState(
127
+ buttons={
128
+ page_of_buttons_id: {
129
+ button_id: _migrate_button_state_to_multi_state(button)
130
+ for button_id, button in page_of_buttons_state.items()
131
+ }
132
+ for page_of_buttons_id, page_of_buttons_state in deck_state.buttons.items()
133
+ },
134
+ display_timeout=deck_state.display_timeout,
135
+ brightness=deck_state.brightness,
136
+ brightness_dimmed=deck_state.brightness_dimmed,
137
+ rotation=deck_state.rotation,
138
+ page=deck_state.page,
139
+ )
140
+ for deck_id, deck_state in deck_state.items()
141
+ }
142
+
143
+
144
+ def _migrate_button_state_to_multi_state(button: ButtonState) -> ButtonMultiState:
145
+ return ButtonMultiState(
146
+ state=0,
147
+ states={
148
+ 0: button,
149
+ },
150
+ )
151
+
152
+
153
+ def _to_deck_states_v1(state: dict) -> Dict[str, DeckStateV1]:
154
+ """Convert a dict to a DeckStateV1 object"""
155
+ return {
156
+ deck_id: DeckStateV1(
157
+ buttons={
158
+ int(page_of_buttons_id): {
159
+ int(button_id): _to_button_state(button) for button_id, button in page_of_buttons_state.items()
160
+ }
161
+ for page_of_buttons_id, page_of_buttons_state in deck_state.get("buttons", {}).items()
162
+ },
163
+ display_timeout=deck_state.get("display_timeout", 0),
164
+ brightness=deck_state.get("brightness", 0),
165
+ brightness_dimmed=deck_state.get("brightness_dimmed", 0),
166
+ rotation=deck_state.get("rotation", 0),
167
+ page=deck_state.get("page", 0),
168
+ )
169
+ for deck_id, deck_state in state.items()
170
+ }
171
+
172
+
173
+ def _to_button_state(button: dict) -> ButtonState:
174
+ """Convert a dict to a ButtonState object"""
175
+ return ButtonState(
176
+ text=button.get("text", ""),
177
+ icon=button.get("icon", ""),
178
+ keys=button.get("keys", ""),
179
+ write=button.get("write", ""),
180
+ command=button.get("command", ""),
181
+ switch_page=button.get("switch_page", 0),
182
+ switch_state=button.get("switch_state", 0),
183
+ brightness_change=button.get("brightness_change", 0),
184
+ text_vertical_align=button.get("text_vertical_align", ""),
185
+ text_horizontal_align=button.get("text_horizontal_align", ""),
186
+ font=button.get("font", ""),
187
+ font_color=button.get("font_color", ""),
188
+ font_size=button.get("font_size", 0),
189
+ background_color=button.get("background_color", ""),
190
+ )
191
+
192
+
193
+ def _to_button_multi_state(button: dict) -> ButtonMultiState:
194
+ return ButtonMultiState(
195
+ state=button.get("state", 0),
196
+ states={int(state_id): _to_button_state(state) for state_id, state in button.get("states", {}).items()},
197
+ )
198
+
199
+
200
+ def _to_deck_config(state: Dict[str, DeckState]) -> dict:
201
+ return {
202
+ deck_id: {
203
+ "buttons": {
204
+ page_of_buttons_id: {
205
+ button_id: _to_multi_state_button_config(button)
206
+ for button_id, button in page_of_buttons_state.items()
207
+ }
208
+ for page_of_buttons_id, page_of_buttons_state in deck_state.buttons.items()
209
+ },
210
+ "display_timeout": deck_state.display_timeout,
211
+ "brightness": deck_state.brightness,
212
+ "brightness_dimmed": deck_state.brightness_dimmed,
213
+ "rotation": deck_state.rotation,
214
+ "page": deck_state.page,
215
+ }
216
+ for deck_id, deck_state in state.items()
217
+ }
218
+
219
+
220
+ def _to_button_config(button: ButtonState) -> dict:
221
+ """Convert a ButtonState object to a dict"""
222
+ return {
223
+ "text": button.text,
224
+ "icon": button.icon,
225
+ "keys": button.keys,
226
+ "write": button.write,
227
+ "command": button.command,
228
+ "brightness_change": button.brightness_change,
229
+ "switch_page": button.switch_page,
230
+ "switch_state": button.switch_state,
231
+ "text_vertical_align": button.text_vertical_align,
232
+ "text_horizontal_align": button.text_horizontal_align,
233
+ "font": button.font,
234
+ "font_color": button.font_color,
235
+ "font_size": button.font_size,
236
+ "background_color": button.background_color,
237
+ }
238
+
239
+
240
+ def _to_multi_state_button_config(button: ButtonMultiState) -> dict:
241
+ return {
242
+ "state": button.state,
243
+ "states": {state_id: _to_button_config(state) for state_id, state in button.states.items()},
244
+ }
@@ -0,0 +1,93 @@
1
+ import threading
2
+ from typing import Callable, Optional
3
+
4
+ from StreamDeck.Transport.Transport import TransportError
5
+
6
+
7
+ class Dimmer:
8
+ def __init__(
9
+ self, timeout: int, brightness: int, brightness_dimmed: int, brightness_callback: Callable[[int], None]
10
+ ):
11
+ """Constructs a new Dimmer instance
12
+
13
+ :param int timeout: The time in seconds before the dimmer starts.
14
+ :param int brightness: The normal brightness level.
15
+ :param int brightness_dimmed: The percentage of normal brightness when dimmed.
16
+ :param Callable[[int], None] brightness_callback: Callback that receives the current
17
+ brightness level.
18
+ """
19
+ self.timeout = timeout
20
+ self.brightness = brightness
21
+ "The brightness when not dimmed"
22
+ self.brightness_dimmed = brightness_dimmed
23
+ "The percentage of normal brightness when dimmed"
24
+ self.brightness_callback = brightness_callback
25
+ self.__stopped = False
26
+ self.dimmed = True
27
+ "True if the Stream Deck is dimmed, False otherwise"
28
+ self.__timer: Optional[threading.Timer] = None
29
+
30
+ def dimmed_brightness(self) -> int:
31
+ """Calculates the effective brightness when dimmed.
32
+
33
+ :return: The brightness value when applying the dim percentage to the normal brightness.
34
+ :rtype: int
35
+ """
36
+ return int(self.brightness * (self.brightness_dimmed / 100))
37
+
38
+ def stop(self) -> None:
39
+ """Stops the dimmer and sets the brightness back to normal. Call
40
+ reset to start normal dimming operation."""
41
+ if self.__timer:
42
+ self.__timer.cancel()
43
+ self.__timer = None
44
+
45
+ try:
46
+ self.brightness_callback(self.brightness)
47
+ except KeyError:
48
+ # During detach cleanup, this is likely to happen
49
+ pass
50
+ except TransportError:
51
+ pass
52
+ self.__stopped = True
53
+
54
+ def reset(self) -> bool:
55
+ """Reset the dimmer and start counting down again. If it was busy dimming, it will
56
+ immediately stop dimming. Callback fires to set brightness back to normal."""
57
+
58
+ self.__stopped = False
59
+ if self.__timer:
60
+ self.__timer.cancel()
61
+ self.__timer = None
62
+
63
+ if self.timeout:
64
+ self.__timer = threading.Timer(self.timeout, self.dim)
65
+ self.__timer.start()
66
+
67
+ if self.dimmed:
68
+ self.brightness_callback(self.brightness)
69
+ self.dimmed = False
70
+ if self.dimmed_brightness() < 20:
71
+ # The screen was "too dark" so reset and let caller know
72
+ return True
73
+
74
+ return False
75
+ # Returning false means "I didn't have to reset it"
76
+
77
+ def dim(self, toggle: bool = False):
78
+ """Manually initiate a dim event.
79
+ If the dimmer is stopped, this has no effect."""
80
+
81
+ if self.__stopped:
82
+ return
83
+
84
+ if toggle and self.dimmed:
85
+ # Don't dim
86
+ self.reset()
87
+ elif self.__timer:
88
+ # No need for the timer anymore, stop it
89
+ self.__timer.cancel()
90
+ self.__timer = None
91
+
92
+ self.brightness_callback(self.dimmed_brightness())
93
+ self.dimmed = True
File without changes
@@ -0,0 +1,41 @@
1
+ from fractions import Fraction
2
+ from typing import Callable, Optional, Tuple
3
+
4
+ from PIL import Image, ImageColor
5
+
6
+ from streamdeck_ui.display.filter import Filter
7
+
8
+
9
+ class BackgroundColorFilter(Filter):
10
+ image: Optional[Image.Image]
11
+
12
+ def __init__(self, color: str):
13
+ super(BackgroundColorFilter, self).__init__()
14
+ self.image = None
15
+ self.color = to_rgb(color)
16
+ self.hashcode = hash((self.__class__, self.color))
17
+
18
+ def initialize(self, size: Tuple[int, int]):
19
+ self.image = Image.new("RGB", size)
20
+ self.image.paste(self.color, (0, 0, size[0], size[1]))
21
+
22
+ def transform( # type: ignore[override]
23
+ self,
24
+ get_input: Callable[[], Image.Image],
25
+ get_output: Callable[[int], Image.Image],
26
+ input_changed: bool,
27
+ time: Fraction,
28
+ ) -> Tuple[Optional[Image.Image], int]:
29
+ if not input_changed:
30
+ return None, self.hashcode
31
+ return self.image, self.hashcode
32
+
33
+
34
+ def to_rgb(hex_str: str) -> Tuple[int, ...]:
35
+ """
36
+ Converts a hex string or a color string to an RGB tuple.
37
+ """
38
+ if hex_str.startswith("#"):
39
+ hex_str = hex_str.lstrip("#")
40
+ return tuple(int(hex_str[i : i + 2], 16) for i in (0, 2, 4))
41
+ return ImageColor.getrgb(hex_str)
@@ -0,0 +1,265 @@
1
+ import threading
2
+ from time import sleep, time
3
+ from typing import Callable, Dict, List, Optional
4
+
5
+ from PIL import Image
6
+ from StreamDeck.Devices.StreamDeck import StreamDeck
7
+ from StreamDeck.Devices.StreamDeckOriginal import StreamDeckOriginal
8
+ from StreamDeck.ImageHelpers import PILHelper
9
+ from StreamDeck.Transport.Transport import TransportError
10
+
11
+ from streamdeck_ui.display.empty_filter import EmptyFilter
12
+ from streamdeck_ui.display.filter import Filter
13
+ from streamdeck_ui.display.keypress_filter import KeypressFilter
14
+ from streamdeck_ui.display.pipeline import Pipeline
15
+
16
+
17
+ class DisplayGrid:
18
+ """
19
+ A DisplayGrid is made up of a collection of pipelines, each processing
20
+ filters for one individual button display.
21
+ """
22
+
23
+ lock: threading.Lock
24
+
25
+ def __init__(
26
+ self,
27
+ lock: threading.Lock,
28
+ streamdeck: StreamDeck,
29
+ pages: List[int],
30
+ cpu_callback: Callable[[str, int], None],
31
+ fps: int = 25,
32
+ ):
33
+ """Creates a new display instance
34
+
35
+ :param lock: A lock object that will be used to get exclusive access while enumerating
36
+ Stream Decks. This lock must be shared by any object that will read or write to the
37
+ Stream Deck.
38
+ :type lock: threading.Lock
39
+ :param streamdeck: The StreamDeck instance associated with this display
40
+ :type streamdeck: StreamDeck
41
+ :param pages: The number of logical pages (screen sets)
42
+ :type pages: int
43
+ :param cpu_callback: A function to call whenever the CPU changes
44
+ :type cpu_callback: Callable[[str, int], None]
45
+ :param fps: The desired FPS, defaults to 25
46
+ :type fps: int, optional
47
+ """
48
+ self.streamdeck = streamdeck
49
+ # Reference to the actual device, used to update icons
50
+
51
+ if streamdeck.is_visual():
52
+ self.size = streamdeck.key_image_format()["size"]
53
+ else:
54
+ self.size = (StreamDeckOriginal.KEY_PIXEL_WIDTH, StreamDeckOriginal.KEY_PIXEL_HEIGHT)
55
+ # Default to original stream deck size - even though we're not actually going to display anything
56
+ self.serial_number = streamdeck.get_serial_number()
57
+
58
+ self._empty_filter: EmptyFilter = EmptyFilter()
59
+ self._empty_filter.initialize(self.size)
60
+ # Instance of EmptyFilter shared by all pipelines related to this
61
+ # DisplayGrid instance
62
+
63
+ self.pages: Dict[int, Dict[int, Pipeline]] = {}
64
+ # A dictionary of lists of pipelines. Each page has
65
+ # a list, corresponding to each button.
66
+
67
+ self.current_page: int = -1
68
+ self.pipeline_thread: Optional[threading.Thread] = None
69
+ self.quit = threading.Event()
70
+ self.fps = fps
71
+ # Configure the maximum frame rate we want to achieve
72
+ self.time_per_frame = 1 / fps
73
+ self.lock = lock
74
+ self.sync = threading.Event()
75
+ self.cpu_callback = cpu_callback
76
+
77
+ # Initialize with a pipeline per key for all pages
78
+ for page in pages:
79
+ self.initialize_page(page)
80
+ # The sync event allows a caller to wait until all the buttons have been processed
81
+
82
+ def initialize_page(self, page: int):
83
+ self.pages[page] = {}
84
+ for button in range(self.streamdeck.key_count()):
85
+ self.pages[page][button] = Pipeline()
86
+ self.replace(page, button, [])
87
+
88
+ def remove_page(self, page: int):
89
+ with self.lock:
90
+ del self.pages[page]
91
+
92
+ def replace(self, page: int, button: int, filters: List[Filter]):
93
+ with self.lock:
94
+ pipeline = Pipeline()
95
+ pipeline.add(self._empty_filter)
96
+ for pipeline_filter in filters:
97
+ pipeline_filter.initialize(self.size)
98
+ pipeline.add(pipeline_filter)
99
+ keypress = KeypressFilter()
100
+ keypress.initialize(self.size)
101
+ pipeline.add(keypress)
102
+ self.pages[page][button] = pipeline
103
+
104
+ def get_image(self, page: int, button: int) -> Optional[Image.Image]:
105
+ with self.lock:
106
+ # REVIEW: Consider returning not the last result, but a thumbnail
107
+ # or something that represents the current "static" look of
108
+ # a button. This will need to be added to the interface
109
+ # of a filter.
110
+ return self.pages[page][button].last_result()
111
+
112
+ def set_keypress(self, button: int, active: bool):
113
+ with self.lock:
114
+ for filter in self.pages[self.current_page][button].filters:
115
+ if isinstance(filter[0], KeypressFilter):
116
+ filter[0].active = active
117
+
118
+ def synchronize(self):
119
+ # Wait until the next cycle is complete.
120
+ # To *guarantee* that you have one complete pass, two waits are needed.
121
+ # The first gets you to the end of one cycle (you could have called it
122
+ # mid cycle). The second gets you one pass through. Worst case, you
123
+ # do two full cycles. Best case, you do 1 full and one partial.
124
+ self.sync.wait()
125
+ self.sync.wait()
126
+
127
+ def _run(self):
128
+ """Method that runs on background thread and updates the pipelines."""
129
+ frames = 0
130
+ start = time()
131
+ last_page = -1
132
+ execution_time = 0
133
+ frame_cache = {}
134
+
135
+ while not self.quit.isSet():
136
+ current_time = time()
137
+
138
+ with self.lock:
139
+ page = self.pages[self.current_page]
140
+
141
+ force_update = False
142
+
143
+ if last_page != page:
144
+ # When a page switch happen, force the pipelines to redraw so icons update
145
+ force_update = True
146
+ last_page = page
147
+
148
+ pipeline_cache_count = 0
149
+
150
+ for button, pipeline in page.items():
151
+ # Process all the steps in the pipeline and return the resulting image
152
+ with self.lock:
153
+ image, hashcode = pipeline.execute(current_time)
154
+
155
+ pipeline_cache_count += len(pipeline.output_cache)
156
+
157
+ # If none of the filters in the pipeline yielded a change, use
158
+ # the last known result
159
+ if force_update and image is None:
160
+ image = pipeline.last_result()
161
+
162
+ if image:
163
+ # We cannot afford to do this conversion on every final frame.
164
+ # Since we want the flexibilty of a pipeline engine that can mutate the
165
+ # images along a chain of filters, the outcome can be somewhat unpredicatable
166
+ # For example - a clock that changes time or an animation that changes
167
+ # the frame and font that overlays. In many instances there is a finite
168
+ # number of frames per pipeline (a looping GIF with image, a pulsing icon etc)
169
+ # Some may also be virtually have infinite mutations. A cache per pipeline
170
+ # with an eviction policy of the oldest would likely suffice.
171
+ # The main problem is since the pipeline can mutate it's too expensive to
172
+ # calculate the actual hash of the final frame.
173
+ # Create a hash function that the filter itself defines. It has to
174
+ # update the hashcode with the unique attributes of the input it requires
175
+ # to make the frame. This could be time, text, frame number etc.
176
+ # The hash can then be passed to the next step and XOR'd or combined
177
+ # with the next hash. This yields a final hash code that can then be
178
+ # used to cache the output. At the end of the pipeline the hash can
179
+ # be checked and final bytes will be ready to pipe to the device.
180
+
181
+ if self.streamdeck.is_visual():
182
+ # FIXME: This will be unbounded, old frames will need to be evicted
183
+ if hashcode not in frame_cache:
184
+ image = PILHelper.to_native_format(self.streamdeck, image)
185
+ frame_cache[hashcode] = image
186
+ else:
187
+ image = frame_cache[hashcode]
188
+
189
+ try:
190
+ with self.lock:
191
+ self.streamdeck.set_key_image(button, image)
192
+ except TransportError:
193
+ # Review - deadlock if you wait on yourself?
194
+ self.stop()
195
+ pass
196
+ return
197
+
198
+ self.sync.set()
199
+ self.sync.clear()
200
+ # Calculate how long we took to process the pipeline
201
+ elapsed_time = time() - current_time
202
+ execution_time += elapsed_time
203
+
204
+ # Calculate how much we have to sleep between processing cycles to maintain the desired FPS
205
+ # If we have less than 5ms left, don't bother sleeping, as the context switch and
206
+ # overhead of sleeping/waking up is consumed
207
+ time_left = self.time_per_frame - elapsed_time
208
+ if time_left > 0.005:
209
+ sleep(time_left)
210
+
211
+ frames += 1
212
+ if time() - start > 1.0:
213
+ execution_time_ms = int(execution_time * 1000)
214
+ if self.cpu_callback:
215
+ self.cpu_callback(self.serial_number, int(execution_time_ms / 1000 * 100))
216
+ # execution_time_ms = int(execution_time * 1000)
217
+ # print(f"FPS: {frames} Execution time: {execution_time_ms} ms Execution %: {int(execution_time_ms/1000 * 100)}")
218
+ # print(f"Output cache size: {len(frame_cache)}")
219
+ # print(f"Pipeline cache size: {pipeline_cache_count}")
220
+ execution_time = 0
221
+ frames = 0
222
+ start = time()
223
+
224
+ def set_page(self, page: int):
225
+ """Switches to the given page. Pipelines for that page starts running,
226
+ other page pipelines stop.
227
+
228
+ Args:
229
+ page (int): The page number to switch to.
230
+ """
231
+ with self.lock:
232
+ if self.current_page >= 0:
233
+ # Ensure none of the button filters are active anymore
234
+ old_page = self.pages[self.current_page]
235
+ for _, pipeline in old_page.items():
236
+ for filter in pipeline.filters:
237
+ if isinstance(filter[0], KeypressFilter):
238
+ filter[0].active = False
239
+ # REVIEW: We could detect the active key on the last page, and make it active
240
+ # on the target page
241
+ self.current_page = page
242
+
243
+ def start(self):
244
+ if self.pipeline_thread is not None:
245
+ self.quit.set()
246
+ try:
247
+ self.pipeline_thread.join()
248
+ except RuntimeError:
249
+ pass
250
+
251
+ self.quit.clear()
252
+ self.pipeline_thread = threading.Thread(target=self._run)
253
+ self.pipeline_thread.daemon = True
254
+ self.pipeline_thread.start()
255
+ self.synchronize()
256
+ # Wait for first frames to become ready
257
+
258
+ def stop(self):
259
+ if self.pipeline_thread is not None:
260
+ self.quit.set()
261
+ try:
262
+ self.pipeline_thread.join()
263
+ except RuntimeError:
264
+ pass
265
+ self.pipeline_thread = None
@@ -0,0 +1,43 @@
1
+ from fractions import Fraction
2
+ from typing import Callable, Optional, Tuple
3
+
4
+ from PIL import Image
5
+
6
+ from streamdeck_ui.display import filter
7
+
8
+
9
+ class EmptyFilter(filter.Filter):
10
+ """
11
+ This is the empty (base) filter where all pipelines start from.
12
+
13
+ :param str name: The name of the filter. The name is useful for debugging purposes.
14
+ """
15
+
16
+ def __init__(self):
17
+ super(EmptyFilter, self).__init__()
18
+
19
+ # For EmptyFilter - create a unique hashcode based on the name of the type
20
+ # This will create "some value" that uniquely identifies this filter output
21
+ # Since it never changes, this works.
22
+ # Calculate it once for speed
23
+ self.hashcode = hash(self.__class__)
24
+
25
+ def initialize(self, size: Tuple[int, int]):
26
+ self.image = Image.new("RGB", size)
27
+
28
+ def transform( # type: ignore[override]
29
+ self,
30
+ get_input: Callable[[], Image.Image],
31
+ get_output: Callable[[int], Image.Image],
32
+ input_changed: bool,
33
+ time: Fraction,
34
+ ) -> Tuple[Optional[Image.Image], int]:
35
+ """
36
+ Returns an empty Image object.
37
+
38
+ :param Fraction time: The current time in seconds, expressed as a fractional number since
39
+ the start of the pipeline.
40
+ """
41
+ if not input_changed:
42
+ return (None, self.hashcode)
43
+ return ((self.image), self.hashcode)