Skip to content

Commit efa66e4

Browse files
jack-arturoclaudeCopilot
authored
feat(touchstrip): expose $X1/$A0/$A1/$B1/$B2/$C1 encoder layouts (#21)
## Summary Opts callers into Elgato's six built-in touchstrip layouts (`$X1` / `$A0` / `$A1` / `$B1` / `$B2` / `$C1`) via a new `encoder_layout` field on `streamdeck_write_page`. Unlocks progress bars ($B1/$B2), icon+value readouts ($A1), dual-row ($C1), full-canvas image ($A0), and centered-icon ($X1) composition on the Stream Deck + / + XL touchstrip. The default action (no layout declared) is unchanged — callers who rely on full-strip `Controllers[Encoder].Background` show-through keep that behavior unless they explicitly pick a layout variant. Picking a variant forgoes show-through by Elgato design (a declared layout replaces the default composition). ## Design Static multi-UUID action variants. Each of the six layouts gets its own action UUID in the bundled plugin manifest with `Encoder.layout` declared — the Elgato-documented, durable site. No plugin-JS changes (stays the no-op registration shell from #20). No per-instance `Controllers[Encoder].Layout` writes (undocumented; stripping risk à la PR #20's `Resources.Encoder.*` lesson). | Action UUID suffix | Layout | Effect | |---|---|---| | `.dial` (default) | *none* | Elgato default composition, full-strip show-through | | `.dial.x1` | `$X1` | Title + centered icon | | `.dial.a0` | `$A0` | Title + full-width image canvas | | `.dial.a1` | `$A1` | Title + icon + value slot | | `.dial.b1` | `$B1` | Title + icon + progress bar | | `.dial.b2` | `$B2` | Title + icon + gradient progress | | `.dial.c1` | `$C1` | Dual icon/progress rows | Plugin manifest version bumped `0.1.0 → 0.2.0`. ## API ```jsonc { "controller": "encoder", "key": 0, "title": "Volume", "icon_path": "/path/to/72x72.png", "encoder_layout": "$A1" // ← new, optional, enum } ``` - `encoder_layout` is encoder-only (keypad buttons reject it). - Unknown values rejected with the supported-enum in the error message. - Cannot combine with `path`/`action_type`/`plugin_uuid`/`action_uuid` — it's a convenience field for the built-in MCP dial; advanced callers who specify their own plugin/action should declare layout in their own plugin manifest. ## Out of scope (deliberately deferred) - **Custom JSON layouts.** Clean support needs either (a) a generic action with JS that reads `Settings.layoutPath` — which reintroduces the JS-logic coupling #20 deliberately avoided — or (b) one action UUID per shipped custom layout, premature without a concrete layout in hand. - **Runtime `setFeedbackLayout`.** Overlaps with the future `setFeedback` dynamic-value experiment. Build once there's a live-value use case. ## Test plan - [x] `uv run pytest tests/` — 69 passing (64 + 5 new) - [x] `uv run ruff check .` — clean - [x] `uv build --wheel` — all 7 action entries ship inside the plugin bundle, plugin manifest version 0.2.0 - [x] On-device (Stream Deck + XL): write one page with six encoder buttons (one per layout variant), visually confirm each renders with the declared composition, quit + relaunch Elgato app, confirm all layouts persist - [x] On-device: confirm default (no `encoder_layout`) still shows full-strip background show-through New tests: - `test_write_page_encoder_layout_routes_to_variant_uuid` — `$A1` → `.dial.a1` UUID - `test_write_page_encoder_layout_default_uses_default_uuid` — omit → `.dial` UUID - `test_write_page_rejects_unknown_encoder_layout` — `$Z9` raises with supported enum - `test_write_page_rejects_encoder_layout_on_keypad` — parallel to `strip_background_path` keypad-rejection - `test_write_page_auto_installs_mcp_plugin_for_layout_variant` — variant triggers plugin install 🤖 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 bf6c276 commit efa66e4

6 files changed

Lines changed: 420 additions & 8 deletions

File tree

profile_manager.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -336,17 +336,30 @@ def ensure_mcp_plugin_installed(*, force: bool = False) -> dict[str, Any]:
336336
action whose plugin does not declare encoder support.
337337
338338
Idempotent: returns ``installed=False`` when the plugin directory already
339-
exists, unless ``force=True`` is passed.
339+
exists at the current bundled version, unless ``force=True`` is passed.
340+
Automatically upgrades when the installed manifest version is older than the
341+
bundled version so that new action UUIDs (e.g. layout variants) are available.
340342
"""
341343
from importlib.resources import as_file, files
342344

343-
from streamdeck_plugin import PLUGIN_DIR_NAME
345+
from streamdeck_plugin import PLUGIN_DIR_NAME, PLUGIN_VERSION
344346

345347
plugins_dir = get_plugins_dir()
346348
dst = plugins_dir / PLUGIN_DIR_NAME
347349

348350
if dst.exists() and not force:
349-
return {"installed": False, "reason": "already installed", "path": str(dst)}
351+
installed_version: str | None = None
352+
try:
353+
installed_manifest = dst / "manifest.json"
354+
installed_version = json.loads(installed_manifest.read_text(encoding="utf-8")).get(
355+
"Version"
356+
)
357+
except Exception:
358+
pass
359+
360+
if installed_version == PLUGIN_VERSION:
361+
return {"installed": False, "reason": "already installed", "path": str(dst)}
362+
# Installed version is missing or outdated — fall through to reinstall.
350363

351364
plugins_dir.mkdir(parents=True, exist_ok=True)
352365
if dst.exists():
@@ -1200,6 +1213,10 @@ def _materialize_action(
12001213
raise ProfileValidationError(
12011214
"strip_background_path is only valid for encoder/dial buttons."
12021215
)
1216+
if button.get("encoder_layout") is not None:
1217+
raise ProfileValidationError(
1218+
"encoder_layout is only valid for encoder/dial buttons."
1219+
)
12031220
if icon_path:
12041221
state_data["Image"] = self._copy_icon_to_page(
12051222
Path(icon_path).expanduser(), page_dir
@@ -1212,6 +1229,18 @@ def _materialize_action(
12121229
def _build_action_from_fields(
12131230
self, button: dict[str, Any], *, controller_type: str = KEYPAD
12141231
) -> dict[str, Any]:
1232+
encoder_layout = button.get("encoder_layout")
1233+
if encoder_layout is not None and controller_type != ENCODER:
1234+
raise ProfileValidationError(
1235+
"encoder_layout is only valid for encoder/dial buttons."
1236+
)
1237+
if encoder_layout is not None and any(
1238+
button.get(k) for k in ("path", "action_type", "plugin_uuid", "action_uuid")
1239+
):
1240+
raise ProfileValidationError(
1241+
"encoder_layout is a convenience field for the built-in MCP dial. "
1242+
"Do not combine with path/action_type/plugin_uuid/action_uuid."
1243+
)
12151244
action_type = button.get("action_type")
12161245
if action_type == "next_page":
12171246
return self._build_navigation_action(direction="next")
@@ -1295,12 +1324,31 @@ def install_mcp_plugin(self, *, force: bool = False) -> dict[str, Any]:
12951324
return ensure_mcp_plugin_installed(force=force)
12961325

12971326
def _build_mcp_dial_action(self, button: dict[str, Any]) -> dict[str, Any]:
1298-
from streamdeck_plugin import ACTION_UUID, PLUGIN_UUID, PLUGIN_VERSION
1327+
from streamdeck_plugin import (
1328+
DEFAULT_ACTION_UUID,
1329+
LAYOUT_ACTION_UUIDS,
1330+
PLUGIN_UUID,
1331+
PLUGIN_VERSION,
1332+
SUPPORTED_ENCODER_LAYOUTS,
1333+
)
1334+
1335+
encoder_layout = button.get("encoder_layout")
1336+
if encoder_layout is not None:
1337+
if encoder_layout not in LAYOUT_ACTION_UUIDS:
1338+
raise ProfileValidationError(
1339+
f"Unknown encoder_layout '{encoder_layout}'. "
1340+
f"Supported: {', '.join(SUPPORTED_ENCODER_LAYOUTS)}."
1341+
)
1342+
action_uuid = LAYOUT_ACTION_UUIDS[encoder_layout]
1343+
name = f"MCP Dial ({encoder_layout})"
1344+
else:
1345+
action_uuid = DEFAULT_ACTION_UUID
1346+
name = "MCP Dial"
12991347

13001348
return {
13011349
"ActionID": str(uuid.uuid4()),
13021350
"LinkedTitle": False,
1303-
"Name": "MCP Dial",
1351+
"Name": name,
13041352
"Plugin": {
13051353
"Name": "streamdeck-mcp",
13061354
"UUID": PLUGIN_UUID,
@@ -1309,7 +1357,7 @@ def _build_mcp_dial_action(self, button: dict[str, Any]) -> dict[str, Any]:
13091357
"Settings": copy.deepcopy(button.get("settings", {})),
13101358
"State": 0,
13111359
"States": [{}],
1312-
"UUID": ACTION_UUID,
1360+
"UUID": action_uuid,
13131361
}
13141362

13151363
def _build_navigation_action(self, *, direction: str) -> dict[str, Any]:

profile_server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,21 @@ async def list_tools() -> list[Tool]:
9393
"keypad button is an error."
9494
),
9595
},
96+
"encoder_layout": {
97+
"type": "string",
98+
"enum": ["$X1", "$A0", "$A1", "$B1", "$B2", "$C1"],
99+
"description": (
100+
"Encoder/dial only: built-in Elgato touchstrip layout. Omit for "
101+
"the default (no declared layout → Elgato default composition with "
102+
"full-strip background show-through). Choose a variant for plugin-"
103+
"rendered layouts: $X1 (title + centered icon), $A0 (title + full-"
104+
"width image), $A1 (title + icon + value slot), $B1 (progress bar), "
105+
"$B2 (gradient progress), $C1 (dual icon/progress rows). Selecting "
106+
"a layout forgoes strip-background show-through — the declared "
107+
"layout replaces the default composition. Do not combine with "
108+
"path/action_type/plugin_uuid/action_uuid."
109+
),
110+
},
96111
"path": {
97112
"type": "string",
98113
"description": (

skills/streamdeck-profile/SKILL.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,16 +81,21 @@ These are the source of truth for manifest schemas, image dimensions, and touchs
8181

8282
### Built-in touchstrip layouts
8383

84+
Pass `encoder_layout: "$A1"` (etc.) on an encoder button in `streamdeck_write_page` to opt into a layout. Omit `encoder_layout` for the default (Elgato default composition with full-strip background show-through). Picking a variant forgoes show-through — the declared layout replaces the default composition.
85+
8486
| Layout | Semantics |
8587
|--------|-----------|
88+
| *(omit)* | Default: icon over `Encoder.background`, full-strip `Controllers[Encoder].Background` shows through |
8689
| `$X1` | Title top, icon centered |
8790
| `$A0` | Title top, full-width image canvas center |
8891
| `$A1` | Title top, icon left, text value right |
8992
| `$B1` | Title top, icon left, text + progress bar right |
9093
| `$B2` | Title top, icon left, text + gradient progress bar |
9194
| `$C1` | Title top, dual icon-left/progress-right rows |
9295

93-
Custom layouts are supported as JSON shipped with the plugin.
96+
Each variant is a separate action UUID in the bundled plugin (`io.github.verygoodplugins.streamdeck-mcp.dial.<x1|a0|a1|b1|b2|c1>`) with `Encoder.layout` statically declared — the only Elgato-documented, durable way to set a layout.
97+
98+
Custom layouts (JSON shipped with the plugin) are not yet supported; deferred until a concrete layout requires it.
9499

95100
### Touchstrip custom art goes in the profile manifest, not `setFeedback`
96101

streamdeck_plugin/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@
1010
PLUGIN_UUID = "io.github.verygoodplugins.streamdeck-mcp"
1111
PLUGIN_DIR_NAME = f"{PLUGIN_UUID}.sdPlugin"
1212
ACTION_UUID = f"{PLUGIN_UUID}.dial"
13+
DEFAULT_ACTION_UUID = ACTION_UUID
14+
15+
# Layout-declaring action variants. Each UUID maps to a plugin-manifest action
16+
# whose `Encoder.layout` is statically set to the corresponding Elgato built-in.
17+
# Callers select one by passing `encoder_layout="$A1"` etc. on an encoder button.
18+
LAYOUT_ACTION_UUIDS: dict[str, str] = {
19+
"$X1": f"{PLUGIN_UUID}.dial.x1",
20+
"$A0": f"{PLUGIN_UUID}.dial.a0",
21+
"$A1": f"{PLUGIN_UUID}.dial.a1",
22+
"$B1": f"{PLUGIN_UUID}.dial.b1",
23+
"$B2": f"{PLUGIN_UUID}.dial.b2",
24+
"$C1": f"{PLUGIN_UUID}.dial.c1",
25+
}
26+
SUPPORTED_ENCODER_LAYOUTS: tuple[str, ...] = tuple(LAYOUT_ACTION_UUIDS.keys())
1327

1428
# Read the plugin version directly from the bundled manifest so there is a
1529
# single source of truth — the manifest — rather than a duplicated constant.

streamdeck_plugin/io.github.verygoodplugins.streamdeck-mcp.sdPlugin/manifest.json

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"Author": "Very Good Plugins",
44
"Description": "Renders images and titles set by the streamdeck-mcp profile writer on keypad keys and Stream Deck + touchstrip segments.",
55
"URL": "https://github.com/verygoodplugins/streamdeck-mcp",
6-
"Version": "0.1.0",
6+
"Version": "0.2.0",
77
"CodePath": "plugin.html",
88
"Icon": "Images/plugin",
99
"Category": "streamdeck-mcp",
@@ -31,6 +31,114 @@
3131
"Touch": ""
3232
}
3333
}
34+
},
35+
{
36+
"UUID": "io.github.verygoodplugins.streamdeck-mcp.dial.x1",
37+
"Name": "MCP Dial ($X1 — icon)",
38+
"Tooltip": "streamdeck-mcp dial with Elgato built-in $X1 layout (title + centered icon).",
39+
"Icon": "Images/action",
40+
"PropertyInspectorPath": "",
41+
"States": [{ "Image": "Images/action", "TitleAlignment": "bottom" }],
42+
"Controllers": ["Encoder"],
43+
"Encoder": {
44+
"layout": "$X1",
45+
"StackColor": "#000000",
46+
"TriggerDescription": {
47+
"Rotate": "",
48+
"Push": "",
49+
"Touch": ""
50+
}
51+
}
52+
},
53+
{
54+
"UUID": "io.github.verygoodplugins.streamdeck-mcp.dial.a0",
55+
"Name": "MCP Dial ($A0 — full image)",
56+
"Tooltip": "streamdeck-mcp dial with Elgato built-in $A0 layout (title + full-width image canvas).",
57+
"Icon": "Images/action",
58+
"PropertyInspectorPath": "",
59+
"States": [{ "Image": "Images/action", "TitleAlignment": "bottom" }],
60+
"Controllers": ["Encoder"],
61+
"Encoder": {
62+
"layout": "$A0",
63+
"StackColor": "#000000",
64+
"TriggerDescription": {
65+
"Rotate": "",
66+
"Push": "",
67+
"Touch": ""
68+
}
69+
}
70+
},
71+
{
72+
"UUID": "io.github.verygoodplugins.streamdeck-mcp.dial.a1",
73+
"Name": "MCP Dial ($A1 — icon + value)",
74+
"Tooltip": "streamdeck-mcp dial with Elgato built-in $A1 layout (title + icon + value slot).",
75+
"Icon": "Images/action",
76+
"PropertyInspectorPath": "",
77+
"States": [{ "Image": "Images/action", "TitleAlignment": "bottom" }],
78+
"Controllers": ["Encoder"],
79+
"Encoder": {
80+
"layout": "$A1",
81+
"StackColor": "#000000",
82+
"TriggerDescription": {
83+
"Rotate": "",
84+
"Push": "",
85+
"Touch": ""
86+
}
87+
}
88+
},
89+
{
90+
"UUID": "io.github.verygoodplugins.streamdeck-mcp.dial.b1",
91+
"Name": "MCP Dial ($B1 — progress)",
92+
"Tooltip": "streamdeck-mcp dial with Elgato built-in $B1 layout (title + icon + progress bar).",
93+
"Icon": "Images/action",
94+
"PropertyInspectorPath": "",
95+
"States": [{ "Image": "Images/action", "TitleAlignment": "bottom" }],
96+
"Controllers": ["Encoder"],
97+
"Encoder": {
98+
"layout": "$B1",
99+
"StackColor": "#000000",
100+
"TriggerDescription": {
101+
"Rotate": "",
102+
"Push": "",
103+
"Touch": ""
104+
}
105+
}
106+
},
107+
{
108+
"UUID": "io.github.verygoodplugins.streamdeck-mcp.dial.b2",
109+
"Name": "MCP Dial ($B2 — gradient progress)",
110+
"Tooltip": "streamdeck-mcp dial with Elgato built-in $B2 layout (title + icon + gradient progress bar).",
111+
"Icon": "Images/action",
112+
"PropertyInspectorPath": "",
113+
"States": [{ "Image": "Images/action", "TitleAlignment": "bottom" }],
114+
"Controllers": ["Encoder"],
115+
"Encoder": {
116+
"layout": "$B2",
117+
"StackColor": "#000000",
118+
"TriggerDescription": {
119+
"Rotate": "",
120+
"Push": "",
121+
"Touch": ""
122+
}
123+
}
124+
},
125+
{
126+
"UUID": "io.github.verygoodplugins.streamdeck-mcp.dial.c1",
127+
"Name": "MCP Dial ($C1 — dual rows)",
128+
"Tooltip": "streamdeck-mcp dial with Elgato built-in $C1 layout (title + dual icon/progress rows).",
129+
"Icon": "Images/action",
130+
"PropertyInspectorPath": "",
131+
"States": [{ "Image": "Images/action", "TitleAlignment": "bottom" }],
132+
"Controllers": ["Encoder"],
133+
"Encoder": {
134+
"layout": "$C1",
135+
"StackColor": "#000000",
136+
"TriggerDescription": {
137+
"Rotate": "",
138+
"Push": "",
139+
"Touch": ""
140+
}
141+
}
34142
}
35143
]
36144
}

0 commit comments

Comments
 (0)