Skip to content

Commit f4a8402

Browse files
jack-arturoclaudeCopilot
authored
feat(profile): preserve encoder controller + Stream Deck + XL layout (#15)
## Summary Phase 1 of Stream Deck + XL support. Fixes a data-loss bug in the profile writer and teaches it about the new 32-key / 6-dial / 1200×100 touchstrip hardware. - **Bug:** `profile_manager.write_page` overwrote `Controllers` with a single Keypad entry, silently wiping Encoder (dial/touchstrip) bindings on every keypad update. - **Fix:** Group incoming buttons by controller, edit only the targeted controller in place, leave all others untouched. - **New:** `controller: "keypad" | "encoder" | "dial"` field on the button schema so a single `streamdeck_write_page` call can bind keys and dials in one go. - **Read side:** `streamdeck_read_page` now returns buttons from every controller (each tagged with `controller`) and a per-controller `layouts` map; `layout` stays as the keypad layout for back-compat. - **Layouts:** added `20GBX9901` → keypad 8×4 + encoder 6×1 to `MODEL_LAYOUTS` so empty pages validate against the real grid instead of the 5×3 fallback. USB-direct path (`server.py`) is untouched — `python-elgato-streamdeck 0.9.8` doesn't know Plus XL's USB PID yet, so that path can't enumerate the device. The profile-manifest writer is the right path for this hardware and doesn't depend on library support. ## Test plan - [x] `uv run pytest tests/` — 44 passed (5 new) - [x] `uv run ruff check .` - [x] `uv run ruff format --check .` - [ ] Manual: write a keypad button via MCP on a real Plus XL profile and confirm existing dial bindings survive - [ ] Manual: bind a dial via `controller: "dial"` and confirm it appears in the Elgato app after `streamdeck_restart_app` ## Follow-ups (deferred) - Phase 2: Iconify icon generator (`streamdeck_create_icon_from_iconify`) - Phase 3: touchstrip image writes (1200×100 split across 6 zones) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jack-arturo <13076544+jack-arturo@users.noreply.github.com>
1 parent 42f0709 commit f4a8402

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
@@ -39,20 +39,35 @@
3939
DEFAULT_TITLE_ALIGNMENT = "bottom"
4040
DEFAULT_ICON_SIZE = (72, 72)
4141

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

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

5873
HEX_COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$")
@@ -183,12 +198,48 @@ def get_profiles_dir(version: str = "auto") -> Path:
183198
)
184199

185200

186-
def _controller_actions(page_manifest: dict[str, Any]) -> dict[str, Any]:
187-
controllers = page_manifest.get("Controllers") or []
188-
if not controllers:
201+
def _find_controller(page_manifest: dict[str, Any], controller_type: str) -> dict[str, Any] | None:
202+
for controller in page_manifest.get("Controllers") or []:
203+
if controller.get("Type") == controller_type:
204+
return controller
205+
return None
206+
207+
208+
def _ensure_controller(page_manifest: dict[str, Any], controller_type: str) -> dict[str, Any]:
209+
controllers = page_manifest.setdefault("Controllers", [])
210+
for controller in controllers:
211+
if controller.get("Type") == controller_type:
212+
return controller
213+
new_controller: dict[str, Any] = {"Type": controller_type, "Actions": None}
214+
controllers.append(new_controller)
215+
return new_controller
216+
217+
218+
def _controller_actions(
219+
page_manifest: dict[str, Any], controller_type: str = KEYPAD
220+
) -> dict[str, Any]:
221+
controller = _find_controller(page_manifest, controller_type)
222+
if not controller:
189223
return {}
190-
actions = controllers[0].get("Actions")
191-
return actions or {}
224+
return controller.get("Actions") or {}
225+
226+
227+
def _normalize_controller(value: str | None) -> str:
228+
if not value:
229+
return KEYPAD
230+
canonical = CONTROLLER_ALIASES.get(value.lower())
231+
if canonical is None:
232+
raise ProfileValidationError(
233+
f"Unknown controller '{value}'. Use one of: {sorted(set(CONTROLLER_ALIASES))}"
234+
)
235+
return canonical
236+
237+
238+
def _total_action_count(page_manifest: dict[str, Any]) -> int:
239+
return sum(
240+
len(controller.get("Actions") or {})
241+
for controller in page_manifest.get("Controllers") or []
242+
)
192243

193244

194245
def _slugify(value: str) -> str:
@@ -296,37 +347,44 @@ def read_page(
296347
directory_id=directory_id,
297348
)
298349
page_manifest = _load_json(page_ref.manifest_path)
299-
columns, rows = self._resolve_layout(profile_manifest, page_manifest)
300-
301-
buttons = []
302-
for position, action in sorted(
303-
_controller_actions(page_manifest).items(),
304-
key=lambda item: self._position_sort_key(item[0]),
305-
):
306-
col, row = [int(part) for part in position.split(",")]
307-
key = (row * columns) + col
308-
state_index = min(
309-
max(int(action.get("State", 0)), 0), max(len(action.get("States", [{}])) - 1, 0)
310-
)
311-
states = action.get("States") or [{}]
312-
active_state = states[state_index] if states else {}
313-
buttons.append(
314-
{
315-
"key": key,
316-
"position": position,
317-
"action_id": action.get("ActionID"),
318-
"action_uuid": action.get("UUID"),
319-
"plugin_uuid": action.get("Plugin", {}).get("UUID"),
320-
"plugin_name": action.get("Plugin", {}).get("Name"),
321-
"name": action.get("Name"),
322-
"state": action.get("State", 0),
323-
"title": active_state.get("Title"),
324-
"image": active_state.get("Image"),
325-
"settings": action.get("Settings", {}),
326-
"show_title": active_state.get("ShowTitle"),
327-
"raw": action,
328-
}
329-
)
350+
keypad_cols, keypad_rows = self._resolve_layout(profile_manifest, page_manifest, KEYPAD)
351+
352+
buttons: list[dict[str, Any]] = []
353+
layouts: dict[str, dict[str, int]] = {}
354+
355+
for controller in page_manifest.get("Controllers") or []:
356+
controller_type = controller.get("Type", KEYPAD)
357+
cols, rows = self._resolve_layout(profile_manifest, page_manifest, controller_type)
358+
layouts[controller_type.lower()] = {"columns": cols, "rows": rows}
359+
360+
actions = controller.get("Actions") or {}
361+
for position, action in sorted(
362+
actions.items(),
363+
key=lambda item: self._position_sort_key(item[0]),
364+
):
365+
col, row = [int(part) for part in position.split(",")]
366+
key = (row * cols + col) if cols else col
367+
states = action.get("States") or [{}]
368+
state_index = min(max(int(action.get("State", 0)), 0), max(len(states) - 1, 0))
369+
active_state = states[state_index] if states else {}
370+
buttons.append(
371+
{
372+
"controller": controller_type.lower(),
373+
"key": key,
374+
"position": position,
375+
"action_id": action.get("ActionID"),
376+
"action_uuid": action.get("UUID"),
377+
"plugin_uuid": action.get("Plugin", {}).get("UUID"),
378+
"plugin_name": action.get("Plugin", {}).get("Name"),
379+
"name": action.get("Name"),
380+
"state": action.get("State", 0),
381+
"title": active_state.get("Title"),
382+
"image": active_state.get("Image"),
383+
"settings": action.get("Settings", {}),
384+
"show_title": active_state.get("ShowTitle"),
385+
"raw": action,
386+
}
387+
)
330388

331389
return {
332390
"profiles_root": self.profiles_dir.name,
@@ -339,7 +397,8 @@ def read_page(
339397
"default_page_uuid": profile_manifest.get("Pages", {}).get("Default"),
340398
},
341399
"page": page_ref.to_dict(),
342-
"layout": {"columns": columns, "rows": rows},
400+
"layout": {"columns": keypad_cols, "rows": keypad_rows},
401+
"layouts": layouts,
343402
"buttons": buttons,
344403
"raw_manifest": page_manifest,
345404
}
@@ -389,17 +448,41 @@ def write_page(
389448
if page_name is not None:
390449
page_manifest["Name"] = page_name
391450

392-
columns, rows = self._resolve_layout(profile_manifest, page_manifest)
393-
actions = {} if clear_existing else copy.deepcopy(_controller_actions(page_manifest))
451+
# Group incoming buttons by the controller they target so a single write can
452+
# update the Keypad and Encoder controllers together without touching the other.
453+
buttons_by_controller: dict[str, list[dict[str, Any]]] = {}
394454
for button in buttons:
395-
position = self._resolve_button_position(button, columns=columns, rows=rows)
396-
actions[position] = self._materialize_action(button, page_dir)
455+
controller_type = _normalize_controller(button.get("controller"))
456+
buttons_by_controller.setdefault(controller_type, []).append(button)
457+
458+
# When clear_existing is requested but no buttons were supplied, default to
459+
# targeting the Keypad controller so that the caller can still clear a page
460+
# by writing an empty button list (restores pre-multi-controller behaviour).
461+
if clear_existing and not buttons_by_controller:
462+
buttons_by_controller[KEYPAD] = []
397463

398-
controllers = page_manifest.setdefault("Controllers", [{"Type": "Keypad"}])
399-
if not controllers:
400-
controllers.append({"Type": "Keypad"})
401-
controllers[0]["Type"] = controllers[0].get("Type", "Keypad")
402-
controllers[0]["Actions"] = actions or None
464+
layouts_out: dict[str, dict[str, int]] = {}
465+
466+
for controller_type, ctl_buttons in buttons_by_controller.items():
467+
cols, rows = self._resolve_layout(profile_manifest, page_manifest, controller_type)
468+
if cols <= 0 or rows <= 0:
469+
raise ProfileValidationError(
470+
f"Device model does not expose a '{controller_type}' controller."
471+
)
472+
controller = _ensure_controller(page_manifest, controller_type)
473+
existing = {} if clear_existing else copy.deepcopy(controller.get("Actions") or {})
474+
for button in ctl_buttons:
475+
position = self._resolve_button_position(button, columns=cols, rows=rows)
476+
existing[position] = self._materialize_action(button, page_dir)
477+
controller["Actions"] = existing or None
478+
layouts_out[controller_type.lower()] = {"columns": cols, "rows": rows}
479+
480+
# New pages always carry a Keypad controller slot so the Elgato app can render them.
481+
if create_new:
482+
_ensure_controller(page_manifest, KEYPAD)
483+
484+
primary_cols, primary_rows = self._resolve_layout(profile_manifest, page_manifest, KEYPAD)
485+
total_button_count = _total_action_count(page_manifest)
403486

404487
if create_new:
405488
pages_section = profile_manifest.setdefault("Pages", {})
@@ -430,8 +513,9 @@ def write_page(
430513
"page_index": None if create_new else page_index,
431514
"directory_id": page_dir.name,
432515
"page_uuid": page_uuid,
433-
"layout": {"columns": columns, "rows": rows},
434-
"button_count": len(actions),
516+
"layout": {"columns": primary_cols, "rows": primary_rows},
517+
"layouts": layouts_out,
518+
"button_count": total_button_count,
435519
"page_name": page_manifest.get("Name", ""),
436520
"manifest_path": str(page_dir / "manifest.json"),
437521
}
@@ -672,7 +756,6 @@ def _build_page_ref(
672756
is_current: bool,
673757
) -> PageRef:
674758
page_manifest = _load_json(manifest_path)
675-
actions = _controller_actions(page_manifest)
676759
return PageRef(
677760
page_index=page_index,
678761
directory_id=directory_id,
@@ -683,7 +766,7 @@ def _build_page_ref(
683766
is_default=is_default,
684767
is_current=is_current,
685768
name=str(page_manifest.get("Name", "")),
686-
button_count=len(actions),
769+
button_count=_total_action_count(page_manifest),
687770
icon_count=_count_icons(manifest_path.parent),
688771
)
689772

@@ -715,19 +798,24 @@ def _resolve_layout(
715798
self,
716799
profile_manifest: dict[str, Any],
717800
page_manifest: dict[str, Any] | None = None,
801+
controller_type: str = KEYPAD,
718802
) -> tuple[int, int]:
719803
device_model = str(profile_manifest.get("Device", {}).get("Model", ""))
720-
if device_model in MODEL_LAYOUTS:
721-
return MODEL_LAYOUTS[device_model]
804+
model_entry = MODEL_LAYOUTS.get(device_model)
805+
if model_entry and controller_type in model_entry:
806+
return model_entry[controller_type]
722807

723808
if page_manifest:
724-
actions = _controller_actions(page_manifest)
809+
actions = _controller_actions(page_manifest, controller_type)
725810
if actions:
726811
cols = max(int(position.split(",")[0]) for position in actions) + 1
727812
rows = max(int(position.split(",")[1]) for position in actions) + 1
728813
if cols > 0 and rows > 0:
729814
return cols, rows
730815

816+
if controller_type == ENCODER:
817+
return (0, 0)
818+
731819
return (5, 3)
732820

733821
def _resolve_button_position(

profile_server.py

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

0 commit comments

Comments
 (0)