stackchan-mcp 0.7.0__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/PKG-INFO +1 -1
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/pyproject.toml +1 -1
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/esp32_client.py +91 -5
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stdio_server.py +79 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_audio_utils.py +2 -1
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_esp32_client.py +264 -1
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/uv.lock +1 -1
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/.env.example +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/.gitignore +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/LICENSE +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/README.md +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/__init__.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/__main__.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/audio_stream.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/capture_server.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/cli.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/gateway.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/__init__.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/audio.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/camera.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/handlers/robot.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/mcp_router.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/protocol.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/server.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/__init__.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/audio_utils.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/base.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/stt/orchestrator.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tools.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/__init__.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/audio_utils.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/base.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/orchestrator.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/stackchan_mcp/tts/voicevox.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/_audio_fixtures.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/conftest.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_audio_stream.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_capture_server.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_cli.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_gateway.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_mcp_router.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_orchestrator.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_protocol.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_stdio_server.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_stt_audio_utils.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_stt_framework.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_stt_orchestrator.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_tts_framework.py +0 -0
- {stackchan_mcp-0.7.0 → stackchan_mcp-0.8.0}/tests/test_voicevox.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackchan-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP.
|
|
5
5
|
Project-URL: Homepage, https://github.com/kisaragi-mochi/stackchan-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/kisaragi-mochi/stackchan-mcp
|
|
@@ -7,6 +7,7 @@ and as an MCP client that sends commands TO the ESP32.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
|
+
from collections.abc import Sequence
|
|
10
11
|
import json
|
|
11
12
|
import logging
|
|
12
13
|
import os
|
|
@@ -25,6 +26,34 @@ logger = logging.getLogger(__name__)
|
|
|
25
26
|
# Timeout for waiting for ESP32 responses
|
|
26
27
|
RESPONSE_TIMEOUT = 10.0
|
|
27
28
|
|
|
29
|
+
ToolCall = tuple[str, dict[str, Any]]
|
|
30
|
+
ToolCallResult = tuple[Any, dict[str, Any] | None]
|
|
31
|
+
|
|
32
|
+
_TOOL_LANES = {
|
|
33
|
+
"self.robot.": "servo",
|
|
34
|
+
"self.led.": "led",
|
|
35
|
+
"self.display.": "avatar",
|
|
36
|
+
"self.screen.": "display",
|
|
37
|
+
"self.audio_speaker.": "audio",
|
|
38
|
+
"self.camera.": "camera",
|
|
39
|
+
"self.touch.": "touch",
|
|
40
|
+
"self.get_device_status": "status",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _hardware_lane(tool_name: str) -> str:
|
|
45
|
+
"""Return the hardware lane used for per-peripheral dispatch ordering."""
|
|
46
|
+
for prefix, lane in _TOOL_LANES.items():
|
|
47
|
+
if tool_name.startswith(prefix):
|
|
48
|
+
return lane
|
|
49
|
+
return "default"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _retrieve_future_exception(future: asyncio.Future[Any]) -> None:
|
|
53
|
+
"""Mark a completed Future exception as observed, if it has one."""
|
|
54
|
+
if future.done() and not future.cancelled():
|
|
55
|
+
future.exception()
|
|
56
|
+
|
|
28
57
|
|
|
29
58
|
class ESP32Connection:
|
|
30
59
|
"""Manages a single ESP32 device connection."""
|
|
@@ -75,14 +104,18 @@ class ESP32Connection:
|
|
|
75
104
|
self._pending[req_id] = future
|
|
76
105
|
|
|
77
106
|
try:
|
|
78
|
-
await self.
|
|
107
|
+
await self._ws_send(json.dumps(message))
|
|
79
108
|
response = await asyncio.wait_for(future, timeout=RESPONSE_TIMEOUT)
|
|
80
109
|
return parse_jsonrpc_response(response)
|
|
110
|
+
except asyncio.CancelledError:
|
|
111
|
+
self._pending.pop(req_id, None)
|
|
112
|
+
raise
|
|
81
113
|
except asyncio.TimeoutError:
|
|
82
114
|
self._pending.pop(req_id, None)
|
|
83
115
|
return None, {"code": -32000, "message": f"Timeout waiting for ESP32 response (method={method})"}
|
|
84
116
|
except Exception as exc:
|
|
85
117
|
self._pending.pop(req_id, None)
|
|
118
|
+
_retrieve_future_exception(future)
|
|
86
119
|
return None, {"code": -32000, "message": f"ESP32 communication error: {exc}"}
|
|
87
120
|
|
|
88
121
|
async def initialize(self, vision_url: str = "", vision_token: str = "") -> bool:
|
|
@@ -285,6 +318,17 @@ class ESP32Manager:
|
|
|
285
318
|
# observable from gateway code; if a full-duplex contract
|
|
286
319
|
# ever lands later the lock can split again.
|
|
287
320
|
self._listen_lock = self._tts_lock
|
|
321
|
+
self._tool_lane_locks = {
|
|
322
|
+
"servo": asyncio.Lock(),
|
|
323
|
+
"led": asyncio.Lock(),
|
|
324
|
+
"avatar": asyncio.Lock(),
|
|
325
|
+
"display": asyncio.Lock(),
|
|
326
|
+
"audio": asyncio.Lock(),
|
|
327
|
+
"camera": asyncio.Lock(),
|
|
328
|
+
"touch": asyncio.Lock(),
|
|
329
|
+
"status": asyncio.Lock(),
|
|
330
|
+
"default": asyncio.Lock(),
|
|
331
|
+
}
|
|
288
332
|
|
|
289
333
|
@property
|
|
290
334
|
def device_connected(self) -> bool:
|
|
@@ -481,13 +525,55 @@ class ESP32Manager:
|
|
|
481
525
|
|
|
482
526
|
async def call_tool(
|
|
483
527
|
self, name: str, arguments: dict[str, Any]
|
|
484
|
-
) ->
|
|
528
|
+
) -> ToolCallResult:
|
|
485
529
|
"""Call a tool on the connected ESP32 device."""
|
|
530
|
+
result = await self.call_tools([(name, arguments)])
|
|
531
|
+
return result[0]
|
|
532
|
+
|
|
533
|
+
async def call_tools(self, calls: Sequence[ToolCall]) -> list[ToolCallResult]:
|
|
534
|
+
"""Call multiple ESP32 tools while preserving per-hardware ordering.
|
|
535
|
+
|
|
536
|
+
Existing single-tool callers should continue to use ``call_tool``.
|
|
537
|
+
This helper is for compound gateway flows that can safely overlap
|
|
538
|
+
hardware-independent peripherals, such as servo + LEDs + avatar.
|
|
539
|
+
Calls sharing the same hardware lane are serialized; calls on
|
|
540
|
+
different lanes are dispatched concurrently.
|
|
541
|
+
"""
|
|
542
|
+
if not calls:
|
|
543
|
+
return []
|
|
486
544
|
if not self._connection or not self._connection.connected:
|
|
487
|
-
return
|
|
545
|
+
return [
|
|
546
|
+
(None, {"code": -32000, "message": "No ESP32 device connected"})
|
|
547
|
+
for _ in calls
|
|
548
|
+
]
|
|
488
549
|
if not self._connection.initialized:
|
|
489
|
-
return
|
|
490
|
-
|
|
550
|
+
return [
|
|
551
|
+
(None, {"code": -32000, "message": "ESP32 not initialized"})
|
|
552
|
+
for _ in calls
|
|
553
|
+
]
|
|
554
|
+
|
|
555
|
+
connection = self._connection
|
|
556
|
+
return list(
|
|
557
|
+
await asyncio.gather(
|
|
558
|
+
*(
|
|
559
|
+
self._call_tool_on_connection(connection, name, arguments)
|
|
560
|
+
for name, arguments in calls
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
async def _call_tool_on_connection(
|
|
566
|
+
self,
|
|
567
|
+
connection: ESP32Connection,
|
|
568
|
+
name: str,
|
|
569
|
+
arguments: dict[str, Any],
|
|
570
|
+
) -> ToolCallResult:
|
|
571
|
+
lane = _hardware_lane(name)
|
|
572
|
+
lock = self._tool_lane_locks[lane]
|
|
573
|
+
async with lock:
|
|
574
|
+
if connection is not self._connection or not connection.connected:
|
|
575
|
+
return None, {"code": -32000, "message": "ESP32 not connected"}
|
|
576
|
+
return await connection.call_tool(name, arguments)
|
|
491
577
|
|
|
492
578
|
async def send_audio_frame(self, opus_frame: bytes) -> None:
|
|
493
579
|
"""Push a single Opus frame to the connected device.
|
|
@@ -296,6 +296,77 @@ def create_server() -> Server:
|
|
|
296
296
|
"required": ["enabled"],
|
|
297
297
|
},
|
|
298
298
|
),
|
|
299
|
+
Tool(
|
|
300
|
+
name="set_servo_torque",
|
|
301
|
+
description=(
|
|
302
|
+
"Enable or disable SCS0009 servo torque on the yaw / "
|
|
303
|
+
"pitch axes independently. Disabling torque stops motor "
|
|
304
|
+
"current on that axis; the head holds via static "
|
|
305
|
+
"friction (no motion is commanded). On disable, the "
|
|
306
|
+
"firmware also cancels any in-flight MotionDriver "
|
|
307
|
+
"interpolation and marks the axis position unknown so "
|
|
308
|
+
"a subsequent same-target set_head_angles is re-"
|
|
309
|
+
"dispatched rather than no-op-optimized. Re-enabling "
|
|
310
|
+
"torque does NOT trigger a move; the next "
|
|
311
|
+
"set_head_angles or wobble call will. Diagnostic / "
|
|
312
|
+
"power-management primitive used to observe physical "
|
|
313
|
+
"head behavior under torque-off (Issue #163; auto "
|
|
314
|
+
"release on idle is Issue #152 Phase 4)."
|
|
315
|
+
),
|
|
316
|
+
inputSchema={
|
|
317
|
+
"type": "object",
|
|
318
|
+
"properties": {
|
|
319
|
+
"yaw_enabled": {
|
|
320
|
+
"type": "boolean",
|
|
321
|
+
"description": (
|
|
322
|
+
"True to enable yaw axis torque, false to "
|
|
323
|
+
"disable."
|
|
324
|
+
),
|
|
325
|
+
},
|
|
326
|
+
"pitch_enabled": {
|
|
327
|
+
"type": "boolean",
|
|
328
|
+
"description": (
|
|
329
|
+
"True to enable pitch axis torque, false "
|
|
330
|
+
"to disable."
|
|
331
|
+
),
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
"required": ["yaw_enabled", "pitch_enabled"],
|
|
335
|
+
},
|
|
336
|
+
),
|
|
337
|
+
Tool(
|
|
338
|
+
name="set_auto_torque_release",
|
|
339
|
+
description=(
|
|
340
|
+
"Enable or disable firmware-side automatic SCS0009 "
|
|
341
|
+
"torque release after motion idle timeout. timeout_ms "
|
|
342
|
+
"is clamped by the firmware to 500..600000 ms. "
|
|
343
|
+
"Disabling this setting does not re-enable torque if "
|
|
344
|
+
"it is already released; the next set_head_angles, "
|
|
345
|
+
"wobble, or explicit set_servo_torque(true, true) call "
|
|
346
|
+
"re-engages torque."
|
|
347
|
+
),
|
|
348
|
+
inputSchema={
|
|
349
|
+
"type": "object",
|
|
350
|
+
"properties": {
|
|
351
|
+
"enabled": {
|
|
352
|
+
"type": "boolean",
|
|
353
|
+
"description": (
|
|
354
|
+
"True to enable idle auto-release, false "
|
|
355
|
+
"to disable it."
|
|
356
|
+
),
|
|
357
|
+
},
|
|
358
|
+
"timeout_ms": {
|
|
359
|
+
"type": "integer",
|
|
360
|
+
"description": (
|
|
361
|
+
"Idle timeout in milliseconds. Values "
|
|
362
|
+
"outside 500..600000 are clamped by the "
|
|
363
|
+
"firmware handler."
|
|
364
|
+
),
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
"required": ["enabled", "timeout_ms"],
|
|
368
|
+
},
|
|
369
|
+
),
|
|
299
370
|
Tool(
|
|
300
371
|
name="get_touch_state",
|
|
301
372
|
description=(
|
|
@@ -677,6 +748,14 @@ def create_server() -> Server:
|
|
|
677
748
|
"self.display.set_blink",
|
|
678
749
|
arguments,
|
|
679
750
|
),
|
|
751
|
+
"set_servo_torque": (
|
|
752
|
+
"self.robot.set_servo_torque",
|
|
753
|
+
arguments,
|
|
754
|
+
),
|
|
755
|
+
"set_auto_torque_release": (
|
|
756
|
+
"self.robot.set_auto_torque_release",
|
|
757
|
+
arguments,
|
|
758
|
+
),
|
|
680
759
|
"get_touch_state": (
|
|
681
760
|
"self.touch.get_touch_state",
|
|
682
761
|
{},
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import array
|
|
6
|
+
import importlib
|
|
6
7
|
|
|
7
8
|
import pytest
|
|
8
9
|
|
|
@@ -206,8 +207,8 @@ def test_chunk_pcm_into_frames_rejects_non_positive_size():
|
|
|
206
207
|
|
|
207
208
|
def test_encode_opus_frames_produces_frames_when_libopus_available():
|
|
208
209
|
"""When libopus is reachable, encode_opus_frames yields one frame per chunk."""
|
|
209
|
-
opuslib = pytest.importorskip("opuslib")
|
|
210
210
|
try:
|
|
211
|
+
opuslib = importlib.import_module("opuslib")
|
|
211
212
|
opuslib.Encoder(16000, 1, opuslib.APPLICATION_VOIP)
|
|
212
213
|
except Exception as exc: # libopus not loadable
|
|
213
214
|
pytest.skip(f"libopus not available: {exc}")
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"""Tests for ESP32 client connection management."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import gc
|
|
4
5
|
import json
|
|
5
6
|
|
|
6
7
|
import pytest
|
|
7
8
|
import pytest_asyncio
|
|
8
9
|
import websockets
|
|
9
10
|
|
|
10
|
-
from stackchan_mcp.esp32_client import ESP32Connection, ESP32Manager
|
|
11
|
+
from stackchan_mcp.esp32_client import ESP32Connection, ESP32Manager, _hardware_lane
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@pytest_asyncio.fixture
|
|
@@ -223,6 +224,242 @@ async def test_auth_rejection(manager):
|
|
|
223
224
|
del os.environ["STACKCHAN_TOKEN"]
|
|
224
225
|
|
|
225
226
|
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Parallel hardware-lane dispatch (Issue #73)
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@pytest.mark.parametrize(
|
|
233
|
+
("tool_name", "lane"),
|
|
234
|
+
[
|
|
235
|
+
("self.robot.set_head_angles", "servo"),
|
|
236
|
+
("self.led.set_many", "led"),
|
|
237
|
+
("self.display.set_avatar", "avatar"),
|
|
238
|
+
("self.screen.set_brightness", "display"),
|
|
239
|
+
("self.audio_speaker.set_volume", "audio"),
|
|
240
|
+
("self.camera.take_photo", "camera"),
|
|
241
|
+
("self.touch.get_touch_state", "touch"),
|
|
242
|
+
("self.get_device_status", "status"),
|
|
243
|
+
("self.unknown.experimental", "default"),
|
|
244
|
+
],
|
|
245
|
+
)
|
|
246
|
+
def test_hardware_lane_covers_gateway_tool_routes(tool_name, lane):
|
|
247
|
+
"""Gateway-routed ESP32 tools map to explicit hardware lanes."""
|
|
248
|
+
assert _hardware_lane(tool_name) == lane
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@pytest.mark.asyncio
|
|
252
|
+
async def test_connection_pipelines_concurrent_tool_calls_before_first_response():
|
|
253
|
+
"""Concurrent tools/call requests are sent before either response arrives."""
|
|
254
|
+
ws = _FakeWebSocket()
|
|
255
|
+
conn = ESP32Connection(ws, session_id="session-parallel") # type: ignore[arg-type]
|
|
256
|
+
|
|
257
|
+
servo_task = asyncio.create_task(
|
|
258
|
+
conn.call_tool("self.robot.set_head_angles", {"yaw": 10, "pitch": 30})
|
|
259
|
+
)
|
|
260
|
+
led_task = asyncio.create_task(
|
|
261
|
+
conn.call_tool("self.led.set_many", {"colors": "[[255, 0, 0]]"})
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
await asyncio.sleep(0)
|
|
265
|
+
|
|
266
|
+
assert len(ws.sent) == 2
|
|
267
|
+
sent_messages = [json.loads(message) for message in ws.sent]
|
|
268
|
+
request_ids = [message["payload"]["id"] for message in sent_messages]
|
|
269
|
+
assert [message["payload"]["method"] for message in sent_messages] == [
|
|
270
|
+
"tools/call",
|
|
271
|
+
"tools/call",
|
|
272
|
+
]
|
|
273
|
+
assert [message["payload"]["params"]["name"] for message in sent_messages] == [
|
|
274
|
+
"self.robot.set_head_angles",
|
|
275
|
+
"self.led.set_many",
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
conn.handle_response(
|
|
279
|
+
{
|
|
280
|
+
"jsonrpc": "2.0",
|
|
281
|
+
"id": request_ids[1],
|
|
282
|
+
"result": {"content": [{"type": "text", "text": "led"}]},
|
|
283
|
+
}
|
|
284
|
+
)
|
|
285
|
+
conn.handle_response(
|
|
286
|
+
{
|
|
287
|
+
"jsonrpc": "2.0",
|
|
288
|
+
"id": request_ids[0],
|
|
289
|
+
"result": {"content": [{"type": "text", "text": "servo"}]},
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
servo_result, led_result = await asyncio.gather(servo_task, led_task)
|
|
294
|
+
assert servo_result[0]["content"][0]["text"] == "servo"
|
|
295
|
+
assert servo_result[1] is None
|
|
296
|
+
assert led_result[0]["content"][0]["text"] == "led"
|
|
297
|
+
assert led_result[1] is None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@pytest.mark.asyncio
|
|
301
|
+
async def test_connection_removes_pending_request_when_call_is_cancelled():
|
|
302
|
+
"""Cancelling a tool call does not leave a stale pending response slot."""
|
|
303
|
+
ws = _FakeWebSocket()
|
|
304
|
+
conn = ESP32Connection(ws, session_id="session-cancel") # type: ignore[arg-type]
|
|
305
|
+
|
|
306
|
+
task = asyncio.create_task(
|
|
307
|
+
conn.call_tool("self.robot.set_head_angles", {"yaw": 10, "pitch": 30})
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
await asyncio.sleep(0)
|
|
311
|
+
assert len(ws.sent) == 1
|
|
312
|
+
assert len(conn._pending) == 1
|
|
313
|
+
|
|
314
|
+
task.cancel()
|
|
315
|
+
with pytest.raises(asyncio.CancelledError):
|
|
316
|
+
await task
|
|
317
|
+
|
|
318
|
+
assert conn._pending == {}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class _GateableConnection:
|
|
322
|
+
"""Fake initialized connection with per-tool release gates."""
|
|
323
|
+
|
|
324
|
+
connected = True
|
|
325
|
+
initialized = True
|
|
326
|
+
|
|
327
|
+
def __init__(self, releases: dict[str, asyncio.Event]) -> None:
|
|
328
|
+
self.releases = releases
|
|
329
|
+
self.started: list[str] = []
|
|
330
|
+
self.finished: list[str] = []
|
|
331
|
+
self.all_started = asyncio.Event()
|
|
332
|
+
|
|
333
|
+
async def call_tool(self, name, arguments): # noqa: ARG002 - test fake
|
|
334
|
+
self.started.append(name)
|
|
335
|
+
if len(self.started) >= len(self.releases):
|
|
336
|
+
self.all_started.set()
|
|
337
|
+
await self.releases[name].wait()
|
|
338
|
+
self.finished.append(name)
|
|
339
|
+
return {"content": [{"type": "text", "text": name}]}, None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@pytest.mark.asyncio
|
|
343
|
+
async def test_manager_call_tools_dispatches_independent_lanes_in_parallel():
|
|
344
|
+
"""Servo, LED, and avatar calls start together instead of waiting in line."""
|
|
345
|
+
releases = {
|
|
346
|
+
"self.robot.set_head_angles": asyncio.Event(),
|
|
347
|
+
"self.led.set_many": asyncio.Event(),
|
|
348
|
+
"self.display.set_avatar": asyncio.Event(),
|
|
349
|
+
}
|
|
350
|
+
connection = _GateableConnection(releases)
|
|
351
|
+
mgr = ESP32Manager()
|
|
352
|
+
mgr._connection = connection # type: ignore[assignment]
|
|
353
|
+
|
|
354
|
+
task = asyncio.create_task(
|
|
355
|
+
mgr.call_tools(
|
|
356
|
+
[
|
|
357
|
+
("self.robot.set_head_angles", {"yaw": 0, "pitch": 45}),
|
|
358
|
+
("self.led.set_many", {"colors": "[]"}),
|
|
359
|
+
("self.display.set_avatar", {"face": "happy"}),
|
|
360
|
+
]
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
await asyncio.wait_for(connection.all_started.wait(), timeout=1.0)
|
|
365
|
+
assert connection.started == [
|
|
366
|
+
"self.robot.set_head_angles",
|
|
367
|
+
"self.led.set_many",
|
|
368
|
+
"self.display.set_avatar",
|
|
369
|
+
]
|
|
370
|
+
assert connection.finished == []
|
|
371
|
+
|
|
372
|
+
for release in releases.values():
|
|
373
|
+
release.set()
|
|
374
|
+
results = await asyncio.wait_for(task, timeout=1.0)
|
|
375
|
+
|
|
376
|
+
assert [result[0]["content"][0]["text"] for result in results] == [
|
|
377
|
+
"self.robot.set_head_angles",
|
|
378
|
+
"self.led.set_many",
|
|
379
|
+
"self.display.set_avatar",
|
|
380
|
+
]
|
|
381
|
+
assert [error for _, error in results] == [None, None, None]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@pytest.mark.asyncio
|
|
385
|
+
async def test_manager_call_tool_uses_lane_dispatch_for_existing_api():
|
|
386
|
+
"""Existing single-tool API can still overlap independent hardware lanes."""
|
|
387
|
+
releases = {
|
|
388
|
+
"self.robot.set_head_angles": asyncio.Event(),
|
|
389
|
+
"self.led.set_many": asyncio.Event(),
|
|
390
|
+
}
|
|
391
|
+
connection = _GateableConnection(releases)
|
|
392
|
+
mgr = ESP32Manager()
|
|
393
|
+
mgr._connection = connection # type: ignore[assignment]
|
|
394
|
+
|
|
395
|
+
servo_task = asyncio.create_task(
|
|
396
|
+
mgr.call_tool("self.robot.set_head_angles", {"yaw": 0, "pitch": 45})
|
|
397
|
+
)
|
|
398
|
+
led_task = asyncio.create_task(
|
|
399
|
+
mgr.call_tool("self.led.set_many", {"colors": "[]"})
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
await asyncio.wait_for(connection.all_started.wait(), timeout=1.0)
|
|
403
|
+
assert connection.started == [
|
|
404
|
+
"self.robot.set_head_angles",
|
|
405
|
+
"self.led.set_many",
|
|
406
|
+
]
|
|
407
|
+
assert connection.finished == []
|
|
408
|
+
|
|
409
|
+
for release in releases.values():
|
|
410
|
+
release.set()
|
|
411
|
+
results = await asyncio.wait_for(
|
|
412
|
+
asyncio.gather(servo_task, led_task),
|
|
413
|
+
timeout=1.0,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
assert [result[0]["content"][0]["text"] for result in results] == [
|
|
417
|
+
"self.robot.set_head_angles",
|
|
418
|
+
"self.led.set_many",
|
|
419
|
+
]
|
|
420
|
+
assert [error for _, error in results] == [None, None]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@pytest.mark.asyncio
|
|
424
|
+
async def test_manager_call_tools_serializes_calls_on_same_hardware_lane():
|
|
425
|
+
"""Two servo calls keep their relative order on the servo lane."""
|
|
426
|
+
releases = {
|
|
427
|
+
"self.robot.set_head_angles": asyncio.Event(),
|
|
428
|
+
"self.robot.get_head_angles": asyncio.Event(),
|
|
429
|
+
}
|
|
430
|
+
connection = _GateableConnection(releases)
|
|
431
|
+
mgr = ESP32Manager()
|
|
432
|
+
mgr._connection = connection # type: ignore[assignment]
|
|
433
|
+
|
|
434
|
+
task = asyncio.create_task(
|
|
435
|
+
mgr.call_tools(
|
|
436
|
+
[
|
|
437
|
+
("self.robot.set_head_angles", {"yaw": 0, "pitch": 45}),
|
|
438
|
+
("self.robot.get_head_angles", {}),
|
|
439
|
+
]
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
await asyncio.sleep(0)
|
|
444
|
+
await asyncio.sleep(0)
|
|
445
|
+
assert connection.started == ["self.robot.set_head_angles"]
|
|
446
|
+
|
|
447
|
+
releases["self.robot.set_head_angles"].set()
|
|
448
|
+
await asyncio.sleep(0)
|
|
449
|
+
await asyncio.sleep(0)
|
|
450
|
+
assert connection.started == [
|
|
451
|
+
"self.robot.set_head_angles",
|
|
452
|
+
"self.robot.get_head_angles",
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
releases["self.robot.get_head_angles"].set()
|
|
456
|
+
await asyncio.wait_for(task, timeout=1.0)
|
|
457
|
+
assert connection.finished == [
|
|
458
|
+
"self.robot.set_head_angles",
|
|
459
|
+
"self.robot.get_head_angles",
|
|
460
|
+
]
|
|
461
|
+
|
|
462
|
+
|
|
226
463
|
# ---------------------------------------------------------------------------
|
|
227
464
|
# send_audio_frame (TTS pipeline egress, Issue #70 PR2)
|
|
228
465
|
# ---------------------------------------------------------------------------
|
|
@@ -443,6 +680,32 @@ async def test_send_tts_state_translates_oserror_to_connection_error():
|
|
|
443
680
|
assert not conn.connected
|
|
444
681
|
|
|
445
682
|
|
|
683
|
+
@pytest.mark.asyncio
|
|
684
|
+
async def test_send_mcp_request_translates_send_failure_and_marks_disconnected():
|
|
685
|
+
"""tools/call send failures use the same connection-state handling as TTS."""
|
|
686
|
+
ws = _FailingWebSocket(OSError("broken pipe"))
|
|
687
|
+
conn = ESP32Connection(ws, session_id="session-1") # type: ignore[arg-type]
|
|
688
|
+
loop = asyncio.get_running_loop()
|
|
689
|
+
loop_errors = []
|
|
690
|
+
previous_handler = loop.get_exception_handler()
|
|
691
|
+
|
|
692
|
+
loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))
|
|
693
|
+
try:
|
|
694
|
+
result, error = await conn.call_tool("self.robot.set_head_angles", {})
|
|
695
|
+
gc.collect()
|
|
696
|
+
await asyncio.sleep(0)
|
|
697
|
+
finally:
|
|
698
|
+
loop.set_exception_handler(previous_handler)
|
|
699
|
+
|
|
700
|
+
assert result is None
|
|
701
|
+
assert error is not None
|
|
702
|
+
assert "WebSocket send failed" in error["message"]
|
|
703
|
+
assert not conn.connected
|
|
704
|
+
assert conn._pending == {}
|
|
705
|
+
assert ws.send_calls == 1
|
|
706
|
+
assert loop_errors == []
|
|
707
|
+
|
|
708
|
+
|
|
446
709
|
def test_connection_default_protocol_version_is_one():
|
|
447
710
|
"""Fresh ESP32Connection defaults to WebSocket protocol v1.
|
|
448
711
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|