stackchan-mcp 0.12.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.12.0 → stackchan_mcp-0.14.0}/PKG-INFO +3 -1
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/pyproject.toml +3 -1
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/capture_server.py +26 -14
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/cli.py +45 -8
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/esp32_client.py +61 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stdio_server.py +168 -14
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/__init__.py +7 -0
- stackchan_mcp-0.14.0/stackchan_mcp/tts/edge_tts.py +158 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/orchestrator.py +2 -0
- stackchan_mcp-0.14.0/stackchan_mcp/user_defaults.py +254 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_capture_server.py +69 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_cli.py +72 -5
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_esp32_client.py +311 -2
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_stdio_server.py +368 -0
- stackchan_mcp-0.14.0/tests/test_user_defaults.py +99 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/uv.lock +14 -1
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/.env.example +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/.gitignore +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/AGENTS.md +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/LICENSE +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/LICENSE-THIRD-PARTY +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/README.md +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/__init__.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/__main__.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/audio_input_hook.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/audio_stream.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/event_log.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/follow_pose_stream.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/gateway.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/__init__.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/audio.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/camera.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/robot.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/http_server.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/mcp_router.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/mdns_advertiser.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/notify.example.yml +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/notify_config.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/ownership.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/protocol.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/queue.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/server.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/__init__.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/audio_utils.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/base.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/orchestrator.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tools.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/audio_utils.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/base.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/emoji_expression.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/irodori.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/voicevox.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/_audio_fixtures.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/conftest.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_audio_input_hook.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_audio_stream.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_audio_utils.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_emoji_expression.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_event_dispatch.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_event_log.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_follow_pose_stream.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_gateway.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_http_server.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_irodori.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_mcp_router.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_mdns_advertiser.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_notify_config.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_orchestrator.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_ownership.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_protocol.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_send_pcm_audio.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_send_pcm_stream.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_stackchan_event.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_stt_audio_utils.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_stt_framework.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_stt_orchestrator.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_tts_framework.py +0 -0
- {stackchan_mcp-0.12.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
|
|
@@ -25,9 +25,11 @@ Classifier: Topic :: System :: Hardware
|
|
|
25
25
|
Requires-Python: >=3.10
|
|
26
26
|
Requires-Dist: aiohttp>=3
|
|
27
27
|
Requires-Dist: mcp<2.0,>=1.27
|
|
28
|
+
Requires-Dist: platformdirs<5.0,>=4.0
|
|
28
29
|
Requires-Dist: pydantic>=2
|
|
29
30
|
Requires-Dist: python-dotenv
|
|
30
31
|
Requires-Dist: pyyaml>=6
|
|
32
|
+
Requires-Dist: tomli<3.0,>=2.0; python_version < '3.11'
|
|
31
33
|
Requires-Dist: websockets>=12
|
|
32
34
|
Requires-Dist: zeroconf>=0.149
|
|
33
35
|
Provides-Extra: stt
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "stackchan-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.14.0"
|
|
4
4
|
description = "Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -34,6 +34,8 @@ dependencies = [
|
|
|
34
34
|
"pydantic>=2",
|
|
35
35
|
"python-dotenv",
|
|
36
36
|
"PyYAML>=6",
|
|
37
|
+
"platformdirs>=4.0,<5.0",
|
|
38
|
+
"tomli>=2.0,<3.0; python_version < '3.11'",
|
|
37
39
|
# The stdio server captures the active ServerSession via a subclassed
|
|
38
40
|
# Server.run() loop because the public MCP SDK does not yet expose a
|
|
39
41
|
# stable hook for server-side notifications. That subclass relies on
|
|
@@ -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"]):
|
|
@@ -745,6 +756,28 @@ def _configure_gateway_startup() -> None:
|
|
|
745
756
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
746
757
|
)
|
|
747
758
|
|
|
759
|
+
from .user_defaults import log_user_defaults_startup
|
|
760
|
+
|
|
761
|
+
log_user_defaults_startup()
|
|
762
|
+
|
|
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
|
+
|
|
748
781
|
|
|
749
782
|
def _acquire_startup_lock(
|
|
750
783
|
*,
|
|
@@ -760,13 +793,16 @@ def _acquire_startup_lock(
|
|
|
760
793
|
release_lock_if_owner,
|
|
761
794
|
)
|
|
762
795
|
|
|
796
|
+
lock_path = _ws_port_lock_path()
|
|
797
|
+
|
|
763
798
|
owner_id = generate_owner_id()
|
|
764
799
|
try:
|
|
765
800
|
if mode == _STDIO_TRANSPORT and http_endpoint is None and started_by is None:
|
|
766
|
-
info = acquire_lock(owner_id)
|
|
801
|
+
info = acquire_lock(owner_id, path=lock_path)
|
|
767
802
|
else:
|
|
768
803
|
info = acquire_lock(
|
|
769
804
|
owner_id,
|
|
805
|
+
path=lock_path,
|
|
770
806
|
mode=mode,
|
|
771
807
|
http_endpoint=http_endpoint,
|
|
772
808
|
started_by=started_by,
|
|
@@ -778,12 +814,13 @@ def _acquire_startup_lock(
|
|
|
778
814
|
try:
|
|
779
815
|
print(
|
|
780
816
|
"stackchan-mcp: acquired ownership lock "
|
|
781
|
-
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})",
|
|
782
819
|
file=sys.stderr,
|
|
783
820
|
)
|
|
784
|
-
atexit.register(release_lock_if_owner, info)
|
|
821
|
+
atexit.register(release_lock_if_owner, info, lock_path)
|
|
785
822
|
except BaseException:
|
|
786
|
-
release_lock_if_owner(info)
|
|
823
|
+
release_lock_if_owner(info, lock_path)
|
|
787
824
|
raise
|
|
788
825
|
|
|
789
826
|
return info
|
|
@@ -806,7 +843,7 @@ def _run_stdio_gateway(*, advertise_mdns: bool = True) -> None:
|
|
|
806
843
|
except KeyboardInterrupt:
|
|
807
844
|
pass
|
|
808
845
|
finally:
|
|
809
|
-
release_lock_if_owner(info)
|
|
846
|
+
release_lock_if_owner(info, _ws_port_lock_path())
|
|
810
847
|
|
|
811
848
|
|
|
812
849
|
def _resolve_mcp_http_endpoint() -> tuple[str, int]:
|
|
@@ -916,7 +953,7 @@ def _run_streamable_http_placeholder(*, advertise_mdns: bool = True) -> None:
|
|
|
916
953
|
pass
|
|
917
954
|
finally:
|
|
918
955
|
if info is not None:
|
|
919
|
-
release_lock_if_owner(info)
|
|
956
|
+
release_lock_if_owner(info, _ws_port_lock_path())
|
|
920
957
|
|
|
921
958
|
|
|
922
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")
|
|
@@ -21,6 +21,7 @@ from mcp.types import Notification, TextContent, Tool
|
|
|
21
21
|
from . import __version__
|
|
22
22
|
from .gateway import get_gateway
|
|
23
23
|
from .notify_config import NotifyConfig, load_notify_config
|
|
24
|
+
from .user_defaults import resolve_default
|
|
24
25
|
from .stt import listen_and_transcribe
|
|
25
26
|
from .tts import synthesize_and_send
|
|
26
27
|
|
|
@@ -388,30 +389,64 @@ async def _handle_follow_pose_stream(
|
|
|
388
389
|
if not (url.startswith("ws://") or url.startswith("wss://")):
|
|
389
390
|
return _follow_pose_error("url must start with ws:// or wss://")
|
|
390
391
|
|
|
391
|
-
|
|
392
|
+
tool_name = "stackchan_follow_pose_stream"
|
|
393
|
+
|
|
394
|
+
flip_yaw = (
|
|
395
|
+
arguments["flip_yaw"]
|
|
396
|
+
if "flip_yaw" in arguments
|
|
397
|
+
else resolve_default(tool_name, "flip_yaw", 1)
|
|
398
|
+
)
|
|
392
399
|
if not _is_int_arg(flip_yaw) or flip_yaw not in (-1, 1):
|
|
393
400
|
return _follow_pose_error("flip_yaw must be -1 or 1")
|
|
394
401
|
|
|
395
|
-
flip_pitch =
|
|
402
|
+
flip_pitch = (
|
|
403
|
+
arguments["flip_pitch"]
|
|
404
|
+
if "flip_pitch" in arguments
|
|
405
|
+
else resolve_default(tool_name, "flip_pitch", 1)
|
|
406
|
+
)
|
|
396
407
|
if not _is_int_arg(flip_pitch) or flip_pitch not in (-1, 1):
|
|
397
408
|
return _follow_pose_error("flip_pitch must be -1 or 1")
|
|
398
409
|
|
|
399
|
-
pitch_center_deg =
|
|
410
|
+
pitch_center_deg = (
|
|
411
|
+
arguments["pitch_center_deg"]
|
|
412
|
+
if "pitch_center_deg" in arguments
|
|
413
|
+
else resolve_default(tool_name, "pitch_center_deg", 45)
|
|
414
|
+
)
|
|
400
415
|
if (
|
|
401
416
|
not _is_int_arg(pitch_center_deg)
|
|
402
417
|
or not 5 <= pitch_center_deg <= 85
|
|
403
418
|
):
|
|
404
419
|
return _follow_pose_error("pitch_center_deg must be an integer in 5..85")
|
|
405
420
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
421
|
+
smoothing_window = (
|
|
422
|
+
arguments["smoothing_window"]
|
|
423
|
+
if "smoothing_window" in arguments
|
|
424
|
+
else resolve_default(tool_name, "smoothing_window", 5)
|
|
425
|
+
)
|
|
426
|
+
if not _is_int_arg(smoothing_window) or not 1 <= smoothing_window <= 20:
|
|
427
|
+
return _follow_pose_error("smoothing_window must be an integer in 1..20")
|
|
428
|
+
|
|
429
|
+
downsample_hz = (
|
|
430
|
+
arguments["downsample_hz"]
|
|
431
|
+
if "downsample_hz" in arguments
|
|
432
|
+
else resolve_default(tool_name, "downsample_hz", 20.0)
|
|
433
|
+
)
|
|
434
|
+
if not _is_number_arg(downsample_hz) or not 0 < downsample_hz <= 20:
|
|
435
|
+
return _follow_pose_error("downsample_hz must be a number in (0, 20]")
|
|
409
436
|
|
|
410
|
-
max_step_deg =
|
|
437
|
+
max_step_deg = (
|
|
438
|
+
arguments["max_step_deg"]
|
|
439
|
+
if "max_step_deg" in arguments
|
|
440
|
+
else resolve_default(tool_name, "max_step_deg", 12.0)
|
|
441
|
+
)
|
|
411
442
|
if not _is_number_arg(max_step_deg) or not 0 < max_step_deg <= 30:
|
|
412
443
|
return _follow_pose_error("max_step_deg must be a number in (0, 30]")
|
|
413
444
|
|
|
414
|
-
speed_dps =
|
|
445
|
+
speed_dps = (
|
|
446
|
+
arguments["speed_dps"]
|
|
447
|
+
if "speed_dps" in arguments
|
|
448
|
+
else resolve_default(tool_name, "speed_dps", 240)
|
|
449
|
+
)
|
|
415
450
|
if not _is_int_arg(speed_dps) or not 1 <= speed_dps <= 240:
|
|
416
451
|
return _follow_pose_error("speed_dps must be an integer in 1..240")
|
|
417
452
|
|
|
@@ -429,6 +464,7 @@ async def _handle_follow_pose_stream(
|
|
|
429
464
|
flip_yaw=flip_yaw,
|
|
430
465
|
flip_pitch=flip_pitch,
|
|
431
466
|
pitch_center_deg=pitch_center_deg,
|
|
467
|
+
smoothing_window=smoothing_window,
|
|
432
468
|
downsample_hz=float(downsample_hz),
|
|
433
469
|
max_step_deg=float(max_step_deg),
|
|
434
470
|
speed_dps=speed_dps,
|
|
@@ -610,6 +646,14 @@ async def _dispatch_mcp_tool(
|
|
|
610
646
|
"self.gateway_config.set",
|
|
611
647
|
arguments,
|
|
612
648
|
),
|
|
649
|
+
"get_touch_sensor_enabled": (
|
|
650
|
+
"self.robot.get_touch_sensor_enabled",
|
|
651
|
+
{},
|
|
652
|
+
),
|
|
653
|
+
"set_touch_sensor_enabled": (
|
|
654
|
+
"self.robot.set_touch_sensor_enabled",
|
|
655
|
+
arguments,
|
|
656
|
+
),
|
|
613
657
|
"set_avatar": (
|
|
614
658
|
"self.display.set_avatar",
|
|
615
659
|
arguments,
|
|
@@ -927,15 +971,29 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
|
|
|
927
971
|
"anchor. Defaults to the head's neutral pose."
|
|
928
972
|
),
|
|
929
973
|
},
|
|
974
|
+
"smoothing_window": {
|
|
975
|
+
"type": "integer",
|
|
976
|
+
"default": 5,
|
|
977
|
+
"minimum": 1,
|
|
978
|
+
"maximum": 20,
|
|
979
|
+
"description": (
|
|
980
|
+
"Moving-average window size for incoming sensor "
|
|
981
|
+
"frames. 1 = passthrough (disable gateway-side "
|
|
982
|
+
"smoothing). Default 5."
|
|
983
|
+
),
|
|
984
|
+
},
|
|
930
985
|
"downsample_hz": {
|
|
931
986
|
"type": "number",
|
|
932
987
|
"default": 20,
|
|
933
988
|
"exclusiveMinimum": 0,
|
|
934
|
-
"maximum":
|
|
989
|
+
"maximum": 20,
|
|
935
990
|
"description": (
|
|
936
991
|
"Cap servo command rate. Recent frames are "
|
|
937
992
|
"smoothed; commands are issued at most this "
|
|
938
|
-
"frequently."
|
|
993
|
+
"frequently. Capped at 20 to match the "
|
|
994
|
+
"SCS0009 servo's observed sustained WritePos "
|
|
995
|
+
"rate; higher continuous rates can trigger "
|
|
996
|
+
"UART hang."
|
|
939
997
|
),
|
|
940
998
|
},
|
|
941
999
|
"max_step_deg": {
|
|
@@ -1062,6 +1120,38 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
|
|
|
1062
1120
|
},
|
|
1063
1121
|
},
|
|
1064
1122
|
),
|
|
1123
|
+
Tool(
|
|
1124
|
+
name="get_touch_sensor_enabled",
|
|
1125
|
+
description=(
|
|
1126
|
+
"Read the device's NVS-backed head-touch sensor enable "
|
|
1127
|
+
"flag. When disabled, the firmware stops both the local "
|
|
1128
|
+
"motion response and the MCP stackchan/event emission; "
|
|
1129
|
+
"the setting persists across reboot."
|
|
1130
|
+
),
|
|
1131
|
+
inputSchema={"type": "object", "properties": {}},
|
|
1132
|
+
),
|
|
1133
|
+
Tool(
|
|
1134
|
+
name="set_touch_sensor_enabled",
|
|
1135
|
+
description=(
|
|
1136
|
+
"Enable or disable the device's head-touch sensor at "
|
|
1137
|
+
"runtime. The NVS-backed setting persists across reboot. "
|
|
1138
|
+
"Disabling stops both the firmware local motion response "
|
|
1139
|
+
"and the MCP stackchan/event emission."
|
|
1140
|
+
),
|
|
1141
|
+
inputSchema={
|
|
1142
|
+
"type": "object",
|
|
1143
|
+
"properties": {
|
|
1144
|
+
"enabled": {
|
|
1145
|
+
"type": "boolean",
|
|
1146
|
+
"description": (
|
|
1147
|
+
"True to enable tap/stroke detection; false "
|
|
1148
|
+
"to disable local reactions and event emission."
|
|
1149
|
+
),
|
|
1150
|
+
},
|
|
1151
|
+
},
|
|
1152
|
+
"required": ["enabled"],
|
|
1153
|
+
},
|
|
1154
|
+
),
|
|
1065
1155
|
Tool(
|
|
1066
1156
|
name="set_avatar",
|
|
1067
1157
|
description=(
|
|
@@ -1516,10 +1606,22 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
|
|
|
1516
1606
|
"speaker_id": {
|
|
1517
1607
|
"type": "integer",
|
|
1518
1608
|
"description": (
|
|
1519
|
-
"Engine-specific speaker identifier "
|
|
1609
|
+
"Engine-specific numeric speaker identifier "
|
|
1520
1610
|
"(e.g. a VOICEVOX speaker ID)."
|
|
1521
1611
|
),
|
|
1522
1612
|
},
|
|
1613
|
+
"speaker_name": {
|
|
1614
|
+
"type": "string",
|
|
1615
|
+
"description": (
|
|
1616
|
+
"Engine-specific string speaker/voice "
|
|
1617
|
+
"identifier (e.g. an Edge TTS voice name "
|
|
1618
|
+
"such as 'en-US-AriaNeural'). Distinct from "
|
|
1619
|
+
"'voice', which selects the engine itself "
|
|
1620
|
+
"(e.g. 'edge-tts'); use speaker_name for "
|
|
1621
|
+
"engines whose speaker selector is a string "
|
|
1622
|
+
"name rather than the numeric speaker_id."
|
|
1623
|
+
),
|
|
1624
|
+
},
|
|
1523
1625
|
"reference_audio": {
|
|
1524
1626
|
"type": "string",
|
|
1525
1627
|
"description": (
|
|
@@ -1644,7 +1746,12 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
|
|
|
1644
1746
|
"address, then read' patterns, use `i2c_write_read` "
|
|
1645
1747
|
"instead. Returns "
|
|
1646
1748
|
"{\"ok\": true, \"bytes\": [...]} or "
|
|
1647
|
-
"{\"ok\": false, \"error\": \"ESP_ERR_TIMEOUT\"} on NACK."
|
|
1749
|
+
"{\"ok\": false, \"error\": \"ESP_ERR_TIMEOUT\"} on NACK. "
|
|
1750
|
+
"Optional `scl_speed_hz` (default 400000) sets the I2C "
|
|
1751
|
+
"clock for this transaction; lower it (e.g. 100000 or "
|
|
1752
|
+
"200000) for slower Units such as the RCWL-9620 "
|
|
1753
|
+
"ultrasonic ranger that fail at 400 kHz with "
|
|
1754
|
+
"ESP_ERR_INVALID_STATE."
|
|
1648
1755
|
),
|
|
1649
1756
|
inputSchema={
|
|
1650
1757
|
"type": "object",
|
|
@@ -1665,6 +1772,19 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
|
|
|
1665
1772
|
"minimum": 1,
|
|
1666
1773
|
"maximum": 256,
|
|
1667
1774
|
},
|
|
1775
|
+
"scl_speed_hz": {
|
|
1776
|
+
"type": "integer",
|
|
1777
|
+
"default": 400000,
|
|
1778
|
+
"description": (
|
|
1779
|
+
"I2C clock for this transaction. Default "
|
|
1780
|
+
"400000; lower it (e.g. 100000 or 200000) "
|
|
1781
|
+
"for slower Units such as the RCWL-9620 "
|
|
1782
|
+
"ultrasonic ranger that fail at 400 kHz "
|
|
1783
|
+
"with ESP_ERR_INVALID_STATE."
|
|
1784
|
+
),
|
|
1785
|
+
"minimum": 100000,
|
|
1786
|
+
"maximum": 1000000,
|
|
1787
|
+
},
|
|
1668
1788
|
},
|
|
1669
1789
|
"required": ["addr", "n_bytes"],
|
|
1670
1790
|
},
|
|
@@ -1676,7 +1796,11 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
|
|
|
1676
1796
|
"on Grove Port A. `bytes` is an array of integers "
|
|
1677
1797
|
"(0..255). This tool operates on the external Port A "
|
|
1678
1798
|
"bus only; on-board ICs (PMIC, AW9523, touch, etc.) "
|
|
1679
|
-
"on the internal bus are not reachable."
|
|
1799
|
+
"on the internal bus are not reachable. Optional "
|
|
1800
|
+
"`scl_speed_hz` (default 400000) sets the I2C clock for "
|
|
1801
|
+
"this transaction; lower it (e.g. 100000 or 200000) for "
|
|
1802
|
+
"slower Units such as the RCWL-9620 ultrasonic ranger "
|
|
1803
|
+
"that fail at 400 kHz with ESP_ERR_INVALID_STATE."
|
|
1680
1804
|
),
|
|
1681
1805
|
inputSchema={
|
|
1682
1806
|
"type": "object",
|
|
@@ -1700,6 +1824,19 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
|
|
|
1700
1824
|
"maximum": 255,
|
|
1701
1825
|
},
|
|
1702
1826
|
},
|
|
1827
|
+
"scl_speed_hz": {
|
|
1828
|
+
"type": "integer",
|
|
1829
|
+
"default": 400000,
|
|
1830
|
+
"description": (
|
|
1831
|
+
"I2C clock for this transaction. Default "
|
|
1832
|
+
"400000; lower it (e.g. 100000 or 200000) "
|
|
1833
|
+
"for slower Units such as the RCWL-9620 "
|
|
1834
|
+
"ultrasonic ranger that fail at 400 kHz "
|
|
1835
|
+
"with ESP_ERR_INVALID_STATE."
|
|
1836
|
+
),
|
|
1837
|
+
"minimum": 100000,
|
|
1838
|
+
"maximum": 1000000,
|
|
1839
|
+
},
|
|
1703
1840
|
},
|
|
1704
1841
|
"required": ["addr", "bytes"],
|
|
1705
1842
|
},
|
|
@@ -1712,7 +1849,11 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
|
|
|
1712
1849
|
"single Repeated Start transaction. Common 'set "
|
|
1713
1850
|
"register pointer, then read' idiom: pass "
|
|
1714
1851
|
"write_bytes=[reg_addr] to read from a specific "
|
|
1715
|
-
"register."
|
|
1852
|
+
"register. Optional `scl_speed_hz` (default 400000) "
|
|
1853
|
+
"sets the I2C clock for this transaction; lower it "
|
|
1854
|
+
"(e.g. 100000 or 200000) for slower Units such as the "
|
|
1855
|
+
"RCWL-9620 ultrasonic ranger that fail at 400 kHz with "
|
|
1856
|
+
"ESP_ERR_INVALID_STATE."
|
|
1716
1857
|
),
|
|
1717
1858
|
inputSchema={
|
|
1718
1859
|
"type": "object",
|
|
@@ -1745,6 +1886,19 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
|
|
|
1745
1886
|
"minimum": 1,
|
|
1746
1887
|
"maximum": 256,
|
|
1747
1888
|
},
|
|
1889
|
+
"scl_speed_hz": {
|
|
1890
|
+
"type": "integer",
|
|
1891
|
+
"default": 400000,
|
|
1892
|
+
"description": (
|
|
1893
|
+
"I2C clock for this transaction. Default "
|
|
1894
|
+
"400000; lower it (e.g. 100000 or 200000) "
|
|
1895
|
+
"for slower Units such as the RCWL-9620 "
|
|
1896
|
+
"ultrasonic ranger that fail at 400 kHz "
|
|
1897
|
+
"with ESP_ERR_INVALID_STATE."
|
|
1898
|
+
),
|
|
1899
|
+
"minimum": 100000,
|
|
1900
|
+
"maximum": 1000000,
|
|
1901
|
+
},
|
|
1748
1902
|
},
|
|
1749
1903
|
"required": ["addr", "write_bytes", "n_bytes"],
|
|
1750
1904
|
},
|
|
@@ -54,8 +54,15 @@ def _register_irodori() -> None:
|
|
|
54
54
|
get_registry().register(IrodoriEngine())
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def _register_edge_tts() -> None:
|
|
58
|
+
from .edge_tts import EdgeTTSEngine
|
|
59
|
+
|
|
60
|
+
get_registry().register(EdgeTTSEngine())
|
|
61
|
+
|
|
62
|
+
|
|
57
63
|
_try_register(_register_voicevox, "voicevox")
|
|
58
64
|
_try_register(_register_irodori, "irodori")
|
|
65
|
+
_try_register(_register_edge_tts, "edge-tts")
|
|
59
66
|
|
|
60
67
|
|
|
61
68
|
__all__ = [
|