stackchan-mcp 0.10.0__tar.gz → 0.11.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 (77) hide show
  1. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/PKG-INFO +5 -1
  2. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/pyproject.toml +13 -2
  3. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/ownership.py +142 -15
  4. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stdio_server.py +246 -9
  5. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/__init__.py +7 -0
  6. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/base.py +5 -0
  7. stackchan_mcp-0.11.0/stackchan_mcp/tts/emoji_expression.py +80 -0
  8. stackchan_mcp-0.11.0/stackchan_mcp/tts/irodori.py +358 -0
  9. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/orchestrator.py +187 -13
  10. stackchan_mcp-0.11.0/tests/test_emoji_expression.py +44 -0
  11. stackchan_mcp-0.11.0/tests/test_irodori.py +438 -0
  12. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_orchestrator.py +303 -21
  13. stackchan_mcp-0.11.0/tests/test_ownership.py +434 -0
  14. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_stdio_server.py +293 -0
  15. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_tts_framework.py +77 -0
  16. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/uv.lock +50 -2
  17. stackchan_mcp-0.10.0/tests/test_ownership.py +0 -92
  18. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/.env.example +0 -0
  19. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/.gitignore +0 -0
  20. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/AGENTS.md +0 -0
  21. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/LICENSE +0 -0
  22. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/LICENSE-THIRD-PARTY +0 -0
  23. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/README.md +0 -0
  24. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/__init__.py +0 -0
  25. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/__main__.py +0 -0
  26. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
  27. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/audio_input_hook.py +0 -0
  28. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/audio_stream.py +0 -0
  29. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/capture_server.py +0 -0
  30. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/cli.py +0 -0
  31. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/esp32_client.py +0 -0
  32. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/event_log.py +0 -0
  33. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/gateway.py +0 -0
  34. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/__init__.py +0 -0
  35. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/audio.py +0 -0
  36. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/camera.py +0 -0
  37. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/robot.py +0 -0
  38. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/http_server.py +0 -0
  39. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/mcp_router.py +0 -0
  40. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/mdns_advertiser.py +0 -0
  41. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/notify.example.yml +0 -0
  42. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/notify_config.py +0 -0
  43. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/protocol.py +0 -0
  44. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/queue.py +0 -0
  45. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/server.py +0 -0
  46. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/__init__.py +0 -0
  47. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/audio_utils.py +0 -0
  48. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/base.py +0 -0
  49. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
  50. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
  51. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/orchestrator.py +0 -0
  52. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/tools.py +0 -0
  53. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/audio_utils.py +0 -0
  54. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/voicevox.py +0 -0
  55. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/_audio_fixtures.py +0 -0
  56. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/conftest.py +0 -0
  57. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_audio_input_hook.py +0 -0
  58. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_audio_stream.py +0 -0
  59. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_audio_utils.py +0 -0
  60. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_capture_server.py +0 -0
  61. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_cli.py +0 -0
  62. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_esp32_client.py +0 -0
  63. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_event_dispatch.py +0 -0
  64. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_event_log.py +0 -0
  65. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_gateway.py +0 -0
  66. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_http_server.py +0 -0
  67. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_mcp_router.py +0 -0
  68. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_mdns_advertiser.py +0 -0
  69. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_notify_config.py +0 -0
  70. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_protocol.py +0 -0
  71. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_send_pcm_audio.py +0 -0
  72. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_send_pcm_stream.py +0 -0
  73. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_stackchan_event.py +0 -0
  74. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_stt_audio_utils.py +0 -0
  75. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_stt_framework.py +0 -0
  76. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_stt_orchestrator.py +0 -0
  77. {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.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.10.0
3
+ Version: 0.11.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
@@ -41,6 +41,10 @@ Requires-Dist: opuslib>=3; extra == 'stt-openai'
41
41
  Provides-Extra: tts
42
42
  Requires-Dist: httpx>=0.27; extra == 'tts'
43
43
  Requires-Dist: opuslib>=3; extra == 'tts'
44
+ Provides-Extra: tts-irodori
45
+ Requires-Dist: httpx>=0.27; extra == 'tts-irodori'
46
+ Requires-Dist: miniaudio>=1.59; extra == 'tts-irodori'
47
+ Requires-Dist: opuslib>=3; extra == 'tts-irodori'
44
48
  Provides-Extra: tts-voicevox
45
49
  Requires-Dist: httpx>=0.27; extra == 'tts-voicevox'
46
50
  Requires-Dist: opuslib>=3; extra == 'tts-voicevox'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackchan-mcp"
3
- version = "0.10.0"
3
+ version = "0.11.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"
@@ -51,7 +51,7 @@ dependencies = [
51
51
  [project.optional-dependencies]
52
52
  # Phase 4 TTS — see Issue #70.
53
53
  # Concrete engines (VOICEVOX, Irodori) consume these libraries:
54
- # * httpx — VOICEVOX HTTP engine client
54
+ # * httpx — VOICEVOX / Irodori HTTP engine client
55
55
  # * opuslib — Opus encoding for the device's audio decoder
56
56
  # `tts-voicevox` is a no-op alias provided so users can declare intent
57
57
  # explicitly; the VOICEVOX engine itself is an external HTTP process and
@@ -63,6 +63,17 @@ tts = [
63
63
  tts-voicevox = [
64
64
  "stackchan-mcp[tts]",
65
65
  ]
66
+ # Irodori is an external HTTP synthesis service that returns MP3 (see
67
+ # Issue #286). On top of the base `[tts]` extra (httpx + opuslib) it adds
68
+ # `miniaudio` to decode the MP3 response to PCM. miniaudio is a small,
69
+ # self-contained C extension (MIT) with prebuilt wheels for Windows,
70
+ # macOS (x86_64 + arm64), and Linux, so it installs without a system
71
+ # audio library — unlike a ffmpeg/libav binding — which keeps the
72
+ # Windows wheel path (and the CI windows-smoke job) simple.
73
+ tts-irodori = [
74
+ "stackchan-mcp[tts]",
75
+ "miniaudio>=1.59",
76
+ ]
66
77
 
67
78
  # Phase 4 STT — see Issue #91.
68
79
  # The base `stt` extra carries `opuslib` for decoding the device's
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import math
6
7
  import os
7
8
  import socket
9
+ import subprocess
8
10
  import sys
9
11
  import uuid
10
12
  from datetime import datetime, timezone
@@ -13,6 +15,8 @@ from typing import Literal, TypedDict
13
15
 
14
16
  LOCK_DIR = Path.home() / ".stackchan-mcp"
15
17
  LOCK_PATH = LOCK_DIR / "owner.lock"
18
+ PROC_START_TOLERANCE_SECONDS = 2.0
19
+ _PROC_ROOT = Path("/proc")
16
20
 
17
21
 
18
22
  LockMode = Literal["stdio", "streamable-http"]
@@ -29,6 +33,7 @@ class LockInfo(_BaseLockInfo, total=False):
29
33
  mode: LockMode
30
34
  http_endpoint: str | None
31
35
  started_by: str | None
36
+ proc_start_epoch: float
32
37
 
33
38
 
34
39
  class OwnershipError(RuntimeError):
@@ -65,6 +70,101 @@ def is_pid_alive(pid: int) -> bool:
65
70
  return True
66
71
 
67
72
 
73
+ def get_process_start_time(pid: int) -> float | None:
74
+ """Return the kernel-reported process start time as epoch seconds.
75
+
76
+ Supported on Linux (``/proc``) and macOS (``ps lstart``). On other
77
+ platforms — notably Windows — this returns ``None``, so the identity
78
+ check is skipped and stale-lock detection falls back to the PID-only
79
+ liveness check (the pre-#253 behavior). Windows process-identity
80
+ verification is deferred per Issue #253's acceptance criteria.
81
+ """
82
+ if pid <= 0:
83
+ return None
84
+ if sys.platform.startswith("linux"):
85
+ return _get_process_start_time_linux(pid)
86
+ if sys.platform == "darwin":
87
+ return _get_process_start_time_macos(pid)
88
+ return None
89
+
90
+
91
+ def _get_process_start_time_linux(pid: int) -> float | None:
92
+ try:
93
+ stat_text = (_PROC_ROOT / str(pid) / "stat").read_text(encoding="utf-8")
94
+ proc_stat_text = (_PROC_ROOT / "stat").read_text(encoding="utf-8")
95
+ ticks_per_second = os.sysconf("SC_CLK_TCK")
96
+ except (OSError, ValueError):
97
+ return None
98
+
99
+ if not isinstance(ticks_per_second, int) or ticks_per_second <= 0:
100
+ return None
101
+
102
+ try:
103
+ after_comm = stat_text.rsplit(")", 1)[1].strip()
104
+ fields = after_comm.split()
105
+ start_ticks = int(fields[19])
106
+ except (IndexError, ValueError):
107
+ return None
108
+
109
+ boot_time: int | None = None
110
+ for line in proc_stat_text.splitlines():
111
+ parts = line.split()
112
+ if len(parts) == 2 and parts[0] == "btime":
113
+ try:
114
+ boot_time = int(parts[1])
115
+ except ValueError:
116
+ return None
117
+ break
118
+ if boot_time is None:
119
+ return None
120
+
121
+ return float(boot_time) + (float(start_ticks) / float(ticks_per_second))
122
+
123
+
124
+ def _get_process_start_time_macos(pid: int) -> float | None:
125
+ env = os.environ.copy()
126
+ env["LC_ALL"] = "C"
127
+ try:
128
+ result = subprocess.run(
129
+ ["ps", "-o", "lstart=", "-p", str(pid)],
130
+ capture_output=True,
131
+ check=False,
132
+ env=env,
133
+ text=True,
134
+ )
135
+ except OSError:
136
+ return None
137
+
138
+ output = result.stdout.strip()
139
+ if not output:
140
+ return None
141
+
142
+ line = output.splitlines()[0].strip()
143
+ try:
144
+ started = datetime.strptime(line, "%a %b %d %H:%M:%S %Y")
145
+ except ValueError:
146
+ return None
147
+ return started.timestamp()
148
+
149
+
150
+ def _coerce_proc_start_epoch(value: object) -> float | None:
151
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
152
+ return None
153
+ proc_start_epoch = float(value)
154
+ if not math.isfinite(proc_start_epoch):
155
+ return None
156
+ return proc_start_epoch
157
+
158
+
159
+ def _process_identity_matches(pid: int, expected_start: float | None) -> bool:
160
+ if expected_start is None:
161
+ return True
162
+ actual_start = get_process_start_time(pid)
163
+ if actual_start is None:
164
+ return True
165
+ return abs(actual_start - expected_start) <= PROC_START_TOLERANCE_SECONDS
166
+
167
+
68
168
  def _is_pid_alive_windows(pid: int) -> bool:
69
169
  import ctypes
70
170
  import ctypes.wintypes
@@ -152,6 +252,11 @@ def read_lock(path: Path = LOCK_PATH) -> LockInfo | None:
152
252
  if started_by is None or isinstance(started_by, str):
153
253
  info["started_by"] = started_by
154
254
 
255
+ if "proc_start_epoch" in raw:
256
+ proc_start_epoch = _coerce_proc_start_epoch(raw["proc_start_epoch"])
257
+ if proc_start_epoch is not None:
258
+ info["proc_start_epoch"] = proc_start_epoch
259
+
155
260
  return info
156
261
 
157
262
 
@@ -178,10 +283,11 @@ def acquire_lock(
178
283
  ) -> LockInfo:
179
284
  """Acquire the ownership lock. Raise OwnershipError on refuse.
180
285
 
181
- The default stdio-mode call writes the original #177 lock shape so
182
- older lock readers and ``stackchan-mcp --check`` output remain
183
- compatible. Daemon transports can attach optional metadata for
184
- diagnostics without changing the atomic hardlink claim.
286
+ The default stdio-mode call writes the original #177 lock base fields
287
+ plus additive process identity metadata. Older lock readers remain
288
+ compatible because the new field is optional JSON. Daemon transports
289
+ can attach optional metadata for diagnostics without changing the
290
+ atomic hardlink claim.
185
291
  """
186
292
  if mode not in ("stdio", "streamable-http"):
187
293
  raise ValueError(f"unsupported lock mode: {mode!r}")
@@ -195,25 +301,39 @@ def acquire_lock(
195
301
  existing = read_lock(path)
196
302
  if existing is not None:
197
303
  if is_pid_alive(existing["pid"]):
198
- raise OwnershipError(
199
- "stackchan-mcp: device already owned by "
200
- f"{existing['owner_id']} "
201
- f"(pid {existing['pid']}, since {existing['start_ts']})"
304
+ if _process_identity_matches(
305
+ existing["pid"], existing.get("proc_start_epoch")
306
+ ):
307
+ raise OwnershipError(
308
+ "stackchan-mcp: device already owned by "
309
+ f"{existing['owner_id']} "
310
+ f"(pid {existing['pid']}, since {existing['start_ts']})"
311
+ )
312
+ print(
313
+ "stackchan-mcp: removed stale lock from recycled pid "
314
+ f"{existing['pid']}",
315
+ file=sys.stderr,
316
+ )
317
+ else:
318
+ print(
319
+ "stackchan-mcp: removed stale lock from dead pid "
320
+ f"{existing['pid']}",
321
+ file=sys.stderr,
202
322
  )
203
- print(
204
- f"stackchan-mcp: removed stale lock from dead pid {existing['pid']}",
205
- file=sys.stderr,
206
- )
207
323
  path.unlink(missing_ok=True)
208
324
  elif path.exists():
209
325
  path.unlink()
210
326
 
327
+ pid = os.getpid()
328
+ proc_start_epoch = get_process_start_time(pid)
211
329
  info: LockInfo = {
212
330
  "owner_id": owner_id,
213
- "pid": os.getpid(),
331
+ "pid": pid,
214
332
  "start_ts": _now_iso(),
215
333
  "host": socket.gethostname(),
216
334
  }
335
+ if proc_start_epoch is not None:
336
+ info["proc_start_epoch"] = proc_start_epoch
217
337
  if mode != "stdio":
218
338
  info["mode"] = mode
219
339
  if http_endpoint is not None:
@@ -246,8 +366,9 @@ def release_lock_if_owner(info: LockInfo, path: Path = LOCK_PATH) -> bool:
246
366
  """Remove the lock file only if it still belongs to ``info``.
247
367
 
248
368
  Returns ``True`` if the lock was removed, ``False`` if the on-disk
249
- lock has a different ``owner_id`` / ``pid`` / ``start_ts`` or no
250
- longer exists. This is the owner-scoped counterpart to
369
+ lock has a different ``owner_id`` / ``pid`` / ``start_ts`` /
370
+ ``proc_start_epoch`` or no longer exists. This is the owner-scoped
371
+ counterpart to
251
372
  :func:`release_lock` and is intended for cleanup paths (``finally``
252
373
  blocks, ``atexit.register``) where the caller may have lost ownership
253
374
  between claim and cleanup — for example after the gateway exited and
@@ -263,6 +384,12 @@ def release_lock_if_owner(info: LockInfo, path: Path = LOCK_PATH) -> bool:
263
384
  or existing.get("start_ts") != info.get("start_ts")
264
385
  ):
265
386
  return False
387
+ if (
388
+ "proc_start_epoch" in existing
389
+ and "proc_start_epoch" in info
390
+ and existing["proc_start_epoch"] != info["proc_start_epoch"]
391
+ ):
392
+ return False
266
393
  try:
267
394
  path.unlink()
268
395
  return True
@@ -493,6 +493,14 @@ async def _dispatch_mcp_tool(
493
493
  "self.robot.check_vm_en",
494
494
  {},
495
495
  ),
496
+ "gateway_config_get": (
497
+ "self.gateway_config.get",
498
+ {},
499
+ ),
500
+ "gateway_config_set": (
501
+ "self.gateway_config.set",
502
+ arguments,
503
+ ),
496
504
  "set_avatar": (
497
505
  "self.display.set_avatar",
498
506
  arguments,
@@ -537,6 +545,26 @@ async def _dispatch_mcp_tool(
537
545
  "self.led.clear",
538
546
  {},
539
547
  ),
548
+ "port_b_ws2812_init": (
549
+ "self.port_b.ws2812.init",
550
+ arguments,
551
+ ),
552
+ "port_b_ws2812_set_pixel": (
553
+ "self.port_b.ws2812.set_pixel",
554
+ arguments,
555
+ ),
556
+ "port_b_ws2812_set_strip": (
557
+ "self.port_b.ws2812.set_strip",
558
+ {"colors": json.dumps(arguments.get("colors", []))},
559
+ ),
560
+ "port_b_ws2812_refresh": (
561
+ "self.port_b.ws2812.refresh",
562
+ {},
563
+ ),
564
+ "port_b_ws2812_clear": (
565
+ "self.port_b.ws2812.clear",
566
+ {},
567
+ ),
540
568
  "i2c_scan": (
541
569
  "self.i2c.scan",
542
570
  {},
@@ -745,6 +773,77 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
745
773
  ),
746
774
  inputSchema={"type": "object", "properties": {}},
747
775
  ),
776
+ Tool(
777
+ name="gateway_config_get",
778
+ description=(
779
+ "Read the device's NVS-backed WebSocket gateway "
780
+ "connection settings. Returns url, fallback_url, "
781
+ "token_set (never the token value), forced_keys, "
782
+ "force_mode, discovery_enabled, and connected_url when "
783
+ "the current WebSocket candidate is connected. Empty url "
784
+ "enables mDNS discovery when firmware discovery support "
785
+ "is compiled in and the primary URL is not forced; "
786
+ "fallback_url is tried after discovery and is suitable "
787
+ "for an out-of-LAN relay. forced_keys lists any of url, "
788
+ "fallback_url, and token that a non-empty Kconfig default "
789
+ "overrides at connect time; force_mode=true means at "
790
+ "least one key is forced."
791
+ ),
792
+ inputSchema={"type": "object", "properties": {}},
793
+ ),
794
+ Tool(
795
+ name="gateway_config_set",
796
+ description=(
797
+ "Update the device's NVS-backed WebSocket gateway "
798
+ "connection settings. Optional string fields: url, "
799
+ "fallback_url, token; at least one must be provided. "
800
+ "Passing an empty string clears that key. Leave url "
801
+ "empty to enable mDNS discovery on the next reconnect "
802
+ "when firmware discovery support is compiled in and the "
803
+ "primary URL is not forced; fallback_url is tried after "
804
+ "discovery and is suitable for an out-of-LAN relay. The "
805
+ "change is persisted but does not disconnect, reconnect, "
806
+ "or reboot the device; it takes effect on the next "
807
+ "reconnect. forced_keys lists any of url, fallback_url, "
808
+ "and token that a non-empty Kconfig default overrides at "
809
+ "connect time; force_mode=true means at least one key is "
810
+ "forced, so updates to those keys are ignored until a "
811
+ "non-force build is flashed."
812
+ ),
813
+ inputSchema={
814
+ "type": "object",
815
+ "properties": {
816
+ "url": {
817
+ "type": "string",
818
+ "description": (
819
+ "Primary NVS WebSocket URL. Empty string "
820
+ "clears websocket.url, which enables mDNS "
821
+ "discovery on the next reconnect when "
822
+ "discovery is compiled in and the primary URL "
823
+ "is not forced."
824
+ ),
825
+ },
826
+ "fallback_url": {
827
+ "type": "string",
828
+ "description": (
829
+ "Fallback NVS WebSocket URL. Tried after "
830
+ "discovery and suitable for an out-of-LAN "
831
+ "relay. Empty string clears "
832
+ "websocket.fallback_url."
833
+ ),
834
+ },
835
+ "token": {
836
+ "type": "string",
837
+ "description": (
838
+ "Bearer token stored in NVS and sent to the "
839
+ "gateway. Empty string clears the token. The "
840
+ "token value is never returned by "
841
+ "gateway_config_get."
842
+ ),
843
+ },
844
+ },
845
+ },
846
+ ),
748
847
  Tool(
749
848
  name="set_avatar",
750
849
  description=(
@@ -1029,19 +1128,157 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
1029
1128
  description="Turn off all 12 RGB LEDs on the StackChan base.",
1030
1129
  inputSchema={"type": "object", "properties": {}},
1031
1130
  ),
1131
+ Tool(
1132
+ name="port_b_ws2812_init",
1133
+ description=(
1134
+ "Initialize a WS2812-compatible LED strip connected to "
1135
+ "Port B (CoreS3 HY2.0-4P digital OUTPUT, GPIO 9). "
1136
+ "led_count is the number of LEDs in the strip (1..256). "
1137
+ "Repeated calls with the same led_count are no-ops; a "
1138
+ "different led_count rebuilds the strip handle. Port B "
1139
+ "outputs 3.3 V CMOS data on GPIO 9; older strict 5 V "
1140
+ "WS2812 variants may require a level shifter."
1141
+ ),
1142
+ inputSchema={
1143
+ "type": "object",
1144
+ "properties": {
1145
+ "led_count": {
1146
+ "type": "integer",
1147
+ "description": "Number of LEDs in the strip (1..256).",
1148
+ "minimum": 1,
1149
+ "maximum": 256,
1150
+ },
1151
+ },
1152
+ "required": ["led_count"],
1153
+ },
1154
+ ),
1155
+ Tool(
1156
+ name="port_b_ws2812_set_pixel",
1157
+ description=(
1158
+ "Set one LED in the Port B WS2812 strip buffer. Call "
1159
+ "port_b_ws2812_init first. index is 0..255, with the "
1160
+ "effective range bounded by led_count. r, g, and b are "
1161
+ "0..255. By default the color is buffered only; pass "
1162
+ "refresh=true to latch it immediately, or call "
1163
+ "port_b_ws2812_refresh after several buffered updates. "
1164
+ "Port B outputs 3.3 V CMOS data on GPIO 9; older strict "
1165
+ "5 V WS2812 variants may require a level shifter."
1166
+ ),
1167
+ inputSchema={
1168
+ "type": "object",
1169
+ "properties": {
1170
+ "index": {
1171
+ "type": "integer",
1172
+ "description": "LED index (0..255).",
1173
+ "minimum": 0,
1174
+ "maximum": 255,
1175
+ },
1176
+ "r": {
1177
+ "type": "integer",
1178
+ "description": "Red 0..255.",
1179
+ "minimum": 0,
1180
+ "maximum": 255,
1181
+ },
1182
+ "g": {
1183
+ "type": "integer",
1184
+ "description": "Green 0..255.",
1185
+ "minimum": 0,
1186
+ "maximum": 255,
1187
+ },
1188
+ "b": {
1189
+ "type": "integer",
1190
+ "description": "Blue 0..255.",
1191
+ "minimum": 0,
1192
+ "maximum": 255,
1193
+ },
1194
+ "refresh": {
1195
+ "type": "boolean",
1196
+ "description": "True to latch the update immediately.",
1197
+ "default": False,
1198
+ },
1199
+ },
1200
+ "required": ["index", "r", "g", "b"],
1201
+ },
1202
+ ),
1203
+ Tool(
1204
+ name="port_b_ws2812_set_strip",
1205
+ description=(
1206
+ "Set multiple LEDs in the Port B WS2812 strip and refresh "
1207
+ "immediately. Call port_b_ws2812_init first. colors is an "
1208
+ "array of [r,g,b] integer triples applied from LED index 0; "
1209
+ "up to led_count entries are written, extras are ignored, "
1210
+ "and missing trailing entries preserve the previous buffer. "
1211
+ "The firmware validates the full payload before writing. "
1212
+ "Port B outputs 3.3 V CMOS data on GPIO 9; older strict "
1213
+ "5 V WS2812 variants may require a level shifter."
1214
+ ),
1215
+ inputSchema={
1216
+ "type": "object",
1217
+ "properties": {
1218
+ "colors": {
1219
+ "type": "array",
1220
+ "description": (
1221
+ "Array of [r,g,b] triples, each integer 0..255."
1222
+ ),
1223
+ "items": {
1224
+ "type": "array",
1225
+ "items": {
1226
+ "type": "integer",
1227
+ "minimum": 0,
1228
+ "maximum": 255,
1229
+ },
1230
+ "minItems": 3,
1231
+ "maxItems": 3,
1232
+ },
1233
+ "minItems": 1,
1234
+ "maxItems": 256,
1235
+ },
1236
+ },
1237
+ "required": ["colors"],
1238
+ },
1239
+ ),
1240
+ Tool(
1241
+ name="port_b_ws2812_refresh",
1242
+ description=(
1243
+ "Refresh the Port B WS2812 strip, latching the current "
1244
+ "buffered colors out on CoreS3 HY2.0-4P digital OUTPUT "
1245
+ "GPIO 9. Call port_b_ws2812_init first. Use this after "
1246
+ "one or more port_b_ws2812_set_pixel calls made with "
1247
+ "refresh=false. Port B outputs 3.3 V CMOS data; older "
1248
+ "strict 5 V WS2812 variants may require a level shifter."
1249
+ ),
1250
+ inputSchema={"type": "object", "properties": {}},
1251
+ ),
1252
+ Tool(
1253
+ name="port_b_ws2812_clear",
1254
+ description=(
1255
+ "Turn off every LED in the Port B WS2812 strip and "
1256
+ "refresh immediately on CoreS3 HY2.0-4P digital OUTPUT "
1257
+ "GPIO 9. Call port_b_ws2812_init first. This clears the "
1258
+ "driver's per-pixel buffer. Port B outputs 3.3 V CMOS "
1259
+ "data; older strict 5 V WS2812 variants may require a "
1260
+ "level shifter."
1261
+ ),
1262
+ inputSchema={"type": "object", "properties": {}},
1263
+ ),
1032
1264
  Tool(
1033
1265
  name="say",
1034
1266
  description=(
1035
1267
  "Speak the given text on the device speaker via gateway-side "
1036
- "TTS (Phase 4, Issue #70). The gateway synthesises audio, "
1037
- "encodes it to Opus, and pushes frames over the existing "
1038
- "WebSocket — the device firmware does not change. Engine is "
1039
- "selectable via 'voice' (default 'voicevox'). "
1040
- "NOTE: this build ships the framework only; concrete engines "
1041
- "(VOICEVOX, Irodori) land in follow-up PRs and require the "
1042
- "matching optional extra (e.g. "
1043
- "'pip install stackchan-mcp[tts-voicevox]'). Calling this tool "
1044
- "before an engine is registered returns a clear error."
1268
+ "TTS. The gateway synthesises audio, encodes it to Opus, "
1269
+ "and pushes frames over the existing WebSocket; the device "
1270
+ "firmware does not change. Engine is selectable via 'voice' "
1271
+ "(default 'voicevox'). If the text contains a supported "
1272
+ "expression emoji, say first switches the avatar face in the "
1273
+ "same call: happy (😊 😄 😀 😁 🙂 😆 🥰 😍 😋 🤗), "
1274
+ "sad (😢 😭 😞 😔 ☹️ 🙁 😿), surprised (😲 😮 😯 😱 🤯), "
1275
+ "embarrassed (😳 😅 🫣), thinking (🤔 🧐 💭). The first "
1276
+ "mapped emoji wins; unmapped emoji do not change the face, "
1277
+ "and emoji never select 'off'. Irodori keeps emoji in the "
1278
+ "TTS input so they can act as voice-style cues. Engines "
1279
+ "without emoji-style support, including VOICEVOX, strip all "
1280
+ "emoji before synthesis; if stripping leaves empty text, the "
1281
+ "face change is still attempted and speech is skipped."
1045
1282
  ),
1046
1283
  inputSchema={
1047
1284
  "type": "object",
@@ -48,7 +48,14 @@ def _register_voicevox() -> None:
48
48
  get_registry().register(VoicevoxEngine())
49
49
 
50
50
 
51
+ def _register_irodori() -> None:
52
+ from .irodori import IrodoriEngine
53
+
54
+ get_registry().register(IrodoriEngine())
55
+
56
+
51
57
  _try_register(_register_voicevox, "voicevox")
58
+ _try_register(_register_irodori, "irodori")
52
59
 
53
60
 
54
61
  __all__ = [
@@ -29,6 +29,11 @@ class TTSEngine(ABC):
29
29
  #: Concrete subclasses must override with a non-empty string.
30
30
  name: str = ""
31
31
 
32
+ #: Whether the engine natively interprets emoji as voice-style cues.
33
+ #: Engines that leave this false receive emoji-stripped text from
34
+ #: the orchestrator.
35
+ supports_emoji_style: bool = False
36
+
32
37
  @abstractmethod
33
38
  async def synthesize(self, text: str, **opts: Any) -> bytes:
34
39
  """Synthesise ``text`` into 16 kHz mono PCM (signed 16-bit LE).
@@ -0,0 +1,80 @@
1
+ """Emoji-driven avatar expression helpers for ``say`` text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ EMOJI_FACE_GROUPS: dict[str, tuple[str, ...]] = {
9
+ "happy": ("😊", "😄", "😀", "😁", "🙂", "😆", "🥰", "😍", "😋", "🤗"),
10
+ "sad": ("😢", "😭", "😞", "😔", "☹️", "🙁", "😿"),
11
+ "surprised": ("😲", "😮", "😯", "😱", "🤯"),
12
+ "embarrassed": ("😳", "😅", "🫣"),
13
+ "thinking": ("🤔", "🧐", "💭"),
14
+ }
15
+
16
+ _EMOJI_TO_FACE: dict[str, str] = {
17
+ emoji: face
18
+ for face, emojis in EMOJI_FACE_GROUPS.items()
19
+ for emoji in emojis
20
+ }
21
+ _MAPPED_EMOJIS = tuple(
22
+ sorted(_EMOJI_TO_FACE, key=lambda emoji: len(emoji), reverse=True)
23
+ )
24
+
25
+ _EMOJI_BASE = (
26
+ "["
27
+ "\u00a9\u00ae"
28
+ "\u2600-\u27bf"
29
+ "\U0001f000-\U0001faff"
30
+ "]"
31
+ )
32
+ _EMOJI_MODIFIER = "[\ufe0f\U0001f3fb-\U0001f3ff]*"
33
+ _EMOJI_SEQUENCE_RE = re.compile(
34
+ f"(?:[0-9#*]\ufe0f?\u20e3|"
35
+ f"{_EMOJI_BASE}{_EMOJI_MODIFIER}"
36
+ f"(?:\u200d{_EMOJI_BASE}{_EMOJI_MODIFIER})*)"
37
+ )
38
+ _EMOJI_RESIDUE_RE = re.compile("[\ufe0f\u200d\U0001f3fb-\U0001f3ff\u20e3]")
39
+ _WHITESPACE_RE = re.compile(r"\s+")
40
+
41
+
42
+ def detect_emoji_face(text: str) -> str | None:
43
+ """Return the face mapped from the first supported emoji in ``text``."""
44
+ best_index: int | None = None
45
+ best_emoji: str | None = None
46
+
47
+ for emoji in _MAPPED_EMOJIS:
48
+ index = text.find(emoji)
49
+ if index < 0:
50
+ continue
51
+ if (
52
+ best_index is None
53
+ or index < best_index
54
+ or (
55
+ index == best_index
56
+ and best_emoji is not None
57
+ and len(emoji) > len(best_emoji)
58
+ )
59
+ ):
60
+ best_index = index
61
+ best_emoji = emoji
62
+
63
+ if best_emoji is None:
64
+ return None
65
+ return _EMOJI_TO_FACE[best_emoji]
66
+
67
+
68
+ def contains_emoji(text: str) -> bool:
69
+ """Return whether ``text`` contains any Unicode emoji-like sequence."""
70
+ return _EMOJI_SEQUENCE_RE.search(text) is not None
71
+
72
+
73
+ def strip_emoji_for_plain_tts(text: str) -> str:
74
+ """Remove emoji for engines that do not interpret them as style cues."""
75
+ if not contains_emoji(text):
76
+ return text
77
+
78
+ stripped = _EMOJI_SEQUENCE_RE.sub(" ", text)
79
+ stripped = _EMOJI_RESIDUE_RE.sub(" ", stripped)
80
+ return _WHITESPACE_RE.sub(" ", stripped).strip()