Skip to content

Commit d3b831e

Browse files
Copilotjack-arturo
andauthored
merge: resolve conflicts with main (encoder controller tests from #15)
Co-authored-by: jack-arturo <13076544+jack-arturo@users.noreply.github.com>
2 parents 1fce5e8 + f4a8402 commit d3b831e

3 files changed

Lines changed: 384 additions & 59 deletions

File tree

profile_manager.py

Lines changed: 145 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,35 @@
4040
DEFAULT_TITLE_ALIGNMENT = "bottom"
4141
DEFAULT_ICON_SIZE = (72, 72)
4242

43+
KEYPAD = "Keypad"
44+
ENCODER = "Encoder"
45+
46+
CONTROLLER_ALIASES: dict[str, str] = {
47+
"keypad": KEYPAD,
48+
"key": KEYPAD,
49+
"button": KEYPAD,
50+
"encoder": ENCODER,
51+
"dial": ENCODER,
52+
}
53+
4354
DEFAULT_PAGE_MANIFEST = {
4455
"Controllers": [
4556
{
4657
"Actions": None,
47-
"Type": "Keypad",
58+
"Type": KEYPAD,
4859
}
4960
],
5061
"Icon": "",
5162
"Name": "",
5263
}
5364

54-
MODEL_LAYOUTS: dict[str, tuple[int, int]] = {
55-
"20GBA9901": (5, 3),
56-
"UI Stream Deck": (4, 2),
65+
MODEL_LAYOUTS: dict[str, dict[str, tuple[int, int]]] = {
66+
# Stream Deck (Original)
67+
"20GBA9901": {KEYPAD: (5, 3)},
68+
# Stream Deck + XL (32 keys, 6 dials with 1200x100 touchstrip)
69+
"20GBX9901": {KEYPAD: (8, 4), ENCODER: (6, 1)},
70+
# Emulator used by the Elgato desktop app
71+
"UI Stream Deck": {KEYPAD: (4, 2)},
5772
}
5873

5974
# The Elgato Stream Deck desktop app caches every profile in memory and rewrites the
@@ -201,12 +216,48 @@ def get_profiles_dir(version: str = "auto") -> Path:
201216
)
202217

203218

204-
def _controller_actions(page_manifest: dict[str, Any]) -> dict[str, Any]:
205-
controllers = page_manifest.get("Controllers") or []
206-
if not controllers:
219+
def _find_controller(page_manifest: dict[str, Any], controller_type: str) -> dict[str, Any] | None:
220+
for controller in page_manifest.get("Controllers") or []:
221+
if controller.get("Type") == controller_type:
222+
return controller
223+
return None
224+
225+
226+
def _ensure_controller(page_manifest: dict[str, Any], controller_type: str) -> dict[str, Any]:
227+
controllers = page_manifest.setdefault("Controllers", [])
228+
for controller in controllers:
229+
if controller.get("Type") == controller_type:
230+
return controller
231+
new_controller: dict[str, Any] = {"Type": controller_type, "Actions": None}
232+
controllers.append(new_controller)
233+
return new_controller
234+
235+
236+
def _controller_actions(
237+
page_manifest: dict[str, Any], controller_type: str = KEYPAD
238+
) -> dict[str, Any]:
239+
controller = _find_controller(page_manifest, controller_type)
240+
if not controller:
207241
return {}
208-
actions = controllers[0].get("Actions")
209-
return actions or {}
242+
return controller.get("Actions") or {}
243+
244+
245+
def _normalize_controller(value: str | None) -> str:
246+
if not value:
247+
return KEYPAD
248+
canonical = CONTROLLER_ALIASES.get(value.lower())
249+
if canonical is None:
250+
raise ProfileValidationError(
251+
f"Unknown controller '{value}'. Use one of: {sorted(set(CONTROLLER_ALIASES))}"
252+
)
253+
return canonical
254+
255+
256+
def _total_action_count(page_manifest: dict[str, Any]) -> int:
257+
return sum(
258+
len(controller.get("Actions") or {})
259+
for controller in page_manifest.get("Controllers") or []
260+
)
210261

211262

212263
def _slugify(value: str) -> str:
@@ -387,37 +438,44 @@ def read_page(
387438
directory_id=directory_id,
388439
)
389440
page_manifest = _load_json(page_ref.manifest_path)
390-
columns, rows = self._resolve_layout(profile_manifest, page_manifest)
391-
392-
buttons = []
393-
for position, action in sorted(
394-
_controller_actions(page_manifest).items(),
395-
key=lambda item: self._position_sort_key(item[0]),
396-
):
397-
col, row = [int(part) for part in position.split(",")]
398-
key = (row * columns) + col
399-
state_index = min(
400-
max(int(action.get("State", 0)), 0), max(len(action.get("States", [{}])) - 1, 0)
401-
)
402-
states = action.get("States") or [{}]
403-
active_state = states[state_index] if states else {}
404-
buttons.append(
405-
{
406-
"key": key,
407-
"position": position,
408-
"action_id": action.get("ActionID"),
409-
"action_uuid": action.get("UUID"),
410-
"plugin_uuid": action.get("Plugin", {}).get("UUID"),
411-
"plugin_name": action.get("Plugin", {}).get("Name"),
412-
"name": action.get("Name"),
413-
"state": action.get("State", 0),
414-
"title": active_state.get("Title"),
415-
"image": active_state.get("Image"),
416-
"settings": action.get("Settings", {}),
417-
"show_title": active_state.get("ShowTitle"),
418-
"raw": action,
419-
}
420-
)
441+
keypad_cols, keypad_rows = self._resolve_layout(profile_manifest, page_manifest, KEYPAD)
442+
443+
buttons: list[dict[str, Any]] = []
444+
layouts: dict[str, dict[str, int]] = {}
445+
446+
for controller in page_manifest.get("Controllers") or []:
447+
controller_type = controller.get("Type", KEYPAD)
448+
cols, rows = self._resolve_layout(profile_manifest, page_manifest, controller_type)
449+
layouts[controller_type.lower()] = {"columns": cols, "rows": rows}
450+
451+
actions = controller.get("Actions") or {}
452+
for position, action in sorted(
453+
actions.items(),
454+
key=lambda item: self._position_sort_key(item[0]),
455+
):
456+
col, row = [int(part) for part in position.split(",")]
457+
key = (row * cols + col) if cols else col
458+
states = action.get("States") or [{}]
459+
state_index = min(max(int(action.get("State", 0)), 0), max(len(states) - 1, 0))
460+
active_state = states[state_index] if states else {}
461+
buttons.append(
462+
{
463+
"controller": controller_type.lower(),
464+
"key": key,
465+
"position": position,
466+
"action_id": action.get("ActionID"),
467+
"action_uuid": action.get("UUID"),
468+
"plugin_uuid": action.get("Plugin", {}).get("UUID"),
469+
"plugin_name": action.get("Plugin", {}).get("Name"),
470+
"name": action.get("Name"),
471+
"state": action.get("State", 0),
472+
"title": active_state.get("Title"),
473+
"image": active_state.get("Image"),
474+
"settings": action.get("Settings", {}),
475+
"show_title": active_state.get("ShowTitle"),
476+
"raw": action,
477+
}
478+
)
421479

422480
return {
423481
"profiles_root": self.profiles_dir.name,
@@ -430,7 +488,8 @@ def read_page(
430488
"default_page_uuid": profile_manifest.get("Pages", {}).get("Default"),
431489
},
432490
"page": page_ref.to_dict(),
433-
"layout": {"columns": columns, "rows": rows},
491+
"layout": {"columns": keypad_cols, "rows": keypad_rows},
492+
"layouts": layouts,
434493
"buttons": buttons,
435494
"raw_manifest": page_manifest,
436495
}
@@ -501,17 +560,41 @@ def write_page(
501560
if page_name is not None:
502561
page_manifest["Name"] = page_name
503562

504-
columns, rows = self._resolve_layout(profile_manifest, page_manifest)
505-
actions = {} if clear_existing else copy.deepcopy(_controller_actions(page_manifest))
563+
# Group incoming buttons by the controller they target so a single write can
564+
# update the Keypad and Encoder controllers together without touching the other.
565+
buttons_by_controller: dict[str, list[dict[str, Any]]] = {}
506566
for button in buttons:
507-
position = self._resolve_button_position(button, columns=columns, rows=rows)
508-
actions[position] = self._materialize_action(button, page_dir)
567+
controller_type = _normalize_controller(button.get("controller"))
568+
buttons_by_controller.setdefault(controller_type, []).append(button)
569+
570+
# When clear_existing is requested but no buttons were supplied, default to
571+
# targeting the Keypad controller so that the caller can still clear a page
572+
# by writing an empty button list (restores pre-multi-controller behaviour).
573+
if clear_existing and not buttons_by_controller:
574+
buttons_by_controller[KEYPAD] = []
509575

510-
controllers = page_manifest.setdefault("Controllers", [{"Type": "Keypad"}])
511-
if not controllers:
512-
controllers.append({"Type": "Keypad"})
513-
controllers[0]["Type"] = controllers[0].get("Type", "Keypad")
514-
controllers[0]["Actions"] = actions or None
576+
layouts_out: dict[str, dict[str, int]] = {}
577+
578+
for controller_type, ctl_buttons in buttons_by_controller.items():
579+
cols, rows = self._resolve_layout(profile_manifest, page_manifest, controller_type)
580+
if cols <= 0 or rows <= 0:
581+
raise ProfileValidationError(
582+
f"Device model does not expose a '{controller_type}' controller."
583+
)
584+
controller = _ensure_controller(page_manifest, controller_type)
585+
existing = {} if clear_existing else copy.deepcopy(controller.get("Actions") or {})
586+
for button in ctl_buttons:
587+
position = self._resolve_button_position(button, columns=cols, rows=rows)
588+
existing[position] = self._materialize_action(button, page_dir)
589+
controller["Actions"] = existing or None
590+
layouts_out[controller_type.lower()] = {"columns": cols, "rows": rows}
591+
592+
# New pages always carry a Keypad controller slot so the Elgato app can render them.
593+
if create_new:
594+
_ensure_controller(page_manifest, KEYPAD)
595+
596+
primary_cols, primary_rows = self._resolve_layout(profile_manifest, page_manifest, KEYPAD)
597+
total_button_count = _total_action_count(page_manifest)
515598

516599
if create_new:
517600
pages_section = profile_manifest.setdefault("Pages", {})
@@ -542,8 +625,9 @@ def write_page(
542625
"page_index": None if create_new else page_index,
543626
"directory_id": page_dir.name,
544627
"page_uuid": page_uuid,
545-
"layout": {"columns": columns, "rows": rows},
546-
"button_count": len(actions),
628+
"layout": {"columns": primary_cols, "rows": primary_rows},
629+
"layouts": layouts_out,
630+
"button_count": total_button_count,
547631
"page_name": page_manifest.get("Name", ""),
548632
"manifest_path": str(page_dir / "manifest.json"),
549633
"app_quit": app_stop_report,
@@ -779,7 +863,6 @@ def _build_page_ref(
779863
is_current: bool,
780864
) -> PageRef:
781865
page_manifest = _load_json(manifest_path)
782-
actions = _controller_actions(page_manifest)
783866
return PageRef(
784867
page_index=page_index,
785868
directory_id=directory_id,
@@ -790,7 +873,7 @@ def _build_page_ref(
790873
is_default=is_default,
791874
is_current=is_current,
792875
name=str(page_manifest.get("Name", "")),
793-
button_count=len(actions),
876+
button_count=_total_action_count(page_manifest),
794877
icon_count=_count_icons(manifest_path.parent),
795878
)
796879

@@ -822,19 +905,24 @@ def _resolve_layout(
822905
self,
823906
profile_manifest: dict[str, Any],
824907
page_manifest: dict[str, Any] | None = None,
908+
controller_type: str = KEYPAD,
825909
) -> tuple[int, int]:
826910
device_model = str(profile_manifest.get("Device", {}).get("Model", ""))
827-
if device_model in MODEL_LAYOUTS:
828-
return MODEL_LAYOUTS[device_model]
911+
model_entry = MODEL_LAYOUTS.get(device_model)
912+
if model_entry and controller_type in model_entry:
913+
return model_entry[controller_type]
829914

830915
if page_manifest:
831-
actions = _controller_actions(page_manifest)
916+
actions = _controller_actions(page_manifest, controller_type)
832917
if actions:
833918
cols = max(int(position.split(",")[0]) for position in actions) + 1
834919
rows = max(int(position.split(",")[1]) for position in actions) + 1
835920
if cols > 0 and rows > 0:
836921
return cols, rows
837922

923+
if controller_type == ENCODER:
924+
return (0, 0)
925+
838926
return (5, 3)
839927

840928
def _resolve_button_position(

profile_server.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,31 @@ async def list_tools() -> list[Tool]:
4444
button_schema = {
4545
"type": "object",
4646
"properties": {
47+
"controller": {
48+
"type": "string",
49+
"enum": ["keypad", "key", "button", "encoder", "dial"],
50+
"default": "keypad",
51+
"description": (
52+
"Which physical controller this button targets. "
53+
"'keypad' (default) addresses the LCD keys; "
54+
"'encoder' (aka 'dial') addresses the rotary/touch dials on "
55+
"Stream Deck + and + XL. "
56+
"The key/position indexes are scoped to the chosen controller."
57+
),
58+
},
4759
"key": {
4860
"type": "integer",
49-
"description": "Linear button index (0-based, left-to-right, top-to-bottom).",
61+
"description": (
62+
"Button index scoped to the chosen controller (0-based). "
63+
"For keypad controllers the index is row-major "
64+
"(left-to-right, then top-to-bottom). "
65+
"For encoder/dial controllers it is a simple 0..N-1 dial index."
66+
),
5067
},
5168
"position": {
5269
"type": "string",
5370
"description": (
54-
"Native position string 'col,row'. "
71+
"Native position string 'col,row' within the chosen controller. "
5572
"Use this when you already know the grid slot."
5673
),
5774
},

0 commit comments

Comments
 (0)