Skip to content

feat(profile): preserve encoder controller + Stream Deck + XL layout#15

Merged
jack-arturo merged 2 commits into
mainfrom
feat/plus-xl-multi-controller
Apr 17, 2026
Merged

feat(profile): preserve encoder controller + Stream Deck + XL layout#15
jack-arturo merged 2 commits into
mainfrom
feat/plus-xl-multi-controller

Conversation

@jack-arturo
Copy link
Copy Markdown
Member

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

  • uv run pytest tests/ — 44 passed (5 new)
  • uv run ruff check .
  • 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

…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>
Copilot AI review requested due to automatic review settings April 17, 2026 17:08
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 overwriting Controllers with a single Keypad entry.
  • Extends read_page() to return buttons from all controllers and adds a per-controller layouts map (keeping layout as the keypad layout for compatibility).
  • Adds a Plus XL model layout entry and introduces a controller field 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.

Comment thread profile_manager.py
Comment thread profile_server.py
Comment thread profile_server.py Outdated
@jack-arturo
Copy link
Copy Markdown
Member Author

@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>
Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 17, 2026

@copilot apply changes based on the comments in this thread

All three review comments addressed in commit 85e8cfb:

  1. write_page() empty-buttons regression – when clear_existing=True and buttons=[], the code now defaults to targeting the Keypad controller so the existing behaviour (clearing a page with an empty list) is preserved. Added a regression test to lock this in.

  2. controller schema – added "enum": ["keypad", "key", "button", "encoder", "dial"] and "default": "keypad" to the field definition so invalid values are rejected before they reach the runtime.

  3. key description – updated to explicitly state row-major ordering (left-to-right, then top-to-bottom) for keypad controllers and a simple 0..N-1 index for encoder/dial controllers.

@jack-arturo jack-arturo merged commit f4a8402 into main Apr 17, 2026
6 checks passed
Copilot AI added a commit that referenced this pull request Apr 17, 2026
Co-authored-by: jack-arturo <13076544+jack-arturo@users.noreply.github.com>
jack-arturo added a commit that referenced this pull request Apr 17, 2026
…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>
jack-arturo added a commit that referenced this pull request Apr 21, 2026
)

## 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>
jack-arturo added a commit that referenced this pull request Apr 28, 2026
🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants