Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions profile_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,14 @@
"20GBX9901": {KEYPAD: (9, 4), ENCODER: (6, 1)},
# Stream Deck Mini (6 keys)
"20GAI9501": {KEYPAD: (3, 2)},
# Stream Deck Neo (8 keys + touchscreen)
"20GBD9901": {KEYPAD: (4, 2)},
# Stream Deck + (8 keys + 4 dials + 800x100 touchstrip, released 2022).
# Profile manifests for this model carry a full Encoder controller with
# 4 dial actions at 0,0…3,0 plus a touchstrip Background image, which
# is what the encoder layout below records.
"20GBD9901": {KEYPAD: (4, 2), ENCODER: (4, 1)},
Comment thread
jack-arturo marked this conversation as resolved.
# Emulator used by the Elgato desktop app ("UI" in older builds, "AI" in recent)
"UI Stream Deck": {KEYPAD: (4, 2)},
"AI Stream Deck": {KEYPAD: (4, 2)},
# NOTE: the original Stream Deck + (8 keys + 4 dials + 800x100 touchstrip,
# released 2022) has not yet been observed in a profile manifest and its
# Elgato-app product ID is unknown. When a profile reports an unknown Model
# with encoders present, _resolve_layout falls back to the 5x3 default — add
# that model's ID here (with ENCODER: (4, 1) and KEYPAD: (4, 2)) when seen.
}

# Human-readable names for the model IDs the Elgato desktop app writes to
Expand All @@ -101,7 +99,7 @@
"20GBA9902": "Stream Deck XL rev2",
"20GBX9901": "Stream Deck + XL",
"20GAI9501": "Stream Deck Mini",
"20GBD9901": "Stream Deck Neo",
"20GBD9901": "Stream Deck +",
"UI Stream Deck": "UI Stream Deck (emulator)",
"AI Stream Deck": "AI Stream Deck (virtual deck / Elgato app companion)",
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ Elgato's current lineup includes **four distinct SKUs with "+" or "XL" in the na
| Stream Deck Mini | `20GAI9501` | 3 × 2 = 6 keys | — | — | Travel; single-purpose dashboards; power user shortcuts |
| Stream Deck XL | `20GAT9902` | 8 × 4 = 32 keys | — | — | Large action surfaces; clustered workflows; streamer decks |
| Stream Deck XL rev2 | `20GBA9902` | 8 × 4 = 32 keys | — | — | Same hardware as XL, updated firmware |
| **Stream Deck +** (original Plus, 2022) | *ID unknown — see note* | 4 × 2 = 8 keys | **4 × 1** | 800 × 100 | Budget control surface with dials; older/smaller sibling of + XL |
| **Stream Deck +** (original Plus, 2022) | `20GBD9901` | 4 × 2 = 8 keys | **4 × 1** | 800 × 100 | Budget control surface with dials; older/smaller sibling of + XL |
| **Stream Deck + XL** (the big Plus, 2025) | `20GBX9901` | **9 × 4 = 36 keys** | **6 × 1** | **1200 × 100** | Pro streaming / control surfaces with 6 continuous knobs |
| Stream Deck Neo | `20GBD9901` | 4 × 2 = 8 keys | — | Embedded clock/weather strip (not separately addressable today) | Entry-level; always-on ambient bar |
| Stream Deck Neo | *ID unknown — see note* | 4 × 2 = 8 keys | — | Embedded clock/weather strip (not separately addressable today) | Entry-level; always-on ambient bar |
| UI Stream Deck / AI Stream Deck (emulators) | `"UI Stream Deck"`, `"AI Stream Deck"` | 4 × 2 = 8 keys | — | — | Elgato app's virtual companion deck (the app renamed the emulator from "UI" to "AI" in recent builds) |

**The "+ XL" and the original "+" are two different devices, not variants of one.** If `streamdeck_read_profiles` returns `ModelName: "Stream Deck + XL"`, the user has the 36-key / 6-dial version. If it says `Stream Deck +` (the original Plus), it's the 8-key / 4-dial version — half the keys, two-thirds the dials, a narrower touchstrip. Never pattern-match "+ XL" to "Plus (non-XL)."

**Status of the original + in this MCP**: the Elgato-app product ID for the original Stream Deck + has not yet been observed in any profile manifest this project has seen, so it's absent from `MODEL_LAYOUTS`. If `streamdeck_read_profiles` returns a `ModelName` of `Unknown Stream Deck model (<id>)` and the user says they have an original Plus, capture that product ID and add it to `MODEL_LAYOUTS` in a follow-up PR. Until then, profiles on that device will fall back to the 5×3 layout and you'll need to author against the 8-key/4-dial shape explicitly (use `position: "col,row"` with bounds 0–3 / 0–1 for keypad, 0–3 / 0 for encoders).
**Status of Stream Deck Neo in this MCP**: the Elgato-app product ID for Neo is not yet confirmed from an observed profile manifest. The ID previously assumed here — `20GBD9901` — turned out to be the original Stream Deck + (profile manifests for that model carry a 4-dial Encoder controller), so Neo is now absent from `MODEL_LAYOUTS`. If `streamdeck_read_profiles` returns `Unknown Stream Deck model (<id>)` and the user says they have a Neo, capture that product ID and add it to `MODEL_LAYOUTS` in a follow-up PR. Until then, profiles on that device will fall back to the 5×3 layout and you'll need to author against the 4×2 keypad shape explicitly (use `position: "col,row"` with bounds 0–3 / 0–1).

## Per-model authoring guidance

Expand All @@ -31,7 +31,7 @@ Every key matters. Resist the urge to fill all 15. A good setup is often 8–10
Space to organize. Use 4-key-wide clusters — column-groupings around a theme (a column for mic, a column for camera, a column for scenes, a column for effects). The two bottom corners (keys 24 and 31) are the most comfortable physically — reserve them for frequent-plus-meaningful actions.

### Stream Deck + (original Plus, 4×2 + 4 encoders + 800×100 touchstrip)
Not yet supported — the Elgato-app product ID is unknown to this MCP (no observed profile). If a user has one, author as if it were a 4×2 keypad + 4 encoders + 4-segment touchstrip (each segment 200×100). The design idioms from + XL apply at smaller scale: encoders for continuous values, keypad for discrete actions. Flag to the user that full layout validation against their device isn't possible until the product ID is added to `MODEL_LAYOUTS`.
Product ID `20GBD9901`. 4×2 keypad + 4 encoders + 4-segment touchstrip (each segment 200×100). The design idioms from + XL apply at smaller scale: encoders for continuous values, keypad for discrete actions. For the touchstrip, a single 800×100 background spans all four segments — use `shape="touchstrip"` with per-dial transparent icon overlays.

### Stream Deck + XL (9×4 + 6 encoders + 1200×100 touchstrip)
The encoders are the magic. Reach for them for **continuous values** (master volume, scene brightness, song position, room temperature, CPU throttle percentage). The keypad stays for discrete actions. The touch strip is six 200×100 segments, one per encoder, that show contextual info for the dial above.
Expand All @@ -45,7 +45,7 @@ Layout idioms that work:
Six slots. One dedicated dashboard, not a launcher. Examples: a travel deck (airplane mode, VPN, hotspot, flashlight, timer, compass); a focus deck (Pomodoro start, mute all, focus mode on, music play, lights dim, done).

### Stream Deck Neo (4×2)
Eight keys + a built-in info bar with clock/weather (rendered by the Elgato app, not currently addressable via streamdeck-mcp). Design as if the info bar is always there — don't duplicate clock/weather in your layout. Key layout is cramped enough that you want 4 pressable actions + 4 navigation keys, not 8 flat actions.
Eight keys + a built-in info bar with clock/weather (rendered by the Elgato app, not currently addressable via streamdeck-mcp). Design as if the info bar is always there — don't duplicate clock/weather in your layout. Key layout is cramped enough that you want 4 pressable actions + 4 navigation keys, not 8 flat actions. Note: Neo's Elgato-app product ID is not yet known to this MCP (see "Status of Stream Deck Neo" above).

## Button indexing

Expand Down
132 changes: 132 additions & 0 deletions tests/test_profile_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,138 @@ def sample_profiles_plus_xl(tmp_path: Path) -> Path:
return profiles_dir


@pytest.fixture
def sample_profiles_plus(tmp_path: Path) -> Path:
"""Profile shaped like an original Stream Deck +: Keypad 4x2 + Encoder 4x1 on the same page."""

profiles_dir = tmp_path / "ProfilesV3"
profile_dir = profiles_dir / "PLUS.sdProfile"
page_uuid = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"

profile_manifest = {
"AppIdentifier": "*",
"Device": {"Model": "20GBD9901", "UUID": "@(1)[4057/132/A5Z5A43312X46E]"},
"Name": "Plus",
"Pages": {
"Current": page_uuid,
"Default": page_uuid,
"Pages": [page_uuid],
},
"Version": "3.0",
}
dial_action = {
"ActionID": "dial-brightness",
"LinkedTitle": True,
"Name": "Brightness",
"Plugin": {
"Name": "Brightness",
"UUID": "com.elgato.streamdeck.system.keybrightness",
"Version": "1.0",
},
"Settings": {"actionIdx": 0},
"State": 0,
"States": [{"Title": "SD+ Brightness"}],
"UUID": "com.elgato.streamdeck.system.keybrightness",
}
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 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_plus_device_exposes_encoder_layout(sample_profiles_plus: Path, tmp_path: Path) -> None:
"""20GBD9901 (Stream Deck +) must expose a 4x1 Encoder alongside its 4x2 Keypad."""

manager = ProfileManager(
profiles_dir=sample_profiles_plus,
scripts_dir=tmp_path / "scripts",
generated_icons_dir=tmp_path / "icons",
)

page = manager.read_page(profile_name="Plus", page_index=0)

assert page["layout"] == {"columns": 4, "rows": 2}
assert page["layouts"] == {
"keypad": {"columns": 4, "rows": 2},
"encoder": {"columns": 4, "rows": 1},
}
controllers = {button["controller"] for button in page["buttons"]}
assert controllers == {"keypad", "encoder"}


def test_plus_device_accepts_encoder_writes(
sample_profiles_plus: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Writing an encoder button to a 20GBD9901 profile must land in the Encoder controller,
not raise ``Device model does not expose a 'Encoder' controller``."""

monkeypatch.setattr("profile_manager.get_plugins_dir", lambda: tmp_path / "plugins")
manager = ProfileManager(
profiles_dir=sample_profiles_plus,
scripts_dir=tmp_path / "scripts",
generated_icons_dir=tmp_path / "icons",
)

manager.write_page(
profile_name="Plus",
page_index=0,
buttons=[
{
"controller": "dial",
"key": 3,
"action_type": "next_page",
"title": "Next",
}
],
clear_existing=False,
)

page = manager.read_page(profile_name="Plus", page_index=0)
encoder_positions = {b["position"] for b in page["buttons"] if b["controller"] == "encoder"}
assert encoder_positions == {"2,0", "3,0"}


def test_plus_device_reports_correct_model_name(
sample_profiles_plus: Path, tmp_path: Path
) -> None:
"""20GBD9901 must surface as 'Stream Deck +' (not 'Stream Deck Neo')."""

manager = ProfileManager(
profiles_dir=sample_profiles_plus,
scripts_dir=tmp_path / "scripts",
generated_icons_dir=tmp_path / "icons",
)

profiles = manager.list_profiles()
assert len(profiles) == 1
assert profiles[0]["device"]["ModelName"] == "Stream Deck +"


def test_read_page_returns_both_controllers(sample_profiles_plus_xl: Path, tmp_path: Path) -> None:
manager = ProfileManager(
profiles_dir=sample_profiles_plus_xl,
Expand Down
Loading