From c603c9dbaa71086948fce38acbf9960ba19e5424 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Fri, 17 Apr 2026 19:07:35 +0200 Subject: [PATCH 1/2] feat(profile): preserve encoder controller and add Stream Deck + XL layout The profile writer previously assumed every page had a single Keypad controller: `_controller_actions` only read `Controllers[0]`, and `write_page` overwrote the controllers list with one Keypad entry. On Stream Deck + / + XL devices that silently wiped the Encoder controller (dial and touchstrip bindings) every time a keypad page was updated. - Introduce `controller` field ("keypad"/"encoder"/"dial") on the button schema so a single write_page call can target either controller. - Rework `write_page` to group incoming buttons by controller, update only the targeted controllers in-place, and leave all others intact. - Extend `read_page` to emit buttons from every controller with a `controller` label, plus a per-controller `layouts` map. - Add model `20GBX9901` (Stream Deck + XL: 8x4 keypad, 6x1 encoder) to MODEL_LAYOUTS so empty new pages validate against the real grid. - New tests cover multi-controller read, encoder preservation on keypad writes, encoder-targeted writes, and rejection of encoder writes on devices without a dial layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- profile_manager.py | 196 ++++++++++++++++++++++++---------- profile_server.py | 17 ++- tests/test_profile_manager.py | 185 ++++++++++++++++++++++++++++++++ 3 files changed, 339 insertions(+), 59 deletions(-) diff --git a/profile_manager.py b/profile_manager.py index fac1962..3e89c35 100644 --- a/profile_manager.py +++ b/profile_manager.py @@ -39,20 +39,35 @@ DEFAULT_TITLE_ALIGNMENT = "bottom" DEFAULT_ICON_SIZE = (72, 72) +KEYPAD = "Keypad" +ENCODER = "Encoder" + +CONTROLLER_ALIASES: dict[str, str] = { + "keypad": KEYPAD, + "key": KEYPAD, + "button": KEYPAD, + "encoder": ENCODER, + "dial": ENCODER, +} + DEFAULT_PAGE_MANIFEST = { "Controllers": [ { "Actions": None, - "Type": "Keypad", + "Type": KEYPAD, } ], "Icon": "", "Name": "", } -MODEL_LAYOUTS: dict[str, tuple[int, int]] = { - "20GBA9901": (5, 3), - "UI Stream Deck": (4, 2), +MODEL_LAYOUTS: dict[str, dict[str, tuple[int, int]]] = { + # Stream Deck (Original) + "20GBA9901": {KEYPAD: (5, 3)}, + # Stream Deck + XL (32 keys, 6 dials with 1200x100 touchstrip) + "20GBX9901": {KEYPAD: (8, 4), ENCODER: (6, 1)}, + # Emulator used by the Elgato desktop app + "UI Stream Deck": {KEYPAD: (4, 2)}, } HEX_COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$") @@ -183,12 +198,48 @@ def get_profiles_dir(version: str = "auto") -> Path: ) -def _controller_actions(page_manifest: dict[str, Any]) -> dict[str, Any]: - controllers = page_manifest.get("Controllers") or [] - if not controllers: +def _find_controller(page_manifest: dict[str, Any], controller_type: str) -> dict[str, Any] | None: + for controller in page_manifest.get("Controllers") or []: + if controller.get("Type") == controller_type: + return controller + return None + + +def _ensure_controller(page_manifest: dict[str, Any], controller_type: str) -> dict[str, Any]: + controllers = page_manifest.setdefault("Controllers", []) + for controller in controllers: + if controller.get("Type") == controller_type: + return controller + new_controller: dict[str, Any] = {"Type": controller_type, "Actions": None} + controllers.append(new_controller) + return new_controller + + +def _controller_actions( + page_manifest: dict[str, Any], controller_type: str = KEYPAD +) -> dict[str, Any]: + controller = _find_controller(page_manifest, controller_type) + if not controller: return {} - actions = controllers[0].get("Actions") - return actions or {} + return controller.get("Actions") or {} + + +def _normalize_controller(value: str | None) -> str: + if not value: + return KEYPAD + canonical = CONTROLLER_ALIASES.get(value.lower()) + if canonical is None: + raise ProfileValidationError( + f"Unknown controller '{value}'. Use one of: {sorted(set(CONTROLLER_ALIASES))}" + ) + return canonical + + +def _total_action_count(page_manifest: dict[str, Any]) -> int: + return sum( + len(controller.get("Actions") or {}) + for controller in page_manifest.get("Controllers") or [] + ) def _slugify(value: str) -> str: @@ -296,37 +347,44 @@ def read_page( directory_id=directory_id, ) page_manifest = _load_json(page_ref.manifest_path) - columns, rows = self._resolve_layout(profile_manifest, page_manifest) - - buttons = [] - for position, action in sorted( - _controller_actions(page_manifest).items(), - key=lambda item: self._position_sort_key(item[0]), - ): - col, row = [int(part) for part in position.split(",")] - key = (row * columns) + col - state_index = min( - max(int(action.get("State", 0)), 0), max(len(action.get("States", [{}])) - 1, 0) - ) - states = action.get("States") or [{}] - active_state = states[state_index] if states else {} - buttons.append( - { - "key": key, - "position": position, - "action_id": action.get("ActionID"), - "action_uuid": action.get("UUID"), - "plugin_uuid": action.get("Plugin", {}).get("UUID"), - "plugin_name": action.get("Plugin", {}).get("Name"), - "name": action.get("Name"), - "state": action.get("State", 0), - "title": active_state.get("Title"), - "image": active_state.get("Image"), - "settings": action.get("Settings", {}), - "show_title": active_state.get("ShowTitle"), - "raw": action, - } - ) + keypad_cols, keypad_rows = self._resolve_layout(profile_manifest, page_manifest, KEYPAD) + + buttons: list[dict[str, Any]] = [] + layouts: dict[str, dict[str, int]] = {} + + for controller in page_manifest.get("Controllers") or []: + controller_type = controller.get("Type", KEYPAD) + cols, rows = self._resolve_layout(profile_manifest, page_manifest, controller_type) + layouts[controller_type.lower()] = {"columns": cols, "rows": rows} + + actions = controller.get("Actions") or {} + for position, action in sorted( + actions.items(), + key=lambda item: self._position_sort_key(item[0]), + ): + col, row = [int(part) for part in position.split(",")] + key = (row * cols + col) if cols else col + states = action.get("States") or [{}] + state_index = min(max(int(action.get("State", 0)), 0), max(len(states) - 1, 0)) + active_state = states[state_index] if states else {} + buttons.append( + { + "controller": controller_type.lower(), + "key": key, + "position": position, + "action_id": action.get("ActionID"), + "action_uuid": action.get("UUID"), + "plugin_uuid": action.get("Plugin", {}).get("UUID"), + "plugin_name": action.get("Plugin", {}).get("Name"), + "name": action.get("Name"), + "state": action.get("State", 0), + "title": active_state.get("Title"), + "image": active_state.get("Image"), + "settings": action.get("Settings", {}), + "show_title": active_state.get("ShowTitle"), + "raw": action, + } + ) return { "profiles_root": self.profiles_dir.name, @@ -339,7 +397,8 @@ def read_page( "default_page_uuid": profile_manifest.get("Pages", {}).get("Default"), }, "page": page_ref.to_dict(), - "layout": {"columns": columns, "rows": rows}, + "layout": {"columns": keypad_cols, "rows": keypad_rows}, + "layouts": layouts, "buttons": buttons, "raw_manifest": page_manifest, } @@ -389,17 +448,35 @@ def write_page( if page_name is not None: page_manifest["Name"] = page_name - columns, rows = self._resolve_layout(profile_manifest, page_manifest) - actions = {} if clear_existing else copy.deepcopy(_controller_actions(page_manifest)) + # Group incoming buttons by the controller they target so a single write can + # update the Keypad and Encoder controllers together without touching the other. + buttons_by_controller: dict[str, list[dict[str, Any]]] = {} for button in buttons: - position = self._resolve_button_position(button, columns=columns, rows=rows) - actions[position] = self._materialize_action(button, page_dir) + controller_type = _normalize_controller(button.get("controller")) + buttons_by_controller.setdefault(controller_type, []).append(button) - controllers = page_manifest.setdefault("Controllers", [{"Type": "Keypad"}]) - if not controllers: - controllers.append({"Type": "Keypad"}) - controllers[0]["Type"] = controllers[0].get("Type", "Keypad") - controllers[0]["Actions"] = actions or None + layouts_out: dict[str, dict[str, int]] = {} + + for controller_type, ctl_buttons in buttons_by_controller.items(): + cols, rows = self._resolve_layout(profile_manifest, page_manifest, controller_type) + if cols <= 0 or rows <= 0: + raise ProfileValidationError( + f"Device model does not expose a '{controller_type}' controller." + ) + controller = _ensure_controller(page_manifest, controller_type) + existing = {} if clear_existing else copy.deepcopy(controller.get("Actions") or {}) + for button in ctl_buttons: + position = self._resolve_button_position(button, columns=cols, rows=rows) + existing[position] = self._materialize_action(button, page_dir) + controller["Actions"] = existing or None + layouts_out[controller_type.lower()] = {"columns": cols, "rows": rows} + + # New pages always carry a Keypad controller slot so the Elgato app can render them. + if create_new: + _ensure_controller(page_manifest, KEYPAD) + + primary_cols, primary_rows = self._resolve_layout(profile_manifest, page_manifest, KEYPAD) + total_button_count = _total_action_count(page_manifest) if create_new: pages_section = profile_manifest.setdefault("Pages", {}) @@ -430,8 +507,9 @@ def write_page( "page_index": None if create_new else page_index, "directory_id": page_dir.name, "page_uuid": page_uuid, - "layout": {"columns": columns, "rows": rows}, - "button_count": len(actions), + "layout": {"columns": primary_cols, "rows": primary_rows}, + "layouts": layouts_out, + "button_count": total_button_count, "page_name": page_manifest.get("Name", ""), "manifest_path": str(page_dir / "manifest.json"), } @@ -672,7 +750,6 @@ def _build_page_ref( is_current: bool, ) -> PageRef: page_manifest = _load_json(manifest_path) - actions = _controller_actions(page_manifest) return PageRef( page_index=page_index, directory_id=directory_id, @@ -683,7 +760,7 @@ def _build_page_ref( is_default=is_default, is_current=is_current, name=str(page_manifest.get("Name", "")), - button_count=len(actions), + button_count=_total_action_count(page_manifest), icon_count=_count_icons(manifest_path.parent), ) @@ -715,19 +792,24 @@ def _resolve_layout( self, profile_manifest: dict[str, Any], page_manifest: dict[str, Any] | None = None, + controller_type: str = KEYPAD, ) -> tuple[int, int]: device_model = str(profile_manifest.get("Device", {}).get("Model", "")) - if device_model in MODEL_LAYOUTS: - return MODEL_LAYOUTS[device_model] + model_entry = MODEL_LAYOUTS.get(device_model) + if model_entry and controller_type in model_entry: + return model_entry[controller_type] if page_manifest: - actions = _controller_actions(page_manifest) + actions = _controller_actions(page_manifest, controller_type) if actions: cols = max(int(position.split(",")[0]) for position in actions) + 1 rows = max(int(position.split(",")[1]) for position in actions) + 1 if cols > 0 and rows > 0: return cols, rows + if controller_type == ENCODER: + return (0, 0) + return (5, 3) def _resolve_button_position( diff --git a/profile_server.py b/profile_server.py index dfeaa1e..f83772e 100644 --- a/profile_server.py +++ b/profile_server.py @@ -43,14 +43,27 @@ async def list_tools() -> list[Tool]: button_schema = { "type": "object", "properties": { + "controller": { + "type": "string", + "description": ( + "Which physical controller this button targets. " + "'keypad' (default) addresses the LCD keys; " + "'encoder' (aka 'dial') addresses the rotary/touch dials on " + "Stream Deck + and + XL. " + "The key/position indexes are scoped to the chosen controller." + ), + }, "key": { "type": "integer", - "description": "Linear button index (0-based, left-to-right, top-to-bottom).", + "description": ( + "Linear button index scoped to the chosen controller (0-based, left-to-right). " + "For encoders this is the dial index." + ), }, "position": { "type": "string", "description": ( - "Native position string 'col,row'. " + "Native position string 'col,row' within the chosen controller. " "Use this when you already know the grid slot." ), }, diff --git a/tests/test_profile_manager.py b/tests/test_profile_manager.py index ee5b1b8..73e7137 100644 --- a/tests/test_profile_manager.py +++ b/tests/test_profile_manager.py @@ -364,3 +364,188 @@ def test_read_page_requires_locator(sample_profiles_v3: Path, tmp_path: Path) -> with pytest.raises(PageNotFoundError): manager.read_page(profile_name="Default Profile", directory_id="DOES-NOT-EXIST") + + +@pytest.fixture +def sample_profiles_plus_xl(tmp_path: Path) -> Path: + """Profile shaped like a Stream Deck + XL: Keypad 8x4 + Encoder 6x1 on the same page.""" + + profiles_dir = tmp_path / "ProfilesV3" + profile_dir = profiles_dir / "PLUSXL.sdProfile" + page_uuid = "dddddddd-dddd-dddd-dddd-dddddddddddd" + + profile_manifest = { + "AppIdentifier": "*", + "Device": {"Model": "20GBX9901", "UUID": "@(1)[4057/198/AD4MA610100UAO]"}, + "Name": "Plus XL", + "Pages": { + "Current": page_uuid, + "Default": page_uuid, + "Pages": [page_uuid], + }, + "Version": "3.0", + } + dial_action = { + "ActionID": "dial-volume", + "LinkedTitle": True, + "Name": "Volume", + "Plugin": {"Name": "Wave Link", "UUID": "com.elgato.wave-link", "Version": "1.0"}, + "Settings": {"channelId": "mic"}, + "State": 0, + "States": [{"Title": "Mic"}, {}], + "UUID": "com.elgato.wave-link.wavecontrol", + } + key_action = { + "ActionID": "action-open", + "LinkedTitle": False, + "Name": "Open", + "Plugin": { + "Name": "Open", + "UUID": "com.elgato.streamdeck.system.open", + "Version": "1.0", + }, + "Settings": {"path": '"/tmp/example.sh"'}, + "State": 0, + "States": [{"Title": "Run"}], + "UUID": "com.elgato.streamdeck.system.open", + } + page_manifest = { + "Controllers": [ + {"Type": "Keypad", "Actions": {"0,0": key_action}}, + {"Type": "Encoder", "Actions": {"2,0": dial_action}}, + ], + "Icon": "", + "Name": "Plus XL Page", + } + + _write_json(profile_dir / "manifest.json", profile_manifest) + _write_json( + profile_dir / "Profiles" / page_uuid.upper() / "manifest.json", + page_manifest, + ) + return profiles_dir + + +def test_read_page_returns_both_controllers(sample_profiles_plus_xl: Path, tmp_path: Path) -> None: + manager = ProfileManager( + profiles_dir=sample_profiles_plus_xl, + scripts_dir=tmp_path / "scripts", + generated_icons_dir=tmp_path / "icons", + ) + + page = manager.read_page(profile_name="Plus XL", page_index=0) + + assert page["layout"] == {"columns": 8, "rows": 4} + assert page["layouts"] == { + "keypad": {"columns": 8, "rows": 4}, + "encoder": {"columns": 6, "rows": 1}, + } + controllers = {button["controller"] for button in page["buttons"]} + assert controllers == {"keypad", "encoder"} + + dial = next(button for button in page["buttons"] if button["controller"] == "encoder") + assert dial["position"] == "2,0" + assert dial["key"] == 2 + assert dial["plugin_uuid"] == "com.elgato.wave-link" + + +def test_write_page_preserves_encoder_when_updating_keypad( + sample_profiles_plus_xl: Path, tmp_path: Path +) -> None: + manager = ProfileManager( + profiles_dir=sample_profiles_plus_xl, + scripts_dir=tmp_path / "scripts", + generated_icons_dir=tmp_path / "icons", + ) + + manager.write_page( + profile_name="Plus XL", + page_index=0, + buttons=[ + { + "key": 1, + "title": "Hello", + "path": str(tmp_path / "hello.sh"), + } + ], + ) + + page = manager.read_page(profile_name="Plus XL", page_index=0) + encoder_buttons = [b for b in page["buttons"] if b["controller"] == "encoder"] + assert len(encoder_buttons) == 1 + assert encoder_buttons[0]["plugin_uuid"] == "com.elgato.wave-link" + + keypad_buttons = [b for b in page["buttons"] if b["controller"] == "keypad"] + assert {b["position"] for b in keypad_buttons} == {"1,0"} + assert keypad_buttons[0]["title"] == "Hello" + + +def test_write_page_targets_encoder_controller( + sample_profiles_plus_xl: Path, tmp_path: Path +) -> None: + manager = ProfileManager( + profiles_dir=sample_profiles_plus_xl, + scripts_dir=tmp_path / "scripts", + generated_icons_dir=tmp_path / "icons", + ) + + manager.write_page( + profile_name="Plus XL", + page_index=0, + buttons=[ + { + "controller": "dial", + "key": 0, + "action_type": "next_page", + "title": "Next", + } + ], + clear_existing=False, + ) + + page = manager.read_page(profile_name="Plus XL", page_index=0) + encoder_positions = {b["position"] for b in page["buttons"] if b["controller"] == "encoder"} + assert encoder_positions == {"0,0", "2,0"} + + keypad_positions = {b["position"] for b in page["buttons"] if b["controller"] == "keypad"} + assert keypad_positions == {"0,0"} + + +def test_write_page_rejects_encoder_on_keypad_only_device( + sample_profiles_v3: Path, tmp_path: Path +) -> None: + manager = ProfileManager( + profiles_dir=sample_profiles_v3, + scripts_dir=tmp_path / "scripts", + generated_icons_dir=tmp_path / "icons", + ) + + with pytest.raises(ProfileValidationError, match="Encoder"): + manager.write_page( + profile_name="Default Profile", + directory_id="BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + buttons=[ + { + "controller": "encoder", + "key": 0, + "action_type": "next_page", + } + ], + ) + + +def test_write_page_rejects_unknown_controller( + sample_profiles_plus_xl: Path, tmp_path: Path +) -> None: + manager = ProfileManager( + profiles_dir=sample_profiles_plus_xl, + scripts_dir=tmp_path / "scripts", + generated_icons_dir=tmp_path / "icons", + ) + + with pytest.raises(ProfileValidationError, match="Unknown controller"): + manager.write_page( + profile_name="Plus XL", + page_index=0, + buttons=[{"controller": "mystery", "key": 0, "action_type": "next_page"}], + ) From 85e8cfb834db05b21770a40e3ef3215310538c53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:11:29 +0000 Subject: [PATCH 2/2] fix(profile): address review feedback on write_page, controller schema, and key description - Fix write_page() clear_existing=True regression: when buttons is empty, default to targeting Keypad so callers can clear a page with an empty list - Add enum constraint and default:'keypad' to controller field in MCP schema - Update key description to clarify row-major ordering for keypad vs simple 0..N-1 index for encoder/dial controllers - Add regression test for the empty-buttons clear_existing case (45 tests) Agent-Logs-Url: https://github.com/verygoodplugins/streamdeck-mcp/sessions/895ddc20-cfea-47c6-9dd3-35ce57f83fe7 Co-authored-by: jack-arturo <13076544+jack-arturo@users.noreply.github.com> --- profile_manager.py | 6 ++++++ profile_server.py | 8 ++++++-- tests/test_profile_manager.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/profile_manager.py b/profile_manager.py index 3e89c35..ed26eab 100644 --- a/profile_manager.py +++ b/profile_manager.py @@ -455,6 +455,12 @@ def write_page( controller_type = _normalize_controller(button.get("controller")) buttons_by_controller.setdefault(controller_type, []).append(button) + # When clear_existing is requested but no buttons were supplied, default to + # targeting the Keypad controller so that the caller can still clear a page + # by writing an empty button list (restores pre-multi-controller behaviour). + if clear_existing and not buttons_by_controller: + buttons_by_controller[KEYPAD] = [] + layouts_out: dict[str, dict[str, int]] = {} for controller_type, ctl_buttons in buttons_by_controller.items(): diff --git a/profile_server.py b/profile_server.py index f83772e..9fce0e5 100644 --- a/profile_server.py +++ b/profile_server.py @@ -45,6 +45,8 @@ async def list_tools() -> list[Tool]: "properties": { "controller": { "type": "string", + "enum": ["keypad", "key", "button", "encoder", "dial"], + "default": "keypad", "description": ( "Which physical controller this button targets. " "'keypad' (default) addresses the LCD keys; " @@ -56,8 +58,10 @@ async def list_tools() -> list[Tool]: "key": { "type": "integer", "description": ( - "Linear button index scoped to the chosen controller (0-based, left-to-right). " - "For encoders this is the dial index." + "Button index scoped to the chosen controller (0-based). " + "For keypad controllers the index is row-major " + "(left-to-right, then top-to-bottom). " + "For encoder/dial controllers it is a simple 0..N-1 dial index." ), }, "position": { diff --git a/tests/test_profile_manager.py b/tests/test_profile_manager.py index 73e7137..7646649 100644 --- a/tests/test_profile_manager.py +++ b/tests/test_profile_manager.py @@ -549,3 +549,38 @@ def test_write_page_rejects_unknown_controller( page_index=0, buttons=[{"controller": "mystery", "key": 0, "action_type": "next_page"}], ) + + +def test_write_page_clear_existing_with_empty_buttons_clears_keypad( + sample_profiles_v3: Path, tmp_path: Path +) -> None: + """Regression: clear_existing=True with an empty button list must clear the Keypad.""" + manager = ProfileManager( + profiles_dir=sample_profiles_v3, + scripts_dir=tmp_path / "scripts", + generated_icons_dir=tmp_path / "icons", + ) + + # Pre-populate a button so there is something to clear. + manager.write_page( + profile_name="Default Profile", + directory_id="BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + buttons=[{"key": 0, "title": "Before", "action_type": "next_page"}], + clear_existing=True, + ) + page = manager.read_page( + profile_name="Default Profile", directory_id="BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + ) + assert len(page["buttons"]) == 1 + + # Now clear with no buttons — the page should have no buttons afterwards. + manager.write_page( + profile_name="Default Profile", + directory_id="BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + buttons=[], + clear_existing=True, + ) + page = manager.read_page( + profile_name="Default Profile", directory_id="BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + ) + assert page["buttons"] == []