feat(profile): preserve encoder controller + Stream Deck + XL layout#15
Conversation
…ayout
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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Implements multi-controller (Keypad + Encoder/Dial) support in the profile manifest reader/writer to support Stream Deck + XL, while fixing a controller data-loss bug when writing keypad updates.
Changes:
- Fixes
write_page()to update only the targeted controller(s) instead of overwritingControllerswith a single Keypad entry. - Extends
read_page()to return buttons from all controllers and adds a per-controllerlayoutsmap (keepinglayoutas the keypad layout for compatibility). - Adds a Plus XL model layout entry and introduces a
controllerfield in the MCP button schema; adds tests covering multi-controller read/write behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
profile_manager.py |
Preserves and updates actions per controller; adds controller normalization/lookup helpers; adds model/controller-specific layouts. |
profile_server.py |
Extends MCP tool input schema to allow targeting keypad vs encoder/dial controllers. |
tests/test_profile_manager.py |
Adds Plus XL-shaped fixture and tests to validate multi-controller reads and preservation during writes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@copilot apply changes based on the comments in this thread |
…a, 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>
All three review comments addressed in commit 85e8cfb:
|
Co-authored-by: jack-arturo <13076544+jack-arturo@users.noreply.github.com>
…p path fix (#16) ## Summary Follow-up to #15. Closes a silent data-loss hole and fixes a pre-existing bug in the app-restart helper — both surfaced by the Phase 1 on-device smoke test. - **Data loss bug:** the Elgato desktop app caches every profile in memory and rewrites the on-disk manifests from its snapshot on quit. Edits made via `streamdeck_write_page` while the app is running are silently wiped the next time it closes (observed live during Phase 1 testing). This PR makes \`write_page\` refuse to run while the app is up and exposes a single additive \`auto_quit_app\` opt-in field for callers that want the tool to quit it for them (AppleScript → killall fallback). - **restart_app -600 bug:** \`open -a \"Stream Deck\"\` / \`open -a \"Elgato Stream Deck\"\` both fail with \`LSOpenURLsWithCompletionHandler -600\` on some macOS installs. Launching by explicit \`/Applications/Elgato Stream Deck.app\` path bypasses LaunchServices entirely. \`STREAMDECK_APP_PATH\` env var overrides the default for non-standard installs. - **Tool count unchanged** (still 6 tools). The new behavior is a single \`auto_quit_app: bool = false\` field on the existing \`streamdeck_write_page\` tool — per Jack's \"avoid tool bloat\" direction. ## Workflow (added to README) 1. \`streamdeck_write_page(auto_quit_app: true, …)\` — tool quits the app, writes, leaves app quit 2. Additional writes can follow without re-passing \`auto_quit_app\` (app stays quit) 3. \`streamdeck_restart_app()\` once done → device picks up the changes ## Test plan - [x] \`uv run pytest tests/\` — 44 passed (5 new) - [x] \`uv run ruff check .\` - [x] \`uv run ruff format --check .\` - [ ] Manual: reproduce the Phase 1 scenario end-to-end (app running → write fails with clear error → retry with auto_quit_app=true → write succeeds → restart_app shows the button on the deck) ## Dependencies Orthogonal to #15 — no merge-order requirement. Either can land first. 🤖 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>
) ## Summary The device model ID `20GBD9901` is currently mapped in `MODEL_LAYOUTS` to the Stream Deck Neo with a keypad-only layout: ```python # Stream Deck Neo (8 keys + touchscreen) "20GBD9901": {KEYPAD: (4, 2)}, ``` But an in-the-wild profile manifest written by the Elgato desktop app for this model contains a full `Type: "Encoder"` controller with 4 dial actions at positions `0,0`…`3,0` plus an 800×100 touchstrip `Background` image — matching the **original Stream Deck + (2022)**, not the Neo (which has no rotary encoders). Today, trying to write an encoder button to a `20GBD9901` profile raises: ``` ProfileValidationError: Device model does not expose a 'Encoder' controller. ``` This PR corrects the mapping so it matches the hardware actually in use. The existing `# NOTE` in `MODEL_LAYOUTS` predicted this exact case and spelled out the fix: > …add that model's ID here (with `ENCODER: (4, 1)` and `KEYPAD: (4, 2)`) when seen. ## Evidence Redacted excerpts from a real `20GBD9901` profile written by the Elgato desktop app: `ProfilesV3/<profile>.sdProfile/manifest.json`: ```json {"Device": {"Model": "20GBD9901", "UUID": "@(1)[4057/132/…]"}, …} ``` `ProfilesV3/<profile>.sdProfile/Profiles/<page>/manifest.json`: ```json { "Controllers": [ { "Type": "Encoder", "Background": "Images/…png", "Actions": { "0,0": {"Name": "Hotkey", …}, "1,0": {"Name": "Multimedia", …}, "2,0": {"Name": "Brightness", …}, "3,0": {"Name": "Hotkey", …} } }, {"Type": "Keypad", "Actions": {"0,0": …, "1,0": …, /* 8 entries across 4x2 */}} ] } ``` ## Changes - `profile_manager.py` — replace the Neo mapping for `20GBD9901` with the correct Stream Deck + layout (`KEYPAD: (4, 2)`, `ENCODER: (4, 1)`); remove the now-stale `# NOTE` that foreshadowed this change. - `profile_manager.py` — update `MODEL_NAMES["20GBD9901"]` from `Stream Deck Neo` → `Stream Deck +` so `streamdeck_read_profiles` reports the correct model name. - `tests/test_profile_manager.py` — add a `sample_profiles_plus` fixture modeled on `sample_profiles_plus_xl`, plus three regression tests: - `test_plus_device_exposes_encoder_layout` — `read_page` returns `{keypad: 4x2, encoder: 4x1}` - `test_plus_device_accepts_encoder_writes` — writing an encoder button to a `20GBD9901` profile succeeds and lands in the Encoder controller - `test_plus_device_reports_correct_model_name` — `list_profiles` surfaces `ModelName: "Stream Deck +"` ## Context — related work - #15 added the multi-controller write path this PR completes for the original Plus - #20 added per-segment encoder icon + touchstrip background writes - #21 added encoder layout variants The encoder-writing machinery already supports this device fully — it was just being rejected at the model-lookup layer before reaching any of it. ## Test plan - [x] `uv run pytest tests/ -q` — 84 passed (81 existing + 3 new) - [x] `uv run ruff check profile_manager.py tests/test_profile_manager.py` — clean - [x] End-to-end: installed this branch into a venv, ran a profile-writing script against a real `20GBD9901` profile — previously errored on every page with `Device model does not expose a 'Encoder' controller`, now writes successfully. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Jack Arturo <info@verygoodplugins.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🤖 I have created a release *beep* *boop* --- ## [0.3.0](v0.2.0...v0.3.0) (2026-04-28) ### Features * **icons:** bundle Material Design Icons for offline glyph rendering ([#19](#19)) ([459d410](459d410)) * **profile:** preserve encoder controller + Stream Deck + XL layout ([#15](#15)) ([f4a8402](f4a8402)) * **profile:** refuse writes while Elgato app is running + restart_app path fix ([#16](#16)) ([e1cb4e3](e1cb4e3)) * **skill:** streamdeck-designer Agent Skill + MCP transport hardening ([#22](#22)) ([fd3ec6f](fd3ec6f)) * **touchstrip:** expose $X1/$A0/$A1/$B1/$B2/$C1 encoder layouts ([#21](#21)) ([efa66e4](efa66e4)) * **touchstrip:** per-segment icon and background on Stream Deck + / + XL ([#20](#20)) ([a41a31c](a41a31c)) ### Bug Fixes * add Stream Deck XL and missing device layouts ([#10](#10)) ([c89435b](c89435b)) * **profile:** correct Stream Deck + XL keypad layout to 9x4 ([#18](#18)) ([d6da4b2](d6da4b2)) * **profile:** map 20GBD9901 to Stream Deck + (4 dials + 4x2 keypad) ([#25](#25)) ([da0ada5](da0ada5)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
profile_manager.write_pageoverwroteControllerswith a single Keypad entry, silently wiping Encoder (dial/touchstrip) bindings on every keypad update.controller: "keypad" | "encoder" | "dial"field on the button schema so a singlestreamdeck_write_pagecall can bind keys and dials in one go.streamdeck_read_pagenow returns buttons from every controller (each tagged withcontroller) and a per-controllerlayoutsmap;layoutstays as the keypad layout for back-compat.20GBX9901→ keypad 8×4 + encoder 6×1 toMODEL_LAYOUTSso 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.8doesn'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
uv run pytest tests/— 44 passed (5 new)uv run ruff check .uv run ruff format --check .controller: "dial"and confirm it appears in the Elgato app afterstreamdeck_restart_appFollow-ups (deferred)
streamdeck_create_icon_from_iconify)🤖 Generated with Claude Code