Skip to content

Commit a41a31c

Browse files
jack-arturoclaudeCopilot
authored
feat(touchstrip): per-segment icon and background on Stream Deck + / + XL (#20)
## Summary Closes the loop on the Stream Deck + / + XL hardware. The MCP can now write per-segment touchstrip art — icon overlays and 200×100 backgrounds — that survives an Elgato app restart and renders on the physical device. Ships a minimal companion Stream Deck plugin inside the Python package. It's the only path Elgato exposes for this: per-instance `Action.Encoder.Icon` and `Action.Encoder.background` writes get stripped on quit unless the action's plugin declares `Controllers: ["Encoder"]`. The bundled plugin is that declaration, nothing more — no CodePath business logic. Also a small correctness fix on `icon_scale` semantics that made the glyph sizing visibly undersized vs native Elgato icons on the strip. ## What's new - **`streamdeck_create_icon`** gained `shape="button" | "touchstrip"` (200×100 for strip backgrounds) and `transparent_bg=True` (RGBA canvas for dial Icons that compose over a strip background). Default `icon_scale` bumped `0.7 → 1.0` so the glyph bounding box fills the canvas edge-to-edge, matching how Elgato's own icons fill the touchstrip slot. - **`streamdeck_write_page`** button schema adds `strip_background_path` (encoder-only; validation error on keypad). Encoder buttons route `icon_path` to `Action.Encoder.Icon` and `strip_background_path` to `Action.Encoder.background`. Encoder buttons with no explicit action spec default to the bundled MCP dial plugin, so a full dial is now just `{controller: "encoder", key: N, icon_path: ..., strip_background_path: ..., title: ...}`. - **`streamdeck_install_mcp_plugin`** MCP tool. Idempotent (`force=true` to reinstall). `streamdeck_write_page` auto-installs the plugin the first time an encoder button needs it, so direct use is rarely necessary. - **Bundled plugin** at `streamdeck_plugin/io.github.verygoodplugins.streamdeck-mcp.sdPlugin/` with a minimal HTML/JS shell (~30 lines of JS, SDK registration only), correctly-sized plugin/action icons per SDK spec, and no `Encoder.layout` — that omission gives us Elgato's default layout, which lets the full-strip `Controllers[Encoder].Background` show through segments whose per-segment `background` is unset. ## Scope tightening / lessons from the SDK docs I probed this empirically for several app-restart cycles before @JGArturo pointed at https://docs.elgato.com/streamdeck/sdk/ which predicts exactly what we found (image dimensions, the six built-in layouts, the plugin-vs-profile field split). SKILL.md now links the docs at the top, includes the authoritative image-dimensions table, and names the six layouts so future sessions start there. Saved AutoMem entries so this lesson persists. ## On-device verification (Stream Deck + XL, 20GBX9901) - Six encoder segments with distinct MDI glyphs + transparent icon overlays + per-segment 200×100 backgrounds render the intended art on the physical strip, not just the app preview - Full-strip `Controllers[Encoder].Background` shows through segments whose per-segment background is cleared - Glyph size at `icon_scale=1.0` matches Elgato's native icon sizing (~22px visible) ## Test plan - [x] `uv run pytest tests/` — **64 passed** (7 new: touchstrip shape, shape validation, plugin install idempotency, encoder `Encoder.Icon`/`background` writes, keypad strip_background rejection, write_page auto-install) - [x] `uv run ruff check .` — clean - [x] `uv build --wheel` — plugin bundle ships inside the wheel correctly ## Known follow-ups (out of scope) - **`icon_scale` default on keypad**: 1.0 fills the button edge-to-edge. On keypad buttons with a bottom title, the glyph touches the title overlay. Callers that want breathing room pass `icon_scale=0.75-0.85` explicitly. Worth revisiting if this becomes annoying in practice. - **Layout flexibility**: `Encoder.layout` is currently unset (default layout). Future PR could expose `$X1` / `$A1` / `$B1` / custom JSON layouts on the plugin side to support richer touchstrip UIs (progress bars, dual icons). 🤖 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 0655a44 commit a41a31c

14 files changed

Lines changed: 568 additions & 23 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,10 @@ uvx --from streamdeck-mcp streamdeck-mcp-usb
6565
| `streamdeck_read_profiles` | Lists desktop profiles and page directories from the active ProfilesV3 or ProfilesV2 store |
6666
| `streamdeck_read_page` | Reads a page manifest and returns simplified button details plus the raw manifest |
6767
| `streamdeck_write_page` | Creates a new page or rewrites an existing page manifest |
68-
| `streamdeck_create_icon` | Generates a 72x72 PNG icon from a Material Design Icons name (e.g. `mdi:cpu-64-bit`) or from text (but not both). ~7400 MDI icons are bundled offline; unknown names return close-match suggestions |
68+
| `streamdeck_create_icon` | Generates a PNG icon from a Material Design Icons name (e.g. `mdi:cpu-64-bit`) or from text (but not both). `shape="button"` (72x72, default) for keypad keys and encoder dial faces; `shape="touchstrip"` (200x100) for Stream Deck + / + XL dial segment backgrounds. ~7400 MDI icons are bundled offline; unknown names return close-match suggestions |
6969
| `streamdeck_create_action` | Creates an executable shell script in `~/StreamDeckScripts/` and returns an Open action block |
7070
| `streamdeck_restart_app` | Restarts the macOS Stream Deck desktop app after profile changes |
71+
| `streamdeck_install_mcp_plugin` | Installs the bundled streamdeck-mcp Stream Deck plugin into the user's Elgato Plugins directory. `streamdeck_write_page` auto-installs it when an encoder button needs it, so direct use is rarely necessary |
7172

7273
## How the Profile Writer Works
7374

@@ -76,6 +77,7 @@ uvx --from streamdeck-mcp streamdeck-mcp-usb
7677
- `streamdeck_write_page` can accept raw native action objects, or use convenience fields like `path`, `action_type`, `plugin_uuid`, and `action_uuid`.
7778
- Generated icons are stored in `~/.streamdeck-mcp/generated-icons/`.
7879
- Generated shell scripts are stored in `~/StreamDeckScripts/`.
80+
- The bundled streamdeck-mcp Stream Deck plugin is installed into the Stream Deck Plugins directory (e.g., `~/Library/Application Support/com.elgato.StreamDeck/Plugins/` on macOS, `%APPDATA%\Elgato\StreamDeck\Plugins\` on Windows) once installed. It's a minimal shell whose only job is to declare encoder support so per-instance `Encoder.Icon` / `Encoder.background` writes survive an Elgato app restart. `streamdeck_write_page` installs it automatically the first time an encoder button needs it.
7981

8082
## Editing Workflow (Important)
8183

profile_manager.py

Lines changed: 212 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
DEFAULT_FONT_SIZE = 12
4040
DEFAULT_TITLE_ALIGNMENT = "bottom"
4141
DEFAULT_ICON_SIZE = (72, 72)
42+
TOUCHSTRIP_ICON_SIZE = (200, 100)
43+
ICON_SHAPES = {"button": DEFAULT_ICON_SIZE, "touchstrip": TOUCHSTRIP_ICON_SIZE}
4244

4345
KEYPAD = "Keypad"
4446
ENCODER = "Encoder"
@@ -312,6 +314,54 @@ def _resolve_app_path() -> Path:
312314
return DEFAULT_STREAM_DECK_APP_PATH
313315

314316

317+
def get_plugins_dir() -> Path:
318+
"""Return the Elgato Stream Deck plugins directory for the current OS."""
319+
home = Path.home()
320+
if sys.platform == "darwin":
321+
return home / "Library/Application Support/com.elgato.StreamDeck/Plugins"
322+
if sys.platform == "win32":
323+
appdata = os.environ.get("APPDATA")
324+
if not appdata:
325+
raise ProfileManagerError("APPDATA is not set; cannot locate Stream Deck plugins.")
326+
return Path(appdata) / "Elgato/StreamDeck/Plugins"
327+
return home / ".local/share/Elgato/StreamDeck/Plugins"
328+
329+
330+
def ensure_mcp_plugin_installed(*, force: bool = False) -> dict[str, Any]:
331+
"""Install the bundled streamdeck-mcp plugin into the Elgato Plugins directory.
332+
333+
The plugin declares an encoder-capable action so that the Stream Deck app
334+
accepts per-instance ``Encoder.Icon`` and ``Encoder.background`` writes made
335+
by the profile writer. Without it, those fields are stripped on quit for any
336+
action whose plugin does not declare encoder support.
337+
338+
Idempotent: returns ``installed=False`` when the plugin directory already
339+
exists, unless ``force=True`` is passed.
340+
"""
341+
from importlib.resources import as_file, files
342+
343+
from streamdeck_plugin import PLUGIN_DIR_NAME
344+
345+
plugins_dir = get_plugins_dir()
346+
dst = plugins_dir / PLUGIN_DIR_NAME
347+
348+
if dst.exists() and not force:
349+
return {"installed": False, "reason": "already installed", "path": str(dst)}
350+
351+
plugins_dir.mkdir(parents=True, exist_ok=True)
352+
if dst.exists():
353+
if dst.is_symlink() or dst.is_file():
354+
dst.unlink()
355+
else:
356+
shutil.rmtree(dst)
357+
358+
src_resource = files("streamdeck_plugin").joinpath(PLUGIN_DIR_NAME)
359+
with as_file(src_resource) as src_path:
360+
shutil.copytree(src_path, dst)
361+
362+
return {"installed": True, "path": str(dst)}
363+
364+
315365
def is_stream_deck_app_running() -> bool:
316366
"""Return True if the Elgato Stream Deck desktop app is currently running."""
317367

@@ -585,6 +635,14 @@ def write_page(
585635

586636
layouts_out: dict[str, dict[str, int]] = {}
587637

638+
# If any encoder button will land on the bundled streamdeck-mcp dial plugin,
639+
# make sure the plugin bundle is actually installed in the Elgato Plugins dir.
640+
# The app just quit (or was already stopped) so now is the right window for
641+
# a filesystem install that the app will pick up on relaunch.
642+
plugin_install_report: dict[str, Any] | None = None
643+
if self._any_button_needs_mcp_plugin(buttons_by_controller):
644+
plugin_install_report = ensure_mcp_plugin_installed()
645+
588646
for controller_type, ctl_buttons in buttons_by_controller.items():
589647
cols, rows = self._resolve_layout(profile_manifest, page_manifest, controller_type)
590648
if cols <= 0 or rows <= 0:
@@ -595,7 +653,9 @@ def write_page(
595653
existing = {} if clear_existing else copy.deepcopy(controller.get("Actions") or {})
596654
for button in ctl_buttons:
597655
position = self._resolve_button_position(button, columns=cols, rows=rows)
598-
existing[position] = self._materialize_action(button, page_dir)
656+
existing[position] = self._materialize_action(
657+
button, page_dir, controller_type=controller_type
658+
)
599659
controller["Actions"] = existing or None
600660
layouts_out[controller_type.lower()] = {"columns": cols, "rows": rows}
601661

@@ -641,6 +701,7 @@ def write_page(
641701
"page_name": page_manifest.get("Name", ""),
642702
"manifest_path": str(page_dir / "manifest.json"),
643703
"app_quit": app_stop_report,
704+
"mcp_plugin_install": plugin_install_report,
644705
}
645706

646707
def create_icon(
@@ -649,13 +710,27 @@ def create_icon(
649710
text: str | None = None,
650711
icon: str | None = None,
651712
icon_color: str | None = None,
652-
icon_scale: float = 0.7,
713+
icon_scale: float = 1.0,
653714
bg_color: str = DEFAULT_BG_COLOR,
654715
text_color: str = DEFAULT_TEXT_COLOR,
655716
font_size: int = 18,
656717
filename: str | None = None,
718+
shape: str = "button",
719+
transparent_bg: bool = False,
657720
) -> dict[str, Any]:
658-
"""Generate a 72x72 PNG icon.
721+
"""Generate a PNG icon.
722+
723+
``shape`` controls the output canvas:
724+
- ``"button"`` (default): 72x72 — keypad keys and encoder dial faces.
725+
- ``"touchstrip"``: 200x100 — the per-segment strip background above a
726+
Stream Deck + / + XL dial, set via ``strip_background_path`` on
727+
``streamdeck_write_page``.
728+
729+
``transparent_bg=True`` produces an RGBA PNG with a transparent canvas
730+
(``bg_color`` is ignored). Use this for dial Icons that overlay a
731+
touchstrip background so the glyph composes cleanly like Elgato's own
732+
transparent icons. Keypad faces and touchstrip backgrounds usually want
733+
the solid-background default.
659734
660735
Provide exactly one of:
661736
- ``icon``: a Material Design Icons name (e.g. ``mdi:cpu-64-bit``) rendered in
@@ -683,7 +758,14 @@ def create_icon(
683758
if not 0.1 <= icon_scale <= 1.0:
684759
raise ProfileValidationError("icon_scale must be between 0.1 and 1.0.")
685760

686-
bg_color = _ensure_hex_color(bg_color, field_name="bg_color")
761+
if shape not in ICON_SHAPES:
762+
raise ProfileValidationError(
763+
f"shape must be one of {sorted(ICON_SHAPES)}, got '{shape}'."
764+
)
765+
canvas_size = ICON_SHAPES[shape]
766+
767+
if not transparent_bg:
768+
bg_color = _ensure_hex_color(bg_color, field_name="bg_color")
687769
text_color = _ensure_hex_color(text_color, field_name="text_color")
688770
resolved_icon_color = _ensure_hex_color(
689771
icon_color or text_color, field_name="icon_color"
@@ -700,35 +782,52 @@ def create_icon(
700782

701783
stem_source = filename or canonical_icon_name or text or "streamdeck-icon"
702784
stem = _slugify(stem_source)
785+
if shape != "button" and filename is None:
786+
stem = f"{stem}-{shape}"
703787
icon_path = self.generated_icons_dir / f"{stem}.png"
704788

705-
image = Image.new("RGB", DEFAULT_ICON_SIZE, bg_color)
789+
if transparent_bg:
790+
image = Image.new("RGBA", canvas_size, (0, 0, 0, 0))
791+
else:
792+
image = Image.new("RGB", canvas_size, bg_color)
706793
draw = ImageDraw.Draw(image)
707794

708795
if glyph is not None:
709-
glyph_size = max(8, int(DEFAULT_ICON_SIZE[1] * icon_scale))
796+
short_side = min(canvas_size)
797+
target_glyph_px = max(8, int(short_side * icon_scale))
710798
from importlib.resources import as_file as _as_file
711799

712800
try:
713801
with _as_file(_mdi_font_path()) as font_file:
714-
glyph_font = ImageFont.truetype(str(font_file), glyph_size)
802+
# MDI glyphs have built-in em-square padding, so a font set to N
803+
# px renders a visual glyph noticeably smaller than N. Measure the
804+
# actual bbox at a reference size, then pick the real font size
805+
# that makes the bbox fill `icon_scale * short_side` pixels.
806+
ref_size = 200
807+
ref_font = ImageFont.truetype(str(font_file), ref_size)
808+
ref_bbox = draw.textbbox((0, 0), glyph, font=ref_font)
809+
ref_w = max(1, ref_bbox[2] - ref_bbox[0])
810+
ref_h = max(1, ref_bbox[3] - ref_bbox[1])
811+
scale = target_glyph_px / max(ref_w, ref_h)
812+
glyph_font_size = max(8, int(round(ref_size * scale)))
813+
glyph_font = ImageFont.truetype(str(font_file), glyph_font_size)
715814
except OSError as exc:
716815
raise ProfileManagerError(
717816
f"Could not load bundled MDI font: {exc}"
718817
) from exc
719818
bbox = draw.textbbox((0, 0), glyph, font=glyph_font)
720819
gw = bbox[2] - bbox[0]
721820
gh = bbox[3] - bbox[1]
722-
gx = (DEFAULT_ICON_SIZE[0] - gw) / 2 - bbox[0]
723-
gy = (DEFAULT_ICON_SIZE[1] - gh) / 2 - bbox[1]
821+
gx = (canvas_size[0] - gw) / 2 - bbox[0]
822+
gy = (canvas_size[1] - gh) / 2 - bbox[1]
724823
draw.text((gx, gy), glyph, font=glyph_font, fill=resolved_icon_color)
725824
else:
726825
label_font = _resolve_font(font_size)
727826
bbox = draw.multiline_textbbox((0, 0), text or "", font=label_font, align="center")
728827
tw = bbox[2] - bbox[0]
729828
th = bbox[3] - bbox[1]
730-
tx = (DEFAULT_ICON_SIZE[0] - tw) / 2
731-
ty = (DEFAULT_ICON_SIZE[1] - th) / 2
829+
tx = (canvas_size[0] - tw) / 2
830+
ty = (canvas_size[1] - th) / 2
732831
draw.multiline_text(
733832
(tx, ty), text or "", font=label_font, fill=text_color, align="center"
734833
)
@@ -737,7 +836,9 @@ def create_icon(
737836

738837
result: dict[str, Any] = {
739838
"path": str(icon_path),
740-
"size": {"width": DEFAULT_ICON_SIZE[0], "height": DEFAULT_ICON_SIZE[1]},
839+
"size": {"width": canvas_size[0], "height": canvas_size[1]},
840+
"shape": shape,
841+
"transparent_bg": transparent_bg,
741842
}
742843
if canonical_icon_name:
743844
result["icon"] = f"mdi:{canonical_icon_name}"
@@ -1028,10 +1129,16 @@ def _resolve_button_position(
10281129

10291130
return f"{col},{row}"
10301131

1031-
def _materialize_action(self, button: dict[str, Any], page_dir: Path) -> dict[str, Any]:
1132+
def _materialize_action(
1133+
self,
1134+
button: dict[str, Any],
1135+
page_dir: Path,
1136+
*,
1137+
controller_type: str = KEYPAD,
1138+
) -> dict[str, Any]:
10321139
raw_action = button.get("action")
10331140
if raw_action is None:
1034-
action = self._build_action_from_fields(button)
1141+
action = self._build_action_from_fields(button, controller_type=controller_type)
10351142
elif isinstance(raw_action, str):
10361143
try:
10371144
action = json.loads(raw_action)
@@ -1076,14 +1183,35 @@ def _materialize_action(self, button: dict[str, Any], page_dir: Path) -> dict[st
10761183
state_data["OutlineThickness"] = state_data.get("OutlineThickness", 2)
10771184

10781185
icon_path = button.get("icon_path")
1079-
if icon_path:
1080-
state_data["Image"] = self._copy_icon_to_page(Path(icon_path).expanduser(), page_dir)
1186+
strip_background_path = button.get("strip_background_path")
1187+
1188+
if controller_type == ENCODER:
1189+
encoder_section = action.setdefault("Encoder", {})
1190+
if icon_path:
1191+
encoder_section["Icon"] = self._copy_icon_to_page(
1192+
Path(icon_path).expanduser(), page_dir
1193+
)
1194+
if strip_background_path:
1195+
encoder_section["background"] = self._copy_icon_to_page(
1196+
Path(strip_background_path).expanduser(), page_dir
1197+
)
1198+
else:
1199+
if strip_background_path:
1200+
raise ProfileValidationError(
1201+
"strip_background_path is only valid for encoder/dial buttons."
1202+
)
1203+
if icon_path:
1204+
state_data["Image"] = self._copy_icon_to_page(
1205+
Path(icon_path).expanduser(), page_dir
1206+
)
10811207

10821208
states[state_index] = state_data
10831209
action["States"] = states
10841210
return action
10851211

1086-
def _build_action_from_fields(self, button: dict[str, Any]) -> dict[str, Any]:
1212+
def _build_action_from_fields(
1213+
self, button: dict[str, Any], *, controller_type: str = KEYPAD
1214+
) -> dict[str, Any]:
10871215
action_type = button.get("action_type")
10881216
if action_type == "next_page":
10891217
return self._build_navigation_action(direction="next")
@@ -1112,11 +1240,78 @@ def _build_action_from_fields(self, button: dict[str, Any]) -> dict[str, Any]:
11121240
"UUID": action_uuid,
11131241
}
11141242

1243+
# Encoder/dial buttons without any explicit action fields fall back to the
1244+
# bundled streamdeck-mcp dial plugin, which is the only action shell that
1245+
# allows per-instance Encoder.Icon / Encoder.background writes to survive.
1246+
if controller_type == ENCODER:
1247+
return self._build_mcp_dial_action(button)
1248+
11151249
raise ProfileValidationError(
11161250
"Button needs either 'action', 'path', 'action_type', "
11171251
"or explicit plugin/action UUID fields."
11181252
)
11191253

1254+
@staticmethod
1255+
def _any_button_needs_mcp_plugin(
1256+
buttons_by_controller: dict[str, list[dict[str, Any]]],
1257+
) -> bool:
1258+
from streamdeck_plugin import PLUGIN_UUID
1259+
1260+
for controller_type, buttons in buttons_by_controller.items():
1261+
if controller_type != ENCODER:
1262+
continue
1263+
for button in buttons:
1264+
raw_action = button.get("action")
1265+
if isinstance(raw_action, dict):
1266+
if (raw_action.get("Plugin") or {}).get("UUID") == PLUGIN_UUID:
1267+
return True
1268+
elif isinstance(raw_action, str):
1269+
# Action may be a JSON-encoded dict — parse and check UUID.
1270+
try:
1271+
parsed = json.loads(raw_action)
1272+
if isinstance(parsed, dict) and (
1273+
(parsed.get("Plugin") or {}).get("UUID") == PLUGIN_UUID
1274+
):
1275+
return True
1276+
except (json.JSONDecodeError, TypeError):
1277+
pass
1278+
elif raw_action is None:
1279+
# Will be built as an MCP dial action iff no other action spec present.
1280+
if not any(
1281+
button.get(k)
1282+
for k in ("path", "action_type", "plugin_uuid", "action_uuid")
1283+
):
1284+
return True
1285+
if button.get("plugin_uuid") == PLUGIN_UUID:
1286+
return True
1287+
return False
1288+
1289+
def install_mcp_plugin(self, *, force: bool = False) -> dict[str, Any]:
1290+
"""Install the bundled streamdeck-mcp plugin into the Elgato Plugins dir.
1291+
1292+
Thin instance wrapper so callers that already hold a ProfileManager can
1293+
trigger an explicit install without importing the module-level helper.
1294+
"""
1295+
return ensure_mcp_plugin_installed(force=force)
1296+
1297+
def _build_mcp_dial_action(self, button: dict[str, Any]) -> dict[str, Any]:
1298+
from streamdeck_plugin import ACTION_UUID, PLUGIN_UUID, PLUGIN_VERSION
1299+
1300+
return {
1301+
"ActionID": str(uuid.uuid4()),
1302+
"LinkedTitle": False,
1303+
"Name": "MCP Dial",
1304+
"Plugin": {
1305+
"Name": "streamdeck-mcp",
1306+
"UUID": PLUGIN_UUID,
1307+
"Version": PLUGIN_VERSION,
1308+
},
1309+
"Settings": copy.deepcopy(button.get("settings", {})),
1310+
"State": 0,
1311+
"States": [{}],
1312+
"UUID": ACTION_UUID,
1313+
}
1314+
11201315
def _build_navigation_action(self, *, direction: str) -> dict[str, Any]:
11211316
if direction not in {"next", "previous"}:
11221317
raise ProfileValidationError(f"Unsupported navigation direction '{direction}'.")

0 commit comments

Comments
 (0)