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.
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/PKG-INFO +5 -1
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/pyproject.toml +13 -2
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/ownership.py +142 -15
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stdio_server.py +246 -9
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/__init__.py +7 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/base.py +5 -0
- stackchan_mcp-0.11.0/stackchan_mcp/tts/emoji_expression.py +80 -0
- stackchan_mcp-0.11.0/stackchan_mcp/tts/irodori.py +358 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/orchestrator.py +187 -13
- stackchan_mcp-0.11.0/tests/test_emoji_expression.py +44 -0
- stackchan_mcp-0.11.0/tests/test_irodori.py +438 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_orchestrator.py +303 -21
- stackchan_mcp-0.11.0/tests/test_ownership.py +434 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_stdio_server.py +293 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_tts_framework.py +77 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/uv.lock +50 -2
- stackchan_mcp-0.10.0/tests/test_ownership.py +0 -92
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/.env.example +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/.gitignore +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/AGENTS.md +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/LICENSE +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/LICENSE-THIRD-PARTY +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/README.md +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/__init__.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/__main__.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/audio_input_hook.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/audio_stream.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/capture_server.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/cli.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/esp32_client.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/event_log.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/gateway.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/__init__.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/audio.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/camera.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/handlers/robot.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/http_server.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/mcp_router.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/mdns_advertiser.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/notify.example.yml +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/notify_config.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/protocol.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/queue.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/server.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/__init__.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/audio_utils.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/base.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/stt/orchestrator.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/tools.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/audio_utils.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/stackchan_mcp/tts/voicevox.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/_audio_fixtures.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/conftest.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_audio_input_hook.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_audio_stream.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_audio_utils.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_capture_server.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_cli.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_esp32_client.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_event_dispatch.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_event_log.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_gateway.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_http_server.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_mcp_router.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_mdns_advertiser.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_notify_config.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_protocol.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_send_pcm_audio.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_send_pcm_stream.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_stackchan_event.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_stt_audio_utils.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_stt_framework.py +0 -0
- {stackchan_mcp-0.10.0 → stackchan_mcp-0.11.0}/tests/test_stt_orchestrator.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
182
|
-
|
|
183
|
-
compatible
|
|
184
|
-
diagnostics without changing the
|
|
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
|
-
|
|
199
|
-
"
|
|
200
|
-
|
|
201
|
-
|
|
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":
|
|
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``
|
|
250
|
-
longer exists. This is the owner-scoped
|
|
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
|
|
1037
|
-
"
|
|
1038
|
-
"
|
|
1039
|
-
"
|
|
1040
|
-
"
|
|
1041
|
-
"(
|
|
1042
|
-
"
|
|
1043
|
-
"
|
|
1044
|
-
"
|
|
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()
|