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.
- streamdeck_gui_ng-4.1.3.dist-info/METADATA +141 -0
- streamdeck_gui_ng-4.1.3.dist-info/RECORD +62 -0
- streamdeck_gui_ng-4.1.3.dist-info/WHEEL +4 -0
- streamdeck_gui_ng-4.1.3.dist-info/entry_points.txt +4 -0
- streamdeck_gui_ng-4.1.3.dist-info/licenses/LICENSE +21 -0
- streamdeck_ui/__init__.py +6 -0
- streamdeck_ui/api.py +712 -0
- streamdeck_ui/button.ui +1214 -0
- streamdeck_ui/cli/__init__.py +0 -0
- streamdeck_ui/cli/commands.py +191 -0
- streamdeck_ui/cli/server.py +292 -0
- streamdeck_ui/config.py +244 -0
- streamdeck_ui/dimmer.py +93 -0
- streamdeck_ui/display/__init__.py +0 -0
- streamdeck_ui/display/background_color_filter.py +41 -0
- streamdeck_ui/display/display_grid.py +265 -0
- streamdeck_ui/display/empty_filter.py +43 -0
- streamdeck_ui/display/filter.py +65 -0
- streamdeck_ui/display/image_filter.py +144 -0
- streamdeck_ui/display/keypress_filter.py +63 -0
- streamdeck_ui/display/pipeline.py +74 -0
- streamdeck_ui/display/pulse_filter.py +54 -0
- streamdeck_ui/display/text_filter.py +142 -0
- streamdeck_ui/fonts/roboto/LICENSE.txt +202 -0
- streamdeck_ui/fonts/roboto/Roboto-Black.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-BlackItalic.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-Bold.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-BoldItalic.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-Italic.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-Light.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-LightItalic.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-Medium.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-MediumItalic.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-Regular.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-Thin.ttf +0 -0
- streamdeck_ui/fonts/roboto/Roboto-ThinItalic.ttf +0 -0
- streamdeck_ui/gui.py +1423 -0
- streamdeck_ui/icons/add_page.png +0 -0
- streamdeck_ui/icons/cross.png +0 -0
- streamdeck_ui/icons/gear.png +0 -0
- streamdeck_ui/icons/horizontal-align.png +0 -0
- streamdeck_ui/icons/remove_page.png +0 -0
- streamdeck_ui/icons/vertical-align.png +0 -0
- streamdeck_ui/icons/warning_icon_button.png +0 -0
- streamdeck_ui/logger.py +11 -0
- streamdeck_ui/logo.png +0 -0
- streamdeck_ui/main.ui +407 -0
- streamdeck_ui/mock_streamdeck.py +204 -0
- streamdeck_ui/model.py +78 -0
- streamdeck_ui/modules/__init__.py +0 -0
- streamdeck_ui/modules/fonts.py +150 -0
- streamdeck_ui/modules/keyboard.py +447 -0
- streamdeck_ui/modules/utils/__init__.py +0 -0
- streamdeck_ui/modules/utils/timers.py +35 -0
- streamdeck_ui/resources.qrc +10 -0
- streamdeck_ui/resources_rc.py +324 -0
- streamdeck_ui/semaphore.py +38 -0
- streamdeck_ui/settings.ui +155 -0
- streamdeck_ui/stream_deck_monitor.py +157 -0
- streamdeck_ui/ui_button.py +421 -0
- streamdeck_ui/ui_main.py +267 -0
- streamdeck_ui/ui_settings.py +119 -0
streamdeck_ui/config.py
ADDED
|
@@ -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
|
+
}
|
streamdeck_ui/dimmer.py
ADDED
|
@@ -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)
|