Skip to content

Commit 67cbe4e

Browse files
authored
fix(usb): select stream deck by serial (#45)
## Summary - add `streamdeck_list_devices` to enumerate attached USB Stream Decks - allow `streamdeck_connect` to select a specific deck by `serial` while preserving first-device default behavior - document the multi-deck USB workflow and add mocked hardware regression tests Fixes #44 ## Tests - `uv run pytest tests/test_server.py -q` - `uv run pytest tests/ -q` - `uv run ruff check .` ## Risk - No physical multi-deck hardware test was run; coverage uses mocked `python-elgato-streamdeck` devices.
1 parent 29bb8bc commit 67cbe4e

3 files changed

Lines changed: 260 additions & 13 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,9 @@ To audit this repo against the shared Very Good Plugins MCP standards:
236236

237237
The original USB-direct server is preserved for backwards compatibility. It exposes direct hardware tools:
238238

239-
`streamdeck_connect`, `streamdeck_info`, `streamdeck_set_button`, `streamdeck_set_buttons`, `streamdeck_clear_button`, `streamdeck_get_button`, `streamdeck_clear_all`, `streamdeck_set_brightness`, `streamdeck_create_page`, `streamdeck_switch_page`, `streamdeck_list_pages`, `streamdeck_delete_page`, `streamdeck_disconnect`.
239+
`streamdeck_list_devices`, `streamdeck_connect`, `streamdeck_info`, `streamdeck_set_button`, `streamdeck_set_buttons`, `streamdeck_clear_button`, `streamdeck_get_button`, `streamdeck_clear_all`, `streamdeck_set_brightness`, `streamdeck_create_page`, `streamdeck_switch_page`, `streamdeck_list_pages`, `streamdeck_delete_page`, `streamdeck_disconnect`.
240+
241+
When multiple decks are attached, call `streamdeck_list_devices` first, then pass the desired `serial` to `streamdeck_connect`. Omitting `serial` preserves the legacy behavior of opening the first enumerated deck.
240242

241243
Run it with:
242244

server.py

Lines changed: 133 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -258,9 +258,72 @@ def _check_deck_connected(self) -> None:
258258
"Stream Deck disconnected. Reconnect with streamdeck_connect."
259259
)
260260

261-
def connect(self) -> dict[str, Any]:
261+
def _enumerate_decks(self) -> list[Any]:
262+
"""Enumerate attached Stream Deck devices."""
263+
try:
264+
return DeviceManager().enumerate()
265+
except Exception as e:
266+
logger.error(f"Failed to enumerate devices: {e}")
267+
raise StreamDeckError(f"Failed to scan for Stream Deck devices: {e}")
268+
269+
def _read_deck_serial(self, deck: Any) -> tuple[str, bool]:
270+
"""
271+
Read a deck serial, opening the device if needed.
272+
273+
Returns:
274+
Tuple of serial and whether this call opened the device.
275+
"""
276+
opened_here = False
277+
try:
278+
if not deck.is_open():
279+
deck.open()
280+
opened_here = True
281+
return deck.get_serial_number(), opened_here
282+
except Exception as e:
283+
if opened_here:
284+
try:
285+
deck.close()
286+
except Exception as close_error:
287+
logger.warning(
288+
f"Failed to close Stream Deck after serial read error: {close_error}"
289+
)
290+
raise StreamDeckError(f"Failed to read Stream Deck serial: {e}")
291+
292+
def list_devices(self) -> list[dict[str, Any]]:
293+
"""
294+
List attached Stream Deck devices.
295+
296+
Returns:
297+
List of discovered deck metadata
298+
299+
Raises:
300+
StreamDeckError: If enumeration fails
262301
"""
263-
Connect to the first available Stream Deck.
302+
if not HAS_STREAMDECK:
303+
raise StreamDeckError(
304+
"streamdeck library not installed. Run: pip install streamdeck pillow"
305+
)
306+
307+
devices = []
308+
for deck in self._enumerate_decks():
309+
serial, opened_here = self._read_deck_serial(deck)
310+
try:
311+
devices.append(
312+
{
313+
"serial": serial,
314+
"deck_type": deck.deck_type(),
315+
"key_count": deck.key_count(),
316+
}
317+
)
318+
finally:
319+
if opened_here:
320+
deck.close()
321+
322+
return devices
323+
324+
def connect(self, serial: str | None = None) -> dict[str, Any]:
325+
"""
326+
Connect to an available Stream Deck.
264327
265328
Returns:
266329
Dict with connection result and deck info
@@ -279,18 +342,38 @@ def connect(self) -> dict[str, Any]:
279342
time.sleep(RECONNECT_DELAY_BASE)
280343
self._last_connect_attempt = now
281344

282-
try:
283-
decks = DeviceManager().enumerate()
284-
except Exception as e:
285-
logger.error(f"Failed to enumerate devices: {e}")
286-
raise StreamDeckError(f"Failed to scan for Stream Deck devices: {e}")
287-
345+
decks = self._enumerate_decks()
288346
if not decks:
289347
raise StreamDeckError("No Stream Deck found. Check USB connection and permissions.")
290348

349+
selected_deck = decks[0]
350+
selected_is_open = False
351+
352+
if serial is not None:
353+
selected_deck = None
354+
available_serials = []
355+
356+
for deck in decks:
357+
deck_serial, opened_here = self._read_deck_serial(deck)
358+
available_serials.append(deck_serial)
359+
360+
if deck_serial == serial:
361+
selected_deck = deck
362+
selected_is_open = opened_here or deck.is_open()
363+
break
364+
365+
if opened_here:
366+
deck.close()
367+
368+
if selected_deck is None:
369+
raise StreamDeckError(
370+
f"No Stream Deck with serial {serial!r}. Available serials: {available_serials}"
371+
)
372+
291373
try:
292-
self.deck = decks[0]
293-
self.deck.open()
374+
self.deck = selected_deck
375+
if not selected_is_open:
376+
self.deck.open()
294377
self.deck.reset()
295378
self.deck.set_brightness(self._brightness)
296379
self.deck.set_key_callback(self._key_callback)
@@ -308,6 +391,13 @@ def connect(self) -> dict[str, Any]:
308391
except Exception as e:
309392
self._connect_attempts += 1
310393
logger.error(f"Connection attempt {self._connect_attempts} failed: {e}")
394+
if self.deck:
395+
try:
396+
self.deck.close()
397+
except Exception as close_error:
398+
logger.warning(
399+
f"Failed to close Stream Deck after connection error: {close_error}"
400+
)
311401
self.deck = None
312402

313403
if self._connect_attempts >= MAX_RECONNECT_ATTEMPTS:
@@ -853,7 +943,23 @@ async def list_tools() -> list[Tool]:
853943
return [
854944
Tool(
855945
name="streamdeck_connect",
856-
description="Connect to a Stream Deck device. Call this first before other operations.",
946+
description=(
947+
"Connect to a Stream Deck device. Call this first before other operations. "
948+
"Use serial to choose a specific deck when multiple are attached."
949+
),
950+
inputSchema={
951+
"type": "object",
952+
"properties": {
953+
"serial": {
954+
"type": "string",
955+
"description": "Optional Stream Deck serial number to connect to",
956+
},
957+
},
958+
},
959+
),
960+
Tool(
961+
name="streamdeck_list_devices",
962+
description="List attached Stream Deck devices without requiring an active connection",
857963
inputSchema={
858964
"type": "object",
859965
"properties": {},
@@ -1099,7 +1205,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
10991205
)
11001206
]
11011207

1102-
info = state.connect()
1208+
info = state.connect(serial=arguments.get("serial"))
11031209
return [
11041210
TextContent(
11051211
type="text",
@@ -1110,6 +1216,21 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
11101216
)
11111217
]
11121218

1219+
elif name == "streamdeck_list_devices":
1220+
if not HAS_STREAMDECK:
1221+
return [
1222+
TextContent(
1223+
type="text",
1224+
text=(
1225+
"❌ streamdeck library not installed. "
1226+
"Run: pip install streamdeck pillow"
1227+
),
1228+
)
1229+
]
1230+
1231+
devices = state.list_devices()
1232+
return [TextContent(type="text", text=json.dumps(devices, indent=2))]
1233+
11131234
elif name == "streamdeck_info":
11141235
info = state.get_deck_info()
11151236
return [TextContent(type="text", text=json.dumps(info, indent=2))]

tests/test_server.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,35 @@
2424
from server import ( # noqa: E402,I001
2525
DeckNotConnectedError,
2626
StreamDeckState,
27+
StreamDeckError,
2728
ValidationError,
29+
list_tools,
2830
subprocess as server_subprocess,
2931
)
3032

3133

3234
class TestStreamDeckState:
3335
"""Tests for StreamDeckState class."""
3436

37+
def _mock_deck(
38+
self,
39+
*,
40+
serial: str,
41+
deck_type: str = "Stream Deck Original",
42+
key_count: int = 15,
43+
is_open: bool = False,
44+
) -> MagicMock:
45+
deck = MagicMock()
46+
deck.deck_type.return_value = deck_type
47+
deck.key_count.return_value = key_count
48+
deck.get_serial_number.return_value = serial
49+
deck.is_open.return_value = is_open
50+
deck.key_layout.return_value = (3, 5)
51+
deck.id.return_value = f"id-{serial}"
52+
deck.get_firmware_version.return_value = "1.0.0"
53+
deck.key_image_format.return_value = {"size": (72, 72), "format": "JPEG"}
54+
return deck
55+
3556
@pytest.fixture
3657
def temp_config_dir(self, tmp_path: Path):
3758
"""Create a temporary config directory."""
@@ -230,6 +251,97 @@ def test_get_deck_info_not_connected(self, state: StreamDeckState):
230251
info = state.get_deck_info()
231252
assert info["connected"] is False
232253

254+
def test_list_devices_returns_serial_type_and_key_count(self, state: StreamDeckState):
255+
"""Should enumerate connected deck metadata without keeping devices open."""
256+
original = self._mock_deck(serial="ORIGINAL123", deck_type="Stream Deck Original")
257+
plus = self._mock_deck(serial="PLUS456", deck_type="Stream Deck Plus", key_count=8)
258+
259+
with patch("server.DeviceManager") as mock_device_manager:
260+
mock_device_manager.return_value.enumerate.return_value = [original, plus]
261+
262+
devices = state.list_devices()
263+
264+
assert devices == [
265+
{
266+
"serial": "ORIGINAL123",
267+
"deck_type": "Stream Deck Original",
268+
"key_count": 15,
269+
},
270+
{
271+
"serial": "PLUS456",
272+
"deck_type": "Stream Deck Plus",
273+
"key_count": 8,
274+
},
275+
]
276+
original.open.assert_called_once_with()
277+
original.close.assert_called_once_with()
278+
plus.open.assert_called_once_with()
279+
plus.close.assert_called_once_with()
280+
281+
def test_connect_without_serial_opens_first_deck(self, state: StreamDeckState):
282+
"""Default connect behavior should preserve the first enumerated deck choice."""
283+
original = self._mock_deck(serial="ORIGINAL123")
284+
plus = self._mock_deck(serial="PLUS456", deck_type="Stream Deck Plus", key_count=8)
285+
286+
with patch("server.DeviceManager") as mock_device_manager:
287+
mock_device_manager.return_value.enumerate.return_value = [original, plus]
288+
with patch.object(state, "_render_current_page"):
289+
info = state.connect()
290+
291+
assert state.deck is original
292+
assert info["serial"] == "ORIGINAL123"
293+
original.open.assert_called_once_with()
294+
plus.open.assert_not_called()
295+
296+
def test_connect_with_serial_opens_matching_deck(self, state: StreamDeckState):
297+
"""A provided serial should select that device from the enumerated decks."""
298+
original = self._mock_deck(serial="ORIGINAL123")
299+
plus = self._mock_deck(serial="PLUS456", deck_type="Stream Deck Plus", key_count=8)
300+
301+
with patch("server.DeviceManager") as mock_device_manager:
302+
mock_device_manager.return_value.enumerate.return_value = [original, plus]
303+
with patch.object(state, "_render_current_page"):
304+
info = state.connect(serial="PLUS456")
305+
306+
assert state.deck is plus
307+
assert info["serial"] == "PLUS456"
308+
original.open.assert_called_once_with()
309+
original.close.assert_called_once_with()
310+
plus.open.assert_called_once_with()
311+
plus.close.assert_not_called()
312+
313+
def test_connect_with_unknown_serial_lists_available_serials(self, state: StreamDeckState):
314+
"""Unknown serial errors should include available serials for self-correction."""
315+
original = self._mock_deck(serial="ORIGINAL123")
316+
plus = self._mock_deck(serial="PLUS456", deck_type="Stream Deck Plus", key_count=8)
317+
318+
with patch("server.DeviceManager") as mock_device_manager:
319+
mock_device_manager.return_value.enumerate.return_value = [original, plus]
320+
with pytest.raises(StreamDeckError, match="MISSING789") as exc_info:
321+
state.connect(serial="MISSING789")
322+
323+
message = str(exc_info.value)
324+
assert "ORIGINAL123" in message
325+
assert "PLUS456" in message
326+
assert state.deck is None
327+
original.close.assert_called_once_with()
328+
plus.close.assert_called_once_with()
329+
330+
def test_connect_with_serial_closes_match_when_setup_fails(self, state: StreamDeckState):
331+
"""A serial-selected deck opened during probing should be closed on setup failure."""
332+
original = self._mock_deck(serial="ORIGINAL123")
333+
plus = self._mock_deck(serial="PLUS456", deck_type="Stream Deck Plus", key_count=8)
334+
plus.reset.side_effect = RuntimeError("reset failed")
335+
336+
with patch("server.DeviceManager") as mock_device_manager:
337+
mock_device_manager.return_value.enumerate.return_value = [original, plus]
338+
with pytest.raises(StreamDeckError, match="reset failed"):
339+
state.connect(serial="PLUS456")
340+
341+
assert state.deck is None
342+
original.close.assert_called_once_with()
343+
plus.close.assert_called_once_with()
344+
233345
# ========================================================================
234346
# Button Action Tests
235347
# ========================================================================
@@ -308,6 +420,18 @@ async def test_tool_error_handling(self):
308420
# Left as placeholder for integration tests
309421
pass
310422

423+
@pytest.mark.asyncio
424+
async def test_tool_schema_includes_device_listing_and_connect_serial(self):
425+
"""MCP schema should expose device listing and optional serial connect."""
426+
tools = await list_tools()
427+
tools_by_name = {tool.name: tool for tool in tools}
428+
429+
assert "streamdeck_list_devices" in tools_by_name
430+
431+
connect_schema = tools_by_name["streamdeck_connect"].inputSchema
432+
assert "serial" in connect_schema["properties"]
433+
assert "serial" not in connect_schema.get("required", [])
434+
311435

312436
# Run with: pytest tests/test_server.py -v
313437
if __name__ == "__main__":

0 commit comments

Comments
 (0)