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.
Files changed (81) hide show
  1. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/PKG-INFO +3 -1
  2. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/pyproject.toml +3 -1
  3. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/capture_server.py +26 -14
  4. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/cli.py +45 -8
  5. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/esp32_client.py +61 -0
  6. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stdio_server.py +168 -14
  7. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/__init__.py +7 -0
  8. stackchan_mcp-0.14.0/stackchan_mcp/tts/edge_tts.py +158 -0
  9. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/orchestrator.py +2 -0
  10. stackchan_mcp-0.14.0/stackchan_mcp/user_defaults.py +254 -0
  11. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_capture_server.py +69 -0
  12. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_cli.py +72 -5
  13. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_esp32_client.py +311 -2
  14. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_stdio_server.py +368 -0
  15. stackchan_mcp-0.14.0/tests/test_user_defaults.py +99 -0
  16. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/uv.lock +14 -1
  17. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/.env.example +0 -0
  18. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/.gitignore +0 -0
  19. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/AGENTS.md +0 -0
  20. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/LICENSE +0 -0
  21. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/LICENSE-THIRD-PARTY +0 -0
  22. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/README.md +0 -0
  23. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/__init__.py +0 -0
  24. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/__main__.py +0 -0
  25. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
  26. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/audio_input_hook.py +0 -0
  27. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/audio_stream.py +0 -0
  28. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/event_log.py +0 -0
  29. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/follow_pose_stream.py +0 -0
  30. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/gateway.py +0 -0
  31. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/__init__.py +0 -0
  32. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/audio.py +0 -0
  33. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/camera.py +0 -0
  34. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/handlers/robot.py +0 -0
  35. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/http_server.py +0 -0
  36. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/mcp_router.py +0 -0
  37. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/mdns_advertiser.py +0 -0
  38. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/notify.example.yml +0 -0
  39. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/notify_config.py +0 -0
  40. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/ownership.py +0 -0
  41. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/protocol.py +0 -0
  42. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/queue.py +0 -0
  43. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/server.py +0 -0
  44. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/__init__.py +0 -0
  45. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/audio_utils.py +0 -0
  46. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/base.py +0 -0
  47. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
  48. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
  49. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/stt/orchestrator.py +0 -0
  50. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tools.py +0 -0
  51. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/audio_utils.py +0 -0
  52. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/base.py +0 -0
  53. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/emoji_expression.py +0 -0
  54. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/irodori.py +0 -0
  55. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/stackchan_mcp/tts/voicevox.py +0 -0
  56. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/_audio_fixtures.py +0 -0
  57. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/conftest.py +0 -0
  58. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_audio_input_hook.py +0 -0
  59. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_audio_stream.py +0 -0
  60. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_audio_utils.py +0 -0
  61. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_emoji_expression.py +0 -0
  62. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_event_dispatch.py +0 -0
  63. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_event_log.py +0 -0
  64. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_follow_pose_stream.py +0 -0
  65. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_gateway.py +0 -0
  66. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_http_server.py +0 -0
  67. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_irodori.py +0 -0
  68. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_mcp_router.py +0 -0
  69. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_mdns_advertiser.py +0 -0
  70. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_notify_config.py +0 -0
  71. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_orchestrator.py +0 -0
  72. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_ownership.py +0 -0
  73. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_protocol.py +0 -0
  74. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_send_pcm_audio.py +0 -0
  75. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_send_pcm_stream.py +0 -0
  76. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_stackchan_event.py +0 -0
  77. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_stt_audio_utils.py +0 -0
  78. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_stt_framework.py +0 -0
  79. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_stt_orchestrator.py +0 -0
  80. {stackchan_mcp-0.12.0 → stackchan_mcp-0.14.0}/tests/test_tts_framework.py +0 -0
  81. {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.12.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.12.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
- question = (await part.read()).decode("utf-8")
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
- # ``client_max_size=0`` disables aiohttp's per-request body size
449
- # cap (default 1 MiB). The /pcm endpoint legitimately streams
450
- # arbitrarily long PCM utterances (multi-minute TTS, live audio
451
- # mixes); a 1 MiB cap would silently cut a chunked-transfer
452
- # producer off mid-stream once its cumulative body exceeded that
453
- # limit observed in practice with a 200-second TTS push, which
454
- # aborted around 36 s in (~2 MiB of source-rate PCM through the
455
- # transfer-encoding pipe). The handler itself enforces no separate
456
- # cap; back-pressure comes from the device-side Opus push rate
457
- # inside ``send_pcm_stream``, which is the right place for it.
458
- # /capture only receives JPEG snapshots from the ESP32 (well under
459
- # 1 MiB each) so removing the cap costs it nothing.
460
- app = web.Application(client_max_size=0)
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
- info = read_lock()
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
- flip_yaw = arguments.get("flip_yaw", 1)
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 = arguments.get("flip_pitch", 1)
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 = arguments.get("pitch_center_deg", 45)
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
- downsample_hz = arguments.get("downsample_hz", 20.0)
407
- if not _is_number_arg(downsample_hz) or not 0 < downsample_hz <= 60:
408
- return _follow_pose_error("downsample_hz must be a number in (0, 60]")
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 = arguments.get("max_step_deg", 12.0)
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 = arguments.get("speed_dps", 240)
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": 60,
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__ = [