stackchan-mcp 0.13.0__tar.gz → 0.14.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.13.0 → stackchan_mcp-0.14.0}/PKG-INFO +1 -1
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/pyproject.toml +1 -1
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/capture_server.py +26 -14
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/cli.py +41 -8
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/esp32_client.py +61 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_capture_server.py +69 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_cli.py +72 -5
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_esp32_client.py +311 -2
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/uv.lock +1 -1
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/.env.example +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/.gitignore +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/AGENTS.md +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/LICENSE +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/LICENSE-THIRD-PARTY +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/README.md +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/__init__.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/__main__.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/audio_input_hook.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/audio_stream.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/event_log.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/follow_pose_stream.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/gateway.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/__init__.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/audio.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/camera.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/robot.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/http_server.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/mcp_router.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/mdns_advertiser.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/notify.example.yml +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/notify_config.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/ownership.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/protocol.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/queue.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/server.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stdio_server.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/__init__.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/audio_utils.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/base.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/orchestrator.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tools.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/__init__.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/audio_utils.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/base.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/edge_tts.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/emoji_expression.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/irodori.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/orchestrator.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/voicevox.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/stackchan_mcp/user_defaults.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/_audio_fixtures.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/conftest.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_audio_input_hook.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_audio_stream.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_audio_utils.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_emoji_expression.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_event_dispatch.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_event_log.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_follow_pose_stream.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_gateway.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_http_server.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_irodori.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_mcp_router.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_mdns_advertiser.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_notify_config.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_orchestrator.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_ownership.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_protocol.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_send_pcm_audio.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_send_pcm_stream.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_stackchan_event.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_stdio_server.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_stt_audio_utils.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_stt_framework.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_stt_orchestrator.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_tts_framework.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.0}/tests/test_user_defaults.py +0 -0
- {stackchan_mcp-0.13.0 → stackchan_mcp-0.14.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.14.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
|
|
@@ -51,6 +51,7 @@ import json
|
|
|
51
51
|
import logging
|
|
52
52
|
import os
|
|
53
53
|
import secrets
|
|
54
|
+
import sys
|
|
54
55
|
import time
|
|
55
56
|
from dataclasses import dataclass
|
|
56
57
|
from typing import TYPE_CHECKING, AsyncIterator
|
|
@@ -142,7 +143,11 @@ async def handle_capture(request: web.Request) -> web.Response:
|
|
|
142
143
|
|
|
143
144
|
async for part in reader:
|
|
144
145
|
if part.name == "question":
|
|
145
|
-
|
|
146
|
+
# Decode defensively: a malformed (non-UTF-8) question field must
|
|
147
|
+
# not turn a photo upload into a 500. The question is advisory
|
|
148
|
+
# metadata, so replacing undecodable bytes is preferable to
|
|
149
|
+
# dropping the whole capture.
|
|
150
|
+
question = (await part.read()).decode("utf-8", errors="replace")
|
|
146
151
|
elif part.name == "file":
|
|
147
152
|
timestamp = int(time.time() * 1000)
|
|
148
153
|
filename = f"capture_{timestamp}.jpg"
|
|
@@ -445,19 +450,26 @@ def create_capture_app(
|
|
|
445
450
|
dispatches to. May be ``None`` for tests of /capture alone; /pcm
|
|
446
451
|
will return 503 in that case.
|
|
447
452
|
"""
|
|
448
|
-
#
|
|
449
|
-
#
|
|
450
|
-
#
|
|
451
|
-
#
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
-
#
|
|
455
|
-
#
|
|
456
|
-
#
|
|
457
|
-
#
|
|
458
|
-
#
|
|
459
|
-
#
|
|
460
|
-
|
|
453
|
+
# Raise aiohttp's per-request body cap (default 1 MiB) effectively to
|
|
454
|
+
# unlimited. The /pcm endpoint legitimately streams arbitrarily long PCM
|
|
455
|
+
# utterances (multi-minute TTS, live audio mixes); a 1 MiB cap would
|
|
456
|
+
# silently cut a chunked-transfer producer off mid-stream once its
|
|
457
|
+
# cumulative body exceeded that limit — observed in practice with a
|
|
458
|
+
# 200-second TTS push, which aborted around 36 s in (~2 MiB of
|
|
459
|
+
# source-rate PCM through the transfer-encoding pipe).
|
|
460
|
+
#
|
|
461
|
+
# Use ``sys.maxsize`` rather than ``0``. Although ``0`` disables the cap
|
|
462
|
+
# for ``request.read()`` / ``request.post()`` (both guard with
|
|
463
|
+
# ``if 0 < max_size``), aiohttp's multipart reader does NOT special-case
|
|
464
|
+
# ``0``: ``BodyPartReader.read()`` compares ``len(data) > client_max_size``
|
|
465
|
+
# directly (aiohttp >= 3.14), so a client_max_size of 0 propagated into
|
|
466
|
+
# ``request.multipart()`` rejects ANY non-empty multipart field with
|
|
467
|
+
# HTTP 413. /capture reads its ``question`` form field via ``part.read()``,
|
|
468
|
+
# so with ``client_max_size=0`` every photo upload carrying a non-empty
|
|
469
|
+
# question 413s ("Failed to upload photo"). A large finite value avoids
|
|
470
|
+
# that trap while keeping /pcm effectively unbounded. /capture's real
|
|
471
|
+
# size limit is the explicit CAPTURE_MAX_BYTES check in handle_capture.
|
|
472
|
+
app = web.Application(client_max_size=sys.maxsize)
|
|
461
473
|
app[CAPTURE_TOKEN_KEY] = capture_token
|
|
462
474
|
app[AVATAR_SETS_KEY] = {}
|
|
463
475
|
app[AVATAR_SETS_LOCK_KEY] = asyncio.Lock()
|
|
@@ -438,9 +438,20 @@ def _run_ownership_check() -> int:
|
|
|
438
438
|
"""Print the current ownership lock status and exit cleanly."""
|
|
439
439
|
from .ownership import is_pid_alive, read_lock
|
|
440
440
|
|
|
441
|
-
|
|
441
|
+
# Load ``.env`` first so that a WS_PORT / PORT defined only there resolves
|
|
442
|
+
# the same per-port lock the running gateway claims. Startup loads ``.env``
|
|
443
|
+
# before ``_acquire_startup_lock`` (via ``_run_stdio_gateway`` /
|
|
444
|
+
# ``_run_streamable_http_placeholder``), so ``--check`` must match it —
|
|
445
|
+
# otherwise a gateway owning ``owner-18765.lock`` would be inspected against
|
|
446
|
+
# the default ``owner-8765.lock`` and wrongly reported ready. Mirrors how
|
|
447
|
+
# ``--preflight`` loads ``.env`` inside ``_run_preflight``.
|
|
448
|
+
_load_dotenv()
|
|
449
|
+
# Inspect the per-WS_PORT lock this gateway would claim (not the legacy
|
|
450
|
+
# machine-global owner.lock), so the preflight reflects the actual port.
|
|
451
|
+
lock_path = _ws_port_lock_path()
|
|
452
|
+
info = read_lock(lock_path)
|
|
442
453
|
if info is None:
|
|
443
|
-
print("no current owner")
|
|
454
|
+
print(f"no current owner (lock: {lock_path.name})")
|
|
444
455
|
print("ownership preflight: ready")
|
|
445
456
|
print("Result: ready. Exit 0.")
|
|
446
457
|
elif is_pid_alive(info["pid"]):
|
|
@@ -750,6 +761,24 @@ def _configure_gateway_startup() -> None:
|
|
|
750
761
|
log_user_defaults_startup()
|
|
751
762
|
|
|
752
763
|
|
|
764
|
+
def _ws_port_lock_path():
|
|
765
|
+
"""Per-WS_PORT ownership lock path.
|
|
766
|
+
|
|
767
|
+
複数機体対応: ownership lock を machine-global な ``owner.lock`` でなく
|
|
768
|
+
WS_PORT 単位に scope する。 lock は「同一 device を 2 つの gateway が
|
|
769
|
+
奪い合わない」 ための機構だが、 1 device = 1 gateway = 1 WS ポートで
|
|
770
|
+
複数機体を同時に動かす構成では global lock が 2 台目以降を誤って弾く。
|
|
771
|
+
ポート単位なら N gateway が共存でき、 同一ポート二重起動は従来通り弾ける。
|
|
772
|
+
WS_PORT 未解決時は従来の global パスにフォールバック。
|
|
773
|
+
"""
|
|
774
|
+
from .ownership import LOCK_DIR
|
|
775
|
+
|
|
776
|
+
ws_port, _ = _resolve_ws_port()
|
|
777
|
+
return LOCK_DIR / (
|
|
778
|
+
f"owner-{ws_port}.lock" if ws_port is not None else "owner.lock"
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
|
|
753
782
|
def _acquire_startup_lock(
|
|
754
783
|
*,
|
|
755
784
|
mode: "LockMode" = _STDIO_TRANSPORT,
|
|
@@ -764,13 +793,16 @@ def _acquire_startup_lock(
|
|
|
764
793
|
release_lock_if_owner,
|
|
765
794
|
)
|
|
766
795
|
|
|
796
|
+
lock_path = _ws_port_lock_path()
|
|
797
|
+
|
|
767
798
|
owner_id = generate_owner_id()
|
|
768
799
|
try:
|
|
769
800
|
if mode == _STDIO_TRANSPORT and http_endpoint is None and started_by is None:
|
|
770
|
-
info = acquire_lock(owner_id)
|
|
801
|
+
info = acquire_lock(owner_id, path=lock_path)
|
|
771
802
|
else:
|
|
772
803
|
info = acquire_lock(
|
|
773
804
|
owner_id,
|
|
805
|
+
path=lock_path,
|
|
774
806
|
mode=mode,
|
|
775
807
|
http_endpoint=http_endpoint,
|
|
776
808
|
started_by=started_by,
|
|
@@ -782,12 +814,13 @@ def _acquire_startup_lock(
|
|
|
782
814
|
try:
|
|
783
815
|
print(
|
|
784
816
|
"stackchan-mcp: acquired ownership lock "
|
|
785
|
-
f"(owner_id={info['owner_id']}, pid={info['pid']}
|
|
817
|
+
f"(owner_id={info['owner_id']}, pid={info['pid']}, "
|
|
818
|
+
f"lock={lock_path.name})",
|
|
786
819
|
file=sys.stderr,
|
|
787
820
|
)
|
|
788
|
-
atexit.register(release_lock_if_owner, info)
|
|
821
|
+
atexit.register(release_lock_if_owner, info, lock_path)
|
|
789
822
|
except BaseException:
|
|
790
|
-
release_lock_if_owner(info)
|
|
823
|
+
release_lock_if_owner(info, lock_path)
|
|
791
824
|
raise
|
|
792
825
|
|
|
793
826
|
return info
|
|
@@ -810,7 +843,7 @@ def _run_stdio_gateway(*, advertise_mdns: bool = True) -> None:
|
|
|
810
843
|
except KeyboardInterrupt:
|
|
811
844
|
pass
|
|
812
845
|
finally:
|
|
813
|
-
release_lock_if_owner(info)
|
|
846
|
+
release_lock_if_owner(info, _ws_port_lock_path())
|
|
814
847
|
|
|
815
848
|
|
|
816
849
|
def _resolve_mcp_http_endpoint() -> tuple[str, int]:
|
|
@@ -920,7 +953,7 @@ def _run_streamable_http_placeholder(*, advertise_mdns: bool = True) -> None:
|
|
|
920
953
|
pass
|
|
921
954
|
finally:
|
|
922
955
|
if info is not None:
|
|
923
|
-
release_lock_if_owner(info)
|
|
956
|
+
release_lock_if_owner(info, _ws_port_lock_path())
|
|
924
957
|
|
|
925
958
|
|
|
926
959
|
def main(argv: list[str] | None = None) -> None:
|
|
@@ -43,6 +43,8 @@ RESPONSE_TIMEOUT = 10.0
|
|
|
43
43
|
ToolCall = tuple[str, dict[str, Any]]
|
|
44
44
|
ToolCallResult = tuple[Any, dict[str, Any] | None]
|
|
45
45
|
|
|
46
|
+
_SET_AVATAR_TOOL = "self.display.set_avatar"
|
|
47
|
+
|
|
46
48
|
_TOOL_LANES = {
|
|
47
49
|
"self.robot.": "servo",
|
|
48
50
|
"self.wifi.": "wifi",
|
|
@@ -82,6 +84,8 @@ class ESP32Connection:
|
|
|
82
84
|
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
83
85
|
self._connected = True
|
|
84
86
|
self._initialized = False
|
|
87
|
+
self._tools_discovered = False
|
|
88
|
+
self._avatar_render_sent = False
|
|
85
89
|
# Phase 4.5 avatar: pending load_avatar_set calls waiting for the
|
|
86
90
|
# device's `avatar_set_loaded` reply. Keyed by expected checksum
|
|
87
91
|
# so that overlapping fetches (different sets) can be discriminated.
|
|
@@ -102,6 +106,14 @@ class ESP32Connection:
|
|
|
102
106
|
def initialized(self) -> bool:
|
|
103
107
|
return self._initialized
|
|
104
108
|
|
|
109
|
+
@property
|
|
110
|
+
def tools_discovered(self) -> bool:
|
|
111
|
+
return self._tools_discovered
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def avatar_render_sent(self) -> bool:
|
|
115
|
+
return self._avatar_render_sent
|
|
116
|
+
|
|
105
117
|
def _next_id(self) -> int:
|
|
106
118
|
self._request_id += 1
|
|
107
119
|
return self._request_id
|
|
@@ -162,6 +174,8 @@ class ESP32Connection:
|
|
|
162
174
|
"""Discover tools available on ESP32."""
|
|
163
175
|
all_tools: list[dict[str, Any]] = []
|
|
164
176
|
cursor = ""
|
|
177
|
+
self._tools_discovered = False
|
|
178
|
+
discovered = False
|
|
165
179
|
|
|
166
180
|
while True:
|
|
167
181
|
params: dict[str, Any] = {"cursor": cursor}
|
|
@@ -169,6 +183,7 @@ class ESP32Connection:
|
|
|
169
183
|
|
|
170
184
|
if error:
|
|
171
185
|
logger.error("tools/list failed: %s", error)
|
|
186
|
+
self.tools = all_tools
|
|
172
187
|
break
|
|
173
188
|
|
|
174
189
|
tools = result.get("tools", [])
|
|
@@ -176,10 +191,12 @@ class ESP32Connection:
|
|
|
176
191
|
|
|
177
192
|
next_cursor = result.get("nextCursor", "")
|
|
178
193
|
if not next_cursor:
|
|
194
|
+
discovered = True
|
|
179
195
|
break
|
|
180
196
|
cursor = next_cursor
|
|
181
197
|
|
|
182
198
|
self.tools = all_tools
|
|
199
|
+
self._tools_discovered = discovered
|
|
183
200
|
logger.info("Discovered %d tools on ESP32", len(all_tools))
|
|
184
201
|
return all_tools
|
|
185
202
|
|
|
@@ -187,6 +204,8 @@ class ESP32Connection:
|
|
|
187
204
|
self, name: str, arguments: dict[str, Any]
|
|
188
205
|
) -> tuple[Any, dict[str, Any] | None]:
|
|
189
206
|
"""Call a tool on ESP32."""
|
|
207
|
+
if name == _SET_AVATAR_TOOL:
|
|
208
|
+
self._avatar_render_sent = True
|
|
190
209
|
return await self.send_mcp_request(
|
|
191
210
|
"tools/call", {"name": name, "arguments": arguments}
|
|
192
211
|
)
|
|
@@ -233,6 +252,9 @@ class ESP32Connection:
|
|
|
233
252
|
return {"ok": False, "checksum": checksum, "error": "device_timeout"}
|
|
234
253
|
except asyncio.CancelledError:
|
|
235
254
|
return {"ok": False, "checksum": checksum, "error": "superseded"}
|
|
255
|
+
except ConnectionError:
|
|
256
|
+
self._avatar_set_waiters.pop(checksum, None)
|
|
257
|
+
return {"ok": False, "checksum": checksum, "error": "disconnected"}
|
|
236
258
|
except Exception as exc:
|
|
237
259
|
self._avatar_set_waiters.pop(checksum, None)
|
|
238
260
|
return {"ok": False, "checksum": checksum, "error": f"send_failed: {exc}"}
|
|
@@ -353,6 +375,10 @@ class ESP32Connection:
|
|
|
353
375
|
if not future.done():
|
|
354
376
|
future.set_exception(ConnectionError("ESP32 disconnected"))
|
|
355
377
|
self._pending.clear()
|
|
378
|
+
for future in self._avatar_set_waiters.values():
|
|
379
|
+
if not future.done():
|
|
380
|
+
future.set_exception(ConnectionError("ESP32 disconnected"))
|
|
381
|
+
self._avatar_set_waiters.clear()
|
|
356
382
|
|
|
357
383
|
|
|
358
384
|
class ESP32Manager:
|
|
@@ -732,6 +758,10 @@ class ESP32Manager:
|
|
|
732
758
|
vision_token=self._vision_token,
|
|
733
759
|
):
|
|
734
760
|
await connection.discover_tools()
|
|
761
|
+
if not connection.tools_discovered:
|
|
762
|
+
logger.error("ESP32 tools discovery failed")
|
|
763
|
+
return
|
|
764
|
+
await self._auto_render_idle_avatar(connection, device_id)
|
|
735
765
|
logger.info(
|
|
736
766
|
"ESP32 ready: device=%s tools=%d",
|
|
737
767
|
device_id,
|
|
@@ -740,6 +770,37 @@ class ESP32Manager:
|
|
|
740
770
|
else:
|
|
741
771
|
logger.error("ESP32 MCP initialization failed")
|
|
742
772
|
|
|
773
|
+
async def _auto_render_idle_avatar(
|
|
774
|
+
self, connection: ESP32Connection, device_id: str
|
|
775
|
+
) -> None:
|
|
776
|
+
"""Best-effort idle avatar render after a fresh device session init."""
|
|
777
|
+
if connection.avatar_render_sent:
|
|
778
|
+
return
|
|
779
|
+
|
|
780
|
+
logger.info(
|
|
781
|
+
"auto-rendering idle avatar (no explicit set_avatar yet): device=%s",
|
|
782
|
+
device_id,
|
|
783
|
+
)
|
|
784
|
+
try:
|
|
785
|
+
_result, error = await connection.call_tool(
|
|
786
|
+
_SET_AVATAR_TOOL,
|
|
787
|
+
{"face": "idle"},
|
|
788
|
+
)
|
|
789
|
+
except Exception as exc:
|
|
790
|
+
logger.warning(
|
|
791
|
+
"auto-rendering idle avatar failed: device=%s error=%s",
|
|
792
|
+
device_id,
|
|
793
|
+
exc,
|
|
794
|
+
)
|
|
795
|
+
return
|
|
796
|
+
|
|
797
|
+
if error:
|
|
798
|
+
logger.warning(
|
|
799
|
+
"auto-rendering idle avatar failed: device=%s error=%s",
|
|
800
|
+
device_id,
|
|
801
|
+
error,
|
|
802
|
+
)
|
|
803
|
+
|
|
743
804
|
async def _emit_stackchan_event(self, payload: dict[str, Any]) -> None:
|
|
744
805
|
"""Forward a firmware-originated stackchan event to the MCP client."""
|
|
745
806
|
event_type = payload.get("event_type")
|
|
@@ -199,3 +199,72 @@ async def test_pcm_no_auth_allows_unauthed_request_when_token_blank():
|
|
|
199
199
|
# We don't auth-reject (would be 401); the next guard (sample-rate)
|
|
200
200
|
# fires first, so 400 here means auth was skipped as intended.
|
|
201
201
|
assert response.status == 400
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# /capture endpoint — multipart body-cap regression
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.asyncio
|
|
210
|
+
async def test_capture_accepts_nonempty_question_field(tmp_path, monkeypatch):
|
|
211
|
+
"""A non-empty multipart ``question`` field must not be rejected with 413.
|
|
212
|
+
|
|
213
|
+
Regression guard. The capture app raises aiohttp's per-request body cap so
|
|
214
|
+
/pcm can stream long PCM. It must NOT do so by setting ``client_max_size=0``:
|
|
215
|
+
aiohttp's multipart reader (>= 3.14) checks ``len(data) > client_max_size``
|
|
216
|
+
directly and, unlike ``request.read()`` / ``request.post()``, does not treat
|
|
217
|
+
``0`` as "unlimited". With ``client_max_size=0`` every /capture upload that
|
|
218
|
+
carries a non-empty ``question`` form field (read via ``part.read()``) fails
|
|
219
|
+
with HTTP 413, surfacing on the device as "Failed to upload photo". A large
|
|
220
|
+
finite cap keeps /pcm unbounded while letting normal uploads through.
|
|
221
|
+
"""
|
|
222
|
+
import aiohttp
|
|
223
|
+
from aiohttp.test_utils import TestClient, TestServer
|
|
224
|
+
|
|
225
|
+
from stackchan_mcp import capture_server
|
|
226
|
+
|
|
227
|
+
monkeypatch.setattr(capture_server, "CAPTURE_DIR", str(tmp_path))
|
|
228
|
+
app = create_capture_app(capture_token="") # blank token → auth skipped
|
|
229
|
+
|
|
230
|
+
async with TestClient(TestServer(app)) as client:
|
|
231
|
+
form = aiohttp.FormData()
|
|
232
|
+
form.add_field("question", "what is in front of you?")
|
|
233
|
+
form.add_field(
|
|
234
|
+
"file",
|
|
235
|
+
b"\xff\xd8\xff\xe0JFIF-dummy-jpeg-bytes",
|
|
236
|
+
filename="photo.jpg",
|
|
237
|
+
content_type="image/jpeg",
|
|
238
|
+
)
|
|
239
|
+
resp = await client.post("/capture", data=form)
|
|
240
|
+
assert resp.status == 200, await resp.text()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@pytest.mark.asyncio
|
|
244
|
+
async def test_capture_tolerates_non_utf8_question(tmp_path, monkeypatch):
|
|
245
|
+
"""A malformed (non-UTF-8) question field must not 500 the upload.
|
|
246
|
+
|
|
247
|
+
The question is advisory metadata; a client that sends it in the wrong
|
|
248
|
+
encoding should still get its photo stored (decoded with errors=replace)
|
|
249
|
+
rather than a 500 that looks to the device like a failed capture.
|
|
250
|
+
"""
|
|
251
|
+
import aiohttp
|
|
252
|
+
from aiohttp.test_utils import TestClient, TestServer
|
|
253
|
+
|
|
254
|
+
from stackchan_mcp import capture_server
|
|
255
|
+
|
|
256
|
+
monkeypatch.setattr(capture_server, "CAPTURE_DIR", str(tmp_path))
|
|
257
|
+
app = create_capture_app(capture_token="")
|
|
258
|
+
|
|
259
|
+
async with TestClient(TestServer(app)) as client:
|
|
260
|
+
with aiohttp.MultipartWriter("form-data") as mpwriter:
|
|
261
|
+
# Raw non-UTF-8 bytes (0x96 is a Shift-JIS lead byte, an invalid
|
|
262
|
+
# UTF-8 start byte) in the question part.
|
|
263
|
+
part = mpwriter.append(b"\x96\x96\x96")
|
|
264
|
+
part.set_content_disposition("form-data", name="question")
|
|
265
|
+
fpart = mpwriter.append(b"\xff\xd8\xff\xe0JFIF-dummy")
|
|
266
|
+
fpart.set_content_disposition(
|
|
267
|
+
"form-data", name="file", filename="photo.jpg"
|
|
268
|
+
)
|
|
269
|
+
resp = await client.post("/capture", data=mpwriter)
|
|
270
|
+
assert resp.status == 200, await resp.text()
|
|
@@ -189,7 +189,7 @@ def test_main_default_advertises_mdns(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
189
189
|
monkeypatch.setattr(cli, "_load_dotenv", lambda: None)
|
|
190
190
|
monkeypatch.setattr(cli, "_ensure_libopus_findable", lambda: None)
|
|
191
191
|
monkeypatch.setattr(cli, "_run", fake_run)
|
|
192
|
-
monkeypatch.setattr(ownership, "release_lock_if_owner", lambda info: True)
|
|
192
|
+
monkeypatch.setattr(ownership, "release_lock_if_owner", lambda info, path=None: True)
|
|
193
193
|
|
|
194
194
|
main([])
|
|
195
195
|
|
|
@@ -210,7 +210,7 @@ def test_main_no_mdns_disables_advertisement(
|
|
|
210
210
|
monkeypatch.setattr(cli, "_load_dotenv", lambda: None)
|
|
211
211
|
monkeypatch.setattr(cli, "_ensure_libopus_findable", lambda: None)
|
|
212
212
|
monkeypatch.setattr(cli, "_run", fake_run)
|
|
213
|
-
monkeypatch.setattr(ownership, "release_lock_if_owner", lambda info: True)
|
|
213
|
+
monkeypatch.setattr(ownership, "release_lock_if_owner", lambda info, path=None: True)
|
|
214
214
|
|
|
215
215
|
main(["--no-mdns"])
|
|
216
216
|
|
|
@@ -325,7 +325,11 @@ def test_main_check_flag_remains_side_effect_free_with_no_mdns(
|
|
|
325
325
|
async def fail_run(*, advertise_mdns: bool = True) -> None:
|
|
326
326
|
raise AssertionError("--check must not start the gateway")
|
|
327
327
|
|
|
328
|
-
|
|
328
|
+
# ``--check`` runs ``_run_ownership_check()``, which calls ``_load_dotenv()``.
|
|
329
|
+
# Without stubbing it, the developer's real ``gateway/.env`` is loaded into
|
|
330
|
+
# ``os.environ`` outside monkeypatch's tracking and never restored, polluting
|
|
331
|
+
# later tests (e.g. a ``STACKCHAN_TOKEN`` there breaks the WS-server suite).
|
|
332
|
+
monkeypatch.setattr(cli, "_load_dotenv", lambda: None)
|
|
329
333
|
monkeypatch.setattr(cli, "_run", fail_run)
|
|
330
334
|
|
|
331
335
|
with pytest.raises(SystemExit) as exc:
|
|
@@ -385,7 +389,9 @@ def test_streamable_http_releases_lock_after_daemon_exit(
|
|
|
385
389
|
monkeypatch.setattr(cli, "_configure_gateway_startup", lambda: None)
|
|
386
390
|
monkeypatch.setattr(cli, "_acquire_startup_lock", fake_acquire)
|
|
387
391
|
monkeypatch.setattr(cli, "_run_streamable_http_daemon", fake_daemon)
|
|
388
|
-
monkeypatch.setattr(
|
|
392
|
+
monkeypatch.setattr(
|
|
393
|
+
ownership, "release_lock_if_owner", lambda info, path=None: released.append(info)
|
|
394
|
+
)
|
|
389
395
|
monkeypatch.delenv("MCP_HTTP_HOST", raising=False)
|
|
390
396
|
monkeypatch.delenv("MCP_HTTP_PORT", raising=False)
|
|
391
397
|
monkeypatch.delenv("STACKCHAN_TOKEN", raising=False)
|
|
@@ -752,7 +758,7 @@ def test_main_check_flag_runs_ownership_check_and_exits(
|
|
|
752
758
|
|
|
753
759
|
_isolate_preflight_env(monkeypatch, tmp_path)
|
|
754
760
|
monkeypatch.setattr(cli, "_run_preflight", lambda: 99)
|
|
755
|
-
monkeypatch.setattr(ownership, "read_lock", lambda: None)
|
|
761
|
+
monkeypatch.setattr(ownership, "read_lock", lambda path=None: None)
|
|
756
762
|
|
|
757
763
|
with pytest.raises(SystemExit) as exc:
|
|
758
764
|
main(["--check"])
|
|
@@ -762,6 +768,67 @@ def test_main_check_flag_runs_ownership_check_and_exits(
|
|
|
762
768
|
assert "Result: ready" in out
|
|
763
769
|
|
|
764
770
|
|
|
771
|
+
def test_main_check_flag_inspects_per_ws_port_lock(
|
|
772
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
773
|
+
capsys: pytest.CaptureFixture[str],
|
|
774
|
+
tmp_path: Path,
|
|
775
|
+
) -> None:
|
|
776
|
+
"""``--check`` inspects the lock for the configured WS_PORT.
|
|
777
|
+
|
|
778
|
+
Guards against the ``--check`` preflight reading the default
|
|
779
|
+
``owner-8765.lock`` while a gateway configured for another port owns
|
|
780
|
+
``owner-<port>.lock``. ``_run_ownership_check`` loads ``.env`` before
|
|
781
|
+
deriving the lock path, mirroring the startup order, so a WS_PORT set in
|
|
782
|
+
the environment (or ``.env``) is reflected here.
|
|
783
|
+
"""
|
|
784
|
+
from stackchan_mcp import ownership
|
|
785
|
+
|
|
786
|
+
_isolate_preflight_env(monkeypatch, tmp_path)
|
|
787
|
+
monkeypatch.setenv("WS_PORT", "18765")
|
|
788
|
+
seen: list[Path] = []
|
|
789
|
+
|
|
790
|
+
def fake_read_lock(path: Path | None = None) -> None:
|
|
791
|
+
seen.append(path)
|
|
792
|
+
return None
|
|
793
|
+
|
|
794
|
+
monkeypatch.setattr(ownership, "read_lock", fake_read_lock)
|
|
795
|
+
|
|
796
|
+
with pytest.raises(SystemExit) as exc:
|
|
797
|
+
main(["--check"])
|
|
798
|
+
assert exc.value.code == 0
|
|
799
|
+
assert seen and seen[0] is not None and seen[0].name == "owner-18765.lock"
|
|
800
|
+
assert "owner-18765.lock" in capsys.readouterr().out
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def test_main_check_flag_scopes_lock_for_ephemeral_ws_port(
|
|
804
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
805
|
+
capsys: pytest.CaptureFixture[str],
|
|
806
|
+
tmp_path: Path,
|
|
807
|
+
) -> None:
|
|
808
|
+
"""``WS_PORT=0`` scopes the lock to ``owner-0.lock``, not the legacy path.
|
|
809
|
+
|
|
810
|
+
Preflight treats ``0`` as a valid (OS-assigned ephemeral) port, so the
|
|
811
|
+
ownership lock must stay per-port for it too. A truthiness check would send
|
|
812
|
+
``0`` to the machine-global ``owner.lock`` and diverge from startup.
|
|
813
|
+
"""
|
|
814
|
+
from stackchan_mcp import ownership
|
|
815
|
+
|
|
816
|
+
_isolate_preflight_env(monkeypatch, tmp_path)
|
|
817
|
+
monkeypatch.setenv("WS_PORT", "0")
|
|
818
|
+
seen: list[Path] = []
|
|
819
|
+
|
|
820
|
+
def fake_read_lock(path: Path | None = None) -> None:
|
|
821
|
+
seen.append(path)
|
|
822
|
+
return None
|
|
823
|
+
|
|
824
|
+
monkeypatch.setattr(ownership, "read_lock", fake_read_lock)
|
|
825
|
+
|
|
826
|
+
with pytest.raises(SystemExit) as exc:
|
|
827
|
+
main(["--check"])
|
|
828
|
+
assert exc.value.code == 0
|
|
829
|
+
assert seen and seen[0] is not None and seen[0].name == "owner-0.lock"
|
|
830
|
+
|
|
831
|
+
|
|
765
832
|
def test_main_preflight_flag_runs_preflight_and_exits(
|
|
766
833
|
monkeypatch: pytest.MonkeyPatch,
|
|
767
834
|
) -> None:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import gc
|
|
5
5
|
import json
|
|
6
|
+
import logging
|
|
6
7
|
|
|
7
8
|
import pytest
|
|
8
9
|
import pytest_asyncio
|
|
@@ -132,6 +133,13 @@ async def test_esp32_hello_handshake(manager):
|
|
|
132
133
|
}
|
|
133
134
|
await ws.send(json.dumps(tools_resp))
|
|
134
135
|
|
|
136
|
+
auto_msg = await _expect_auto_idle_avatar(ws)
|
|
137
|
+
await _send_mcp_response(
|
|
138
|
+
ws,
|
|
139
|
+
auto_msg,
|
|
140
|
+
result={"content": [{"type": "text", "text": "true"}], "isError": False},
|
|
141
|
+
)
|
|
142
|
+
|
|
135
143
|
# Wait for manager to process
|
|
136
144
|
await asyncio.sleep(0.2)
|
|
137
145
|
|
|
@@ -318,6 +326,265 @@ async def test_connection_removes_pending_request_when_call_is_cancelled():
|
|
|
318
326
|
assert conn._pending == {}
|
|
319
327
|
|
|
320
328
|
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# Auto idle avatar render after session initialization (Issue #77)
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class _InitDeviceConnection:
|
|
335
|
+
"""Fake connection for exercising ESP32Manager._init_device."""
|
|
336
|
+
|
|
337
|
+
def __init__(
|
|
338
|
+
self,
|
|
339
|
+
*,
|
|
340
|
+
avatar_render_sent: bool = False,
|
|
341
|
+
discover_ok: bool = True,
|
|
342
|
+
auto_error: dict | None = None,
|
|
343
|
+
auto_exception: Exception | None = None,
|
|
344
|
+
) -> None:
|
|
345
|
+
self.tools: list[dict] = []
|
|
346
|
+
self.tools_discovered = False
|
|
347
|
+
self.avatar_render_sent = avatar_render_sent
|
|
348
|
+
self.discover_ok = discover_ok
|
|
349
|
+
self.auto_error = auto_error
|
|
350
|
+
self.auto_exception = auto_exception
|
|
351
|
+
self.initialize_calls = 0
|
|
352
|
+
self.discover_calls = 0
|
|
353
|
+
self.call_tool_calls: list[tuple[str, dict]] = []
|
|
354
|
+
|
|
355
|
+
async def initialize(self, *, vision_url: str = "", vision_token: str = "") -> bool:
|
|
356
|
+
self.initialize_calls += 1
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
async def discover_tools(self) -> list[dict]:
|
|
360
|
+
self.discover_calls += 1
|
|
361
|
+
if not self.discover_ok:
|
|
362
|
+
self.tools = []
|
|
363
|
+
self.tools_discovered = False
|
|
364
|
+
return self.tools
|
|
365
|
+
|
|
366
|
+
self.tools = [
|
|
367
|
+
{
|
|
368
|
+
"name": "self.display.set_avatar",
|
|
369
|
+
"description": "Set avatar",
|
|
370
|
+
"inputSchema": {"type": "object"},
|
|
371
|
+
}
|
|
372
|
+
]
|
|
373
|
+
self.tools_discovered = True
|
|
374
|
+
return self.tools
|
|
375
|
+
|
|
376
|
+
async def call_tool(self, name: str, arguments: dict):
|
|
377
|
+
self.call_tool_calls.append((name, arguments))
|
|
378
|
+
if name == "self.display.set_avatar":
|
|
379
|
+
self.avatar_render_sent = True
|
|
380
|
+
if self.auto_exception is not None:
|
|
381
|
+
raise self.auto_exception
|
|
382
|
+
return {"content": [{"type": "text", "text": "true"}]}, self.auto_error
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class _AutoMcpWebSocket:
|
|
386
|
+
"""Fake WebSocket that responds to gateway MCP requests immediately."""
|
|
387
|
+
|
|
388
|
+
def __init__(self) -> None:
|
|
389
|
+
self.connection: ESP32Connection | None = None
|
|
390
|
+
self.sent: list[str] = []
|
|
391
|
+
self.tool_calls: list[tuple[str, dict]] = []
|
|
392
|
+
|
|
393
|
+
async def send(self, data: str) -> None:
|
|
394
|
+
self.sent.append(data)
|
|
395
|
+
message = json.loads(data)
|
|
396
|
+
payload = message["payload"]
|
|
397
|
+
method = payload["method"]
|
|
398
|
+
|
|
399
|
+
if method == "initialize":
|
|
400
|
+
result = {
|
|
401
|
+
"protocolVersion": "2024-11-05",
|
|
402
|
+
"capabilities": {"tools": {}},
|
|
403
|
+
"serverInfo": {"name": "test-device", "version": "1.0.0"},
|
|
404
|
+
}
|
|
405
|
+
elif method == "tools/list":
|
|
406
|
+
result = {
|
|
407
|
+
"tools": [
|
|
408
|
+
{
|
|
409
|
+
"name": "self.display.set_avatar",
|
|
410
|
+
"description": "Set avatar",
|
|
411
|
+
"inputSchema": {"type": "object"},
|
|
412
|
+
}
|
|
413
|
+
],
|
|
414
|
+
"nextCursor": "",
|
|
415
|
+
}
|
|
416
|
+
elif method == "tools/call":
|
|
417
|
+
params = payload["params"]
|
|
418
|
+
self.tool_calls.append((params["name"], params["arguments"]))
|
|
419
|
+
result = {"content": [{"type": "text", "text": "true"}], "isError": False}
|
|
420
|
+
else:
|
|
421
|
+
raise AssertionError(f"unexpected MCP method: {method}")
|
|
422
|
+
|
|
423
|
+
assert self.connection is not None
|
|
424
|
+
self.connection.handle_response(
|
|
425
|
+
{"jsonrpc": "2.0", "id": payload["id"], "result": result}
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@pytest.mark.asyncio
|
|
430
|
+
async def test_init_auto_renders_idle_avatar_after_tools_list():
|
|
431
|
+
"""A successful initialize + tools/list sends idle set_avatar once."""
|
|
432
|
+
ws = _AutoMcpWebSocket()
|
|
433
|
+
connection = ESP32Connection(ws, session_id="session-auto") # type: ignore[arg-type]
|
|
434
|
+
ws.connection = connection
|
|
435
|
+
mgr = ESP32Manager()
|
|
436
|
+
|
|
437
|
+
await mgr._init_device(connection, "device-test")
|
|
438
|
+
|
|
439
|
+
assert ws.tool_calls == [("self.display.set_avatar", {"face": "idle"})]
|
|
440
|
+
assert connection.avatar_render_sent is True
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@pytest.mark.asyncio
|
|
444
|
+
async def test_init_skips_auto_idle_avatar_when_avatar_already_sent():
|
|
445
|
+
"""The connection-scoped flag suppresses the automatic idle render."""
|
|
446
|
+
mgr = ESP32Manager()
|
|
447
|
+
connection = _InitDeviceConnection(avatar_render_sent=True)
|
|
448
|
+
|
|
449
|
+
await mgr._init_device(connection, "device-test") # type: ignore[arg-type]
|
|
450
|
+
|
|
451
|
+
assert connection.initialize_calls == 1
|
|
452
|
+
assert connection.discover_calls == 1
|
|
453
|
+
assert connection.call_tool_calls == []
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@pytest.mark.asyncio
|
|
457
|
+
async def test_init_skips_auto_idle_avatar_when_tools_discovery_fails(caplog):
|
|
458
|
+
"""The auto-render path only runs after successful tools/list discovery."""
|
|
459
|
+
caplog.set_level(logging.INFO, logger="stackchan_mcp.esp32_client")
|
|
460
|
+
mgr = ESP32Manager()
|
|
461
|
+
connection = _InitDeviceConnection(discover_ok=False)
|
|
462
|
+
|
|
463
|
+
await mgr._init_device(connection, "device-test") # type: ignore[arg-type]
|
|
464
|
+
|
|
465
|
+
assert connection.initialize_calls == 1
|
|
466
|
+
assert connection.discover_calls == 1
|
|
467
|
+
assert connection.call_tool_calls == []
|
|
468
|
+
assert "ESP32 ready: device=device-test" not in caplog.text
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@pytest.mark.parametrize("failure_mode", ["error", "timeout"])
|
|
472
|
+
@pytest.mark.asyncio
|
|
473
|
+
async def test_init_continues_when_auto_idle_avatar_fails(failure_mode, caplog):
|
|
474
|
+
"""Auto-render failures are warnings and do not block ESP32 ready."""
|
|
475
|
+
caplog.set_level(logging.INFO, logger="stackchan_mcp.esp32_client")
|
|
476
|
+
if failure_mode == "error":
|
|
477
|
+
connection = _InitDeviceConnection(
|
|
478
|
+
auto_error={"code": -32000, "message": "device rejected set_avatar"}
|
|
479
|
+
)
|
|
480
|
+
else:
|
|
481
|
+
connection = _InitDeviceConnection(
|
|
482
|
+
auto_exception=asyncio.TimeoutError("set_avatar timed out")
|
|
483
|
+
)
|
|
484
|
+
mgr = ESP32Manager()
|
|
485
|
+
|
|
486
|
+
await mgr._init_device(connection, "device-test") # type: ignore[arg-type]
|
|
487
|
+
|
|
488
|
+
assert connection.call_tool_calls == [
|
|
489
|
+
("self.display.set_avatar", {"face": "idle"})
|
|
490
|
+
]
|
|
491
|
+
assert "auto-rendering idle avatar failed" in caplog.text
|
|
492
|
+
assert "ESP32 ready: device=device-test tools=1" in caplog.text
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@pytest.mark.asyncio
|
|
496
|
+
async def test_reconnect_auto_renders_idle_avatar_again():
|
|
497
|
+
"""A new ESP32Connection gets a fresh auto-render flag."""
|
|
498
|
+
first_ws = _AutoMcpWebSocket()
|
|
499
|
+
first = ESP32Connection(first_ws, session_id="session-first") # type: ignore[arg-type]
|
|
500
|
+
first_ws.connection = first
|
|
501
|
+
second_ws = _AutoMcpWebSocket()
|
|
502
|
+
second = ESP32Connection(second_ws, session_id="session-second") # type: ignore[arg-type]
|
|
503
|
+
second_ws.connection = second
|
|
504
|
+
mgr = ESP32Manager()
|
|
505
|
+
|
|
506
|
+
await mgr._init_device(first, "device-test")
|
|
507
|
+
await mgr._init_device(second, "device-test")
|
|
508
|
+
|
|
509
|
+
assert first_ws.tool_calls == [("self.display.set_avatar", {"face": "idle"})]
|
|
510
|
+
assert second_ws.tool_calls == [("self.display.set_avatar", {"face": "idle"})]
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@pytest.mark.asyncio
|
|
514
|
+
async def test_send_avatar_set_fetch_resolves_when_loaded_event_arrives():
|
|
515
|
+
"""avatar_set_loaded resolves the matching load_avatar_set waiter."""
|
|
516
|
+
ws = _FakeWebSocket()
|
|
517
|
+
conn = ESP32Connection(ws, session_id="session-avatar") # type: ignore[arg-type]
|
|
518
|
+
|
|
519
|
+
task = asyncio.create_task(
|
|
520
|
+
conn.send_avatar_set_fetch(
|
|
521
|
+
url="https://example.invalid/avatar-set.bin",
|
|
522
|
+
token="test-token",
|
|
523
|
+
mode="replace",
|
|
524
|
+
checksum="sha256:avatar-set",
|
|
525
|
+
expected_size=1234,
|
|
526
|
+
timeout=30.0,
|
|
527
|
+
)
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
await asyncio.sleep(0)
|
|
531
|
+
assert len(ws.sent) == 1
|
|
532
|
+
assert json.loads(ws.sent[0]) == {
|
|
533
|
+
"type": "avatar_set_fetch",
|
|
534
|
+
"url": "https://example.invalid/avatar-set.bin",
|
|
535
|
+
"token": "test-token",
|
|
536
|
+
"mode": "replace",
|
|
537
|
+
"checksum": "sha256:avatar-set",
|
|
538
|
+
"expected_size": 1234,
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
payload = {
|
|
542
|
+
"ok": True,
|
|
543
|
+
"checksum": "sha256:avatar-set",
|
|
544
|
+
"bytes": 1234,
|
|
545
|
+
}
|
|
546
|
+
conn.handle_avatar_set_loaded(payload)
|
|
547
|
+
|
|
548
|
+
result = await asyncio.wait_for(task, timeout=1.0)
|
|
549
|
+
assert result == payload
|
|
550
|
+
assert conn._avatar_set_waiters == {}
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@pytest.mark.asyncio
|
|
554
|
+
async def test_send_avatar_set_fetch_returns_disconnected_when_connection_drops():
|
|
555
|
+
"""Disconnect wakes an in-flight avatar set fetch without waiting for timeout."""
|
|
556
|
+
ws = _FakeWebSocket()
|
|
557
|
+
conn = ESP32Connection(ws, session_id="session-avatar") # type: ignore[arg-type]
|
|
558
|
+
|
|
559
|
+
task = asyncio.create_task(
|
|
560
|
+
conn.send_avatar_set_fetch(
|
|
561
|
+
url="https://example.invalid/avatar-set.bin",
|
|
562
|
+
token="test-token",
|
|
563
|
+
mode="replace",
|
|
564
|
+
checksum="sha256:avatar-set",
|
|
565
|
+
expected_size=1234,
|
|
566
|
+
timeout=30.0,
|
|
567
|
+
)
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
await asyncio.sleep(0)
|
|
571
|
+
assert len(ws.sent) == 1
|
|
572
|
+
assert len(conn._avatar_set_waiters) == 1
|
|
573
|
+
|
|
574
|
+
started_at = asyncio.get_running_loop().time()
|
|
575
|
+
conn.disconnect()
|
|
576
|
+
result = await asyncio.wait_for(task, timeout=1.0)
|
|
577
|
+
elapsed = asyncio.get_running_loop().time() - started_at
|
|
578
|
+
|
|
579
|
+
assert result == {
|
|
580
|
+
"ok": False,
|
|
581
|
+
"checksum": "sha256:avatar-set",
|
|
582
|
+
"error": "disconnected",
|
|
583
|
+
}
|
|
584
|
+
assert elapsed < 1.0
|
|
585
|
+
assert conn._avatar_set_waiters == {}
|
|
586
|
+
|
|
587
|
+
|
|
321
588
|
class _GateableConnection:
|
|
322
589
|
"""Fake initialized connection with per-tool release gates."""
|
|
323
590
|
|
|
@@ -726,7 +993,7 @@ def test_connection_default_protocol_version_is_one():
|
|
|
726
993
|
# ---------------------------------------------------------------------------
|
|
727
994
|
|
|
728
995
|
|
|
729
|
-
async def _complete_handshake(ws, tools=None):
|
|
996
|
+
async def _complete_handshake(ws, tools=None, *, consume_auto_avatar=True):
|
|
730
997
|
"""Complete the full ESP32 handshake sequence."""
|
|
731
998
|
if tools is None:
|
|
732
999
|
tools = []
|
|
@@ -774,6 +1041,49 @@ async def _complete_handshake(ws, tools=None):
|
|
|
774
1041
|
},
|
|
775
1042
|
}
|
|
776
1043
|
await ws.send(json.dumps(tools_resp))
|
|
1044
|
+
if not consume_auto_avatar:
|
|
1045
|
+
return None
|
|
1046
|
+
|
|
1047
|
+
auto_msg = await _expect_auto_idle_avatar(ws)
|
|
1048
|
+
await _send_mcp_response(
|
|
1049
|
+
ws,
|
|
1050
|
+
auto_msg,
|
|
1051
|
+
result={"content": [{"type": "text", "text": "true"}], "isError": False},
|
|
1052
|
+
)
|
|
1053
|
+
return auto_msg
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
async def _expect_auto_idle_avatar(ws):
|
|
1057
|
+
"""Receive and assert the automatic idle avatar tools/call."""
|
|
1058
|
+
auto_raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
|
1059
|
+
auto_msg = json.loads(auto_raw)
|
|
1060
|
+
assert auto_msg["type"] == "mcp"
|
|
1061
|
+
assert auto_msg["payload"]["method"] == "tools/call"
|
|
1062
|
+
assert auto_msg["payload"]["params"]["name"] == "self.display.set_avatar"
|
|
1063
|
+
assert auto_msg["payload"]["params"]["arguments"] == {"face": "idle"}
|
|
1064
|
+
return auto_msg
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
async def _send_mcp_response(ws, req_msg, *, result=None, error=None):
|
|
1068
|
+
"""Send a JSON-RPC response for a gateway-originated MCP request."""
|
|
1069
|
+
payload = {
|
|
1070
|
+
"jsonrpc": "2.0",
|
|
1071
|
+
"id": req_msg["payload"]["id"],
|
|
1072
|
+
}
|
|
1073
|
+
if error is None:
|
|
1074
|
+
payload["result"] = result or {}
|
|
1075
|
+
else:
|
|
1076
|
+
payload["error"] = error
|
|
1077
|
+
|
|
1078
|
+
await ws.send(
|
|
1079
|
+
json.dumps(
|
|
1080
|
+
{
|
|
1081
|
+
"session_id": req_msg["session_id"],
|
|
1082
|
+
"type": "mcp",
|
|
1083
|
+
"payload": payload,
|
|
1084
|
+
}
|
|
1085
|
+
)
|
|
1086
|
+
)
|
|
777
1087
|
|
|
778
1088
|
|
|
779
1089
|
# --- Device-driven listen capture --------------------------------------------
|
|
@@ -932,4 +1242,3 @@ async def test_device_driven_listen_cleanup_on_disconnect(manager_with_hook):
|
|
|
932
1242
|
assert not is_recording(), "recording slot was leaked across connections"
|
|
933
1243
|
# No push should have fired for the aborted capture.
|
|
934
1244
|
assert calls == []
|
|
935
|
-
|
|
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
|
|
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
|