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
29 changes: 21 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,42 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

jobs:
test:
name: Python CI
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]

steps:
- uses: actions/checkout@v6

- name: Set up Python
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: "3.11"
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@v8.1.0

- name: Install dependencies
run: |
uv venv
uv pip install -e ".[dev]"
run: uv pip install --system -e ".[dev]"

- name: Run linter
run: uv run ruff check .
run: ruff check .

- name: Run tests
run: uv run pytest tests/ -v
- name: Check formatting
run: ruff format --check .

- name: Run tests with coverage
run: pytest tests/ -v --cov=. --cov-report=xml --cov-report=term-missing

- name: Upload coverage
uses: codecov/codecov-action@v5
with:
files: ./coverage.xml
continue-on-error: true
14 changes: 14 additions & 0 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: Dependabot auto-merge

on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]

jobs:
auto-merge:
Comment thread
jack-arturo marked this conversation as resolved.
if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' && github.actor == 'dependabot[bot]' }}
permissions:
contents: write
pull-requests: write
uses: verygoodplugins/.github/.github/workflows/dependabot-auto-merge.yml@main
secrets: inherit
2 changes: 2 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:

- name: Audit dependencies
run: pip-audit
continue-on-error: true

bandit:
name: Bandit Security Scan
Expand All @@ -65,3 +66,4 @@ jobs:

- name: Run bandit
run: bandit -r . -x .venv,tests -ll
continue-on-error: true
2 changes: 0 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* make profile writer the default streamdeck server ([#1](https://github.com/verygoodplugins/streamdeck-mcp/issues/1)) ([bdc73c0](https://github.com/verygoodplugins/streamdeck-mcp/commit/bdc73c0654f661dc905eeaddf3791248694db6da))

## [0.2.0] - Unreleased

### Added

- New `profile_server.py` MCP server that writes directly to Stream Deck desktop profiles
Expand Down
19 changes: 5 additions & 14 deletions profile_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,9 +807,7 @@ def create_icon(
if not transparent_bg:
bg_color = _ensure_hex_color(bg_color, field_name="bg_color")
text_color = _ensure_hex_color(text_color, field_name="text_color")
resolved_icon_color = _ensure_hex_color(
icon_color or text_color, field_name="icon_color"
)
resolved_icon_color = _ensure_hex_color(icon_color or text_color, field_name="icon_color")

self.generated_icons_dir.mkdir(parents=True, exist_ok=True)
canonical_icon_name: str | None = None
Expand Down Expand Up @@ -852,9 +850,7 @@ def create_icon(
glyph_font_size = max(8, int(round(ref_size * scale)))
glyph_font = ImageFont.truetype(str(font_file), glyph_font_size)
except OSError as exc:
raise ProfileManagerError(
f"Could not load bundled MDI font: {exc}"
) from exc
raise ProfileManagerError(f"Could not load bundled MDI font: {exc}") from exc
bbox = draw.textbbox((0, 0), glyph, font=glyph_font)
gw = bbox[2] - bbox[0]
gh = bbox[3] - bbox[1]
Expand Down Expand Up @@ -900,9 +896,7 @@ def create_icons(
"""

if not isinstance(specs, list) or not specs:
raise ProfileValidationError(
"create_icons requires a non-empty list of icon specs."
)
raise ProfileValidationError("create_icons requires a non-empty list of icon specs.")

results: list[dict[str, Any]] = []
for index, spec in enumerate(specs):
Expand Down Expand Up @@ -1332,9 +1326,7 @@ def _build_action_from_fields(
) -> dict[str, Any]:
encoder_layout = button.get("encoder_layout")
if encoder_layout is not None and controller_type != ENCODER:
raise ProfileValidationError(
"encoder_layout is only valid for encoder/dial buttons."
)
raise ProfileValidationError("encoder_layout is only valid for encoder/dial buttons.")
if encoder_layout is not None and any(
button.get(k) for k in ("path", "action_type", "plugin_uuid", "action_uuid")
):
Expand Down Expand Up @@ -1408,8 +1400,7 @@ def _any_button_needs_mcp_plugin(
elif raw_action is None:
# Will be built as an MCP dial action iff no other action spec present.
if not any(
button.get(k)
for k in ("path", "action_type", "plugin_uuid", "action_uuid")
button.get(k) for k in ("path", "action_type", "plugin_uuid", "action_uuid")
):
return True
if button.get("plugin_uuid") == PLUGIN_UUID:
Expand Down
15 changes: 5 additions & 10 deletions profile_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,7 @@
server = Server("streamdeck-profile-mcp")

_SKILL_PATH = (
Path(__file__).parent
/ "streamdeck_assets"
/ "skill"
/ "streamdeck-designer"
/ "SKILL.md"
Path(__file__).parent / "streamdeck_assets" / "skill" / "streamdeck-designer" / "SKILL.md"
)


Expand Down Expand Up @@ -379,9 +375,9 @@ async def list_tools() -> list[Tool]:
"'icons' as a list of spec dicts to generate them all in one call "
"and avoid the round-trip timeouts serial calls hit. ~7400 MDI icons "
"bundled offline; unknown names return close-match suggestions. "
"Returns either a single {path, size, ...} dict or {\"icons\": [...]} "
'Returns either a single {path, size, ...} dict or {"icons": [...]} '
"when 'icons' is used (each list element is a per-icon result or an "
"{\"error\"} entry for that spec)."
'{"error"} entry for that spec).'
),
inputSchema={
"type": "object",
Expand All @@ -397,8 +393,7 @@ async def list_tools() -> list[Tool]:
"icon_color": {
"type": "string",
"description": (
"Hex color for the icon glyph, e.g. '#00ff88'. "
"Defaults to text_color."
"Hex color for the icon glyph, e.g. '#00ff88'. Defaults to text_color."
),
},
"icon_scale": {
Expand Down Expand Up @@ -450,7 +445,7 @@ async def list_tools() -> list[Tool]:
"transparent_bg/text_color/font_size/filename). When "
"this field is present, all other single-icon fields "
"at the top level are ignored and the response shape "
"becomes {\"icons\": [per-spec result]}. Use this for "
'becomes {"icons": [per-spec result]}. Use this for '
"30+ icon decks to avoid per-call round-trip cost. "
"Accepts either a JSON array or a JSON-encoded string "
"containing an array — some MCP clients stringify "
Expand Down
23 changes: 6 additions & 17 deletions tests/test_profile_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,9 +556,7 @@ def test_plus_device_accepts_encoder_writes(
assert encoder_positions == {"2,0", "3,0"}


def test_plus_device_reports_correct_model_name(
sample_profiles_plus: Path, tmp_path: Path
) -> None:
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(
Expand Down Expand Up @@ -903,8 +901,7 @@ def test_create_icon_mdi_glyph_only(tmp_path: Path) -> None:
greenish_pixels += 1

assert greenish_pixels >= 25, (
f"expected rendered glyph to contain greenish non-background pixels, got "
f"{greenish_pixels}"
f"expected rendered glyph to contain greenish non-background pixels, got {greenish_pixels}"
)


Expand Down Expand Up @@ -1050,9 +1047,7 @@ def test_write_page_encoder_writes_encoder_icon_and_background(
generated_icons_dir=tmp_path / "icons",
)
icon = manager.create_icon(icon="mdi:volume-high", filename="vol")
strip = manager.create_icon(
icon="mdi:volume-high", shape="touchstrip", filename="vol-strip"
)
strip = manager.create_icon(icon="mdi:volume-high", shape="touchstrip", filename="vol-strip")

manager.write_page(
profile_name="Plus XL",
Expand All @@ -1078,9 +1073,7 @@ def test_write_page_encoder_writes_encoder_icon_and_background(
/ "manifest.json"
).read_text()
)
encoder_actions = next(
c["Actions"] for c in raw["Controllers"] if c["Type"] == "Encoder"
)
encoder_actions = next(c["Actions"] for c in raw["Controllers"] if c["Type"] == "Encoder")
action = encoder_actions["0,0"]
assert action["UUID"] == "io.github.verygoodplugins.streamdeck-mcp.dial"
assert action["Encoder"]["Icon"].startswith("Images/")
Expand Down Expand Up @@ -1131,9 +1124,7 @@ def test_write_page_auto_installs_mcp_plugin_for_encoder_default(
result = manager.write_page(
profile_name="Plus XL",
page_index=0,
buttons=[
{"controller": "encoder", "key": 0, "icon_path": icon["path"], "title": "V"}
],
buttons=[{"controller": "encoder", "key": 0, "icon_path": icon["path"], "title": "V"}],
clear_existing=False,
)
assert result["mcp_plugin_install"]["installed"] is True
Expand All @@ -1150,9 +1141,7 @@ def _read_plus_xl_encoder_action(profiles_dir: Path, key: str) -> dict:
/ "manifest.json"
).read_text()
)
encoder_actions = next(
c["Actions"] for c in raw["Controllers"] if c["Type"] == "Encoder"
)
encoder_actions = next(c["Actions"] for c in raw["Controllers"] if c["Type"] == "Encoder")
return encoder_actions[key]


Expand Down
Loading