stackchan-mcp 0.9.1__py3-none-win_amd64.whl
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/__init__.py +81 -0
- stackchan_mcp/__main__.py +12 -0
- stackchan_mcp/_libs/SOURCES.md +130 -0
- stackchan_mcp/_libs/opus.dll +0 -0
- stackchan_mcp/audio_input_hook.py +432 -0
- stackchan_mcp/audio_stream.py +162 -0
- stackchan_mcp/capture_server.py +469 -0
- stackchan_mcp/cli.py +958 -0
- stackchan_mcp/esp32_client.py +983 -0
- stackchan_mcp/event_log.py +189 -0
- stackchan_mcp/gateway.py +274 -0
- stackchan_mcp/handlers/__init__.py +7 -0
- stackchan_mcp/handlers/audio.py +21 -0
- stackchan_mcp/handlers/camera.py +25 -0
- stackchan_mcp/handlers/robot.py +52 -0
- stackchan_mcp/http_server.py +398 -0
- stackchan_mcp/mcp_router.py +126 -0
- stackchan_mcp/mdns_advertiser.py +347 -0
- stackchan_mcp/notify.example.yml +21 -0
- stackchan_mcp/notify_config.py +235 -0
- stackchan_mcp/ownership.py +270 -0
- stackchan_mcp/protocol.py +95 -0
- stackchan_mcp/queue.py +191 -0
- stackchan_mcp/server.py +28 -0
- stackchan_mcp/stdio_server.py +1365 -0
- stackchan_mcp/stt/__init__.py +62 -0
- stackchan_mcp/stt/audio_utils.py +102 -0
- stackchan_mcp/stt/base.py +94 -0
- stackchan_mcp/stt/faster_whisper.py +217 -0
- stackchan_mcp/stt/openai_whisper.py +177 -0
- stackchan_mcp/stt/orchestrator.py +568 -0
- stackchan_mcp/tools.py +82 -0
- stackchan_mcp/tts/__init__.py +62 -0
- stackchan_mcp/tts/audio_utils.py +177 -0
- stackchan_mcp/tts/base.py +86 -0
- stackchan_mcp/tts/orchestrator.py +688 -0
- stackchan_mcp/tts/voicevox.py +184 -0
- stackchan_mcp-0.9.1.dist-info/METADATA +324 -0
- stackchan_mcp-0.9.1.dist-info/RECORD +43 -0
- stackchan_mcp-0.9.1.dist-info/WHEEL +5 -0
- stackchan_mcp-0.9.1.dist-info/entry_points.txt +2 -0
- stackchan_mcp-0.9.1.dist-info/licenses/LICENSE +39 -0
- stackchan_mcp-0.9.1.dist-info/licenses/LICENSE-THIRD-PARTY +65 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Event log writer for firmware-originated stackchan events.
|
|
2
|
+
|
|
3
|
+
When the JSONL notification path is enabled, the gateway appends each
|
|
4
|
+
successfully-validated ``stackchan-event`` frame to a JSONL file (default
|
|
5
|
+
``~/.claude/stackchan-events.jsonl``) so downstream host integrations can
|
|
6
|
+
read events between the firmware reaction and the next conversational
|
|
7
|
+
turn. MCP notification paths are configured independently.
|
|
8
|
+
|
|
9
|
+
The log file path is overridable via ``STACKCHAN_EVENTS_PATH`` or an
|
|
10
|
+
explicit caller-provided path. Entries
|
|
11
|
+
whose ``ts_unix`` is older than ``RETENTION_DAYS`` are pruned exactly
|
|
12
|
+
once on gateway startup via :func:`rotate_old_entries`. Long-running
|
|
13
|
+
gateways are not re-rotated mid-flight; downstream readers are expected
|
|
14
|
+
to filter by ``ts_unix`` themselves, and any disk-growth concern over
|
|
15
|
+
multi-day uptimes is tracked as a separate follow-up.
|
|
16
|
+
|
|
17
|
+
All persistence failures (``PermissionError``, ``OSError``, malformed
|
|
18
|
+
lines, missing parent directory, etc.) are caught and logged at WARNING
|
|
19
|
+
level. The MCP notification path must never be broken by event log
|
|
20
|
+
persistence issues; callers should treat this helper as fire-and-forget.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from tempfile import NamedTemporaryFile
|
|
31
|
+
from typing import Final
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
DEFAULT_LOG_PATH: Final[Path] = Path.home() / ".claude" / "stackchan-events.jsonl"
|
|
36
|
+
PATH_ENV_VAR: Final[str] = "STACKCHAN_EVENTS_PATH"
|
|
37
|
+
RETENTION_DAYS: Final[int] = 7
|
|
38
|
+
_RETENTION_SECONDS: Final[int] = RETENTION_DAYS * 24 * 60 * 60
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_log_path() -> Path:
|
|
42
|
+
"""Return the active event log path.
|
|
43
|
+
|
|
44
|
+
Honors the ``STACKCHAN_EVENTS_PATH`` environment variable when set
|
|
45
|
+
(``~`` is expanded), otherwise falls back to
|
|
46
|
+
``~/.claude/stackchan-events.jsonl``.
|
|
47
|
+
"""
|
|
48
|
+
override = os.environ.get(PATH_ENV_VAR)
|
|
49
|
+
if override:
|
|
50
|
+
return Path(override).expanduser()
|
|
51
|
+
return DEFAULT_LOG_PATH
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def log_event(
|
|
55
|
+
event_type: str,
|
|
56
|
+
subtype: str,
|
|
57
|
+
duration_ms: int,
|
|
58
|
+
ts: int,
|
|
59
|
+
session_id: str,
|
|
60
|
+
*,
|
|
61
|
+
action: str | None = None,
|
|
62
|
+
path: Path | None = None,
|
|
63
|
+
ts_unix: float | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Append a single stackchan event to the JSONL log.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
event_type, subtype, duration_ms, ts, session_id
|
|
70
|
+
Already-validated fields from the firmware-emitted
|
|
71
|
+
``stackchan-event`` WebSocket frame. ``ts`` is firmware uptime
|
|
72
|
+
in milliseconds (monotonic); ``ts_unix`` is the wall-clock
|
|
73
|
+
moment the gateway recorded the event and is what hook
|
|
74
|
+
consumers should use for ``"how long ago"`` calculations.
|
|
75
|
+
action
|
|
76
|
+
Optional human-axis avatar action to include in the JSONL payload.
|
|
77
|
+
path
|
|
78
|
+
Optional resolved log path from notify.yml. When omitted, the legacy
|
|
79
|
+
``STACKCHAN_EVENTS_PATH`` / default path resolution is used.
|
|
80
|
+
ts_unix
|
|
81
|
+
Optional override for the wall-clock timestamp. Defaults to
|
|
82
|
+
``time.time()`` at append time. Exposed for tests.
|
|
83
|
+
|
|
84
|
+
Errors are logged at WARNING and swallowed; the MCP notification
|
|
85
|
+
path continues regardless of disk outcome.
|
|
86
|
+
"""
|
|
87
|
+
if ts_unix is None:
|
|
88
|
+
ts_unix = time.time()
|
|
89
|
+
|
|
90
|
+
if path is None:
|
|
91
|
+
path = resolve_log_path()
|
|
92
|
+
line = {
|
|
93
|
+
"event_type": event_type,
|
|
94
|
+
"subtype": subtype,
|
|
95
|
+
"duration_ms": duration_ms,
|
|
96
|
+
"ts": ts,
|
|
97
|
+
"ts_unix": ts_unix,
|
|
98
|
+
"session_id": session_id,
|
|
99
|
+
}
|
|
100
|
+
if action is not None:
|
|
101
|
+
line["action"] = action
|
|
102
|
+
try:
|
|
103
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
with path.open("a", encoding="utf-8") as f:
|
|
105
|
+
f.write(json.dumps(line, ensure_ascii=False) + "\n")
|
|
106
|
+
f.flush()
|
|
107
|
+
except (OSError, PermissionError) as exc:
|
|
108
|
+
logger.warning(
|
|
109
|
+
"Failed to append stackchan-event log line to %s: %s",
|
|
110
|
+
path,
|
|
111
|
+
exc,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def rotate_old_entries(
|
|
116
|
+
*,
|
|
117
|
+
path: Path | None = None,
|
|
118
|
+
now_unix: float | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Prune log entries older than ``RETENTION_DAYS`` from the log file.
|
|
121
|
+
|
|
122
|
+
Intended to be called exactly once at gateway startup. Reads every
|
|
123
|
+
line, keeps the ones whose ``ts_unix`` is within the retention
|
|
124
|
+
window, and atomically replaces the original file via
|
|
125
|
+
``os.replace`` on a same-directory temporary file. Malformed lines
|
|
126
|
+
and lines without a usable ``ts_unix`` are dropped.
|
|
127
|
+
|
|
128
|
+
A missing log file is a no-op. Any disk or permission error during
|
|
129
|
+
rotation is logged at WARNING and swallowed so a broken log file
|
|
130
|
+
cannot prevent the gateway from starting up.
|
|
131
|
+
"""
|
|
132
|
+
if path is None:
|
|
133
|
+
path = resolve_log_path()
|
|
134
|
+
if not path.exists():
|
|
135
|
+
return
|
|
136
|
+
if now_unix is None:
|
|
137
|
+
now_unix = time.time()
|
|
138
|
+
cutoff = now_unix - _RETENTION_SECONDS
|
|
139
|
+
|
|
140
|
+
kept: list[str] = []
|
|
141
|
+
try:
|
|
142
|
+
with path.open("r", encoding="utf-8") as f:
|
|
143
|
+
for raw in f:
|
|
144
|
+
stripped = raw.strip()
|
|
145
|
+
if not stripped:
|
|
146
|
+
continue
|
|
147
|
+
try:
|
|
148
|
+
obj = json.loads(stripped)
|
|
149
|
+
except json.JSONDecodeError:
|
|
150
|
+
logger.debug(
|
|
151
|
+
"Dropping malformed event log line during rotation: %s",
|
|
152
|
+
stripped[:120],
|
|
153
|
+
)
|
|
154
|
+
continue
|
|
155
|
+
if not isinstance(obj, dict):
|
|
156
|
+
continue
|
|
157
|
+
ts_unix = obj.get("ts_unix")
|
|
158
|
+
if isinstance(ts_unix, bool) or not isinstance(ts_unix, (int, float)):
|
|
159
|
+
continue
|
|
160
|
+
if ts_unix >= cutoff:
|
|
161
|
+
kept.append(stripped + "\n")
|
|
162
|
+
except (OSError, PermissionError) as exc:
|
|
163
|
+
logger.warning(
|
|
164
|
+
"Failed to read stackchan-event log %s for rotation: %s",
|
|
165
|
+
path,
|
|
166
|
+
exc,
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
with NamedTemporaryFile(
|
|
173
|
+
mode="w",
|
|
174
|
+
encoding="utf-8",
|
|
175
|
+
delete=False,
|
|
176
|
+
dir=str(path.parent),
|
|
177
|
+
prefix=path.name + ".",
|
|
178
|
+
suffix=".tmp",
|
|
179
|
+
) as tmp:
|
|
180
|
+
tmp.writelines(kept)
|
|
181
|
+
tmp.flush()
|
|
182
|
+
tmp_path = Path(tmp.name)
|
|
183
|
+
os.replace(tmp_path, path)
|
|
184
|
+
except (OSError, PermissionError) as exc:
|
|
185
|
+
logger.warning(
|
|
186
|
+
"Failed to atomically rotate stackchan-event log %s: %s",
|
|
187
|
+
path,
|
|
188
|
+
exc,
|
|
189
|
+
)
|
stackchan_mcp/gateway.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Two-faced gateway: bridges MCP client (stdio MCP) and ESP32 (WebSocket MCP).
|
|
2
|
+
|
|
3
|
+
MCP client sees a standard MCP server via stdio.
|
|
4
|
+
ESP32 sees a WebSocket server that sends MCP client requests.
|
|
5
|
+
This module orchestrates both sides.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from aiohttp import web
|
|
14
|
+
|
|
15
|
+
from .capture_server import create_capture_app, stage_avatar_set
|
|
16
|
+
from .esp32_client import ESP32Manager
|
|
17
|
+
from .mdns_advertiser import MdnsAdvertiser
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Gateway:
|
|
23
|
+
"""Main gateway orchestrator.
|
|
24
|
+
|
|
25
|
+
Holds the ESP32 manager and provides the bridge between
|
|
26
|
+
the stdio MCP server (MCP client side) and the ESP32 device.
|
|
27
|
+
|
|
28
|
+
Also runs an HTTP capture server for receiving photos from ESP32.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.esp32 = ESP32Manager()
|
|
33
|
+
self._running = False
|
|
34
|
+
self._http_runner: web.AppRunner | None = None
|
|
35
|
+
# Phase 4.5 avatar: kept so load_avatar_set can stage payloads
|
|
36
|
+
# against the same web.Application that serves /avatar_set/{id}.
|
|
37
|
+
self._capture_app: web.Application | None = None
|
|
38
|
+
self._mdns_advertiser: MdnsAdvertiser | None = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def vision_url(self) -> str:
|
|
42
|
+
"""URL for ESP32 to POST captured photos to.
|
|
43
|
+
|
|
44
|
+
VISION_URL can be set to a complete public capture URL for remote
|
|
45
|
+
access setups such as Tailscale Funnel. Otherwise VISION_HOST should
|
|
46
|
+
be the LAN IP of the host running this gateway, as seen from the ESP32
|
|
47
|
+
(e.g. something like 192.168.x.y on a typical home network). Falls
|
|
48
|
+
back to "127.0.0.1" with a warning if unset; in that case the ESP32
|
|
49
|
+
will not be able to reach the capture endpoint over the network.
|
|
50
|
+
"""
|
|
51
|
+
explicit_url = os.getenv("VISION_URL")
|
|
52
|
+
if explicit_url:
|
|
53
|
+
return explicit_url
|
|
54
|
+
|
|
55
|
+
host = os.getenv("VISION_HOST")
|
|
56
|
+
if not host:
|
|
57
|
+
logger.warning(
|
|
58
|
+
"VISION_URL/VISION_HOST not set; defaulting to 127.0.0.1. "
|
|
59
|
+
"ESP32 will not reach the capture endpoint unless "
|
|
60
|
+
"VISION_HOST is set to this host's LAN IP or VISION_URL is "
|
|
61
|
+
"set to a full capture URL."
|
|
62
|
+
)
|
|
63
|
+
host = "127.0.0.1"
|
|
64
|
+
port = int(os.getenv("CAPTURE_PORT", "8766"))
|
|
65
|
+
return f"http://{host}:{port}/capture"
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def vision_token(self) -> str:
|
|
69
|
+
"""Bearer token expected by the capture endpoint.
|
|
70
|
+
|
|
71
|
+
VISION_TOKEN can be set separately. By default, reuse the ESP32
|
|
72
|
+
WebSocket token so remote capture uploads are protected whenever the
|
|
73
|
+
gateway itself is protected.
|
|
74
|
+
"""
|
|
75
|
+
return (
|
|
76
|
+
os.getenv("VISION_TOKEN")
|
|
77
|
+
or os.getenv("STACKCHAN_TOKEN")
|
|
78
|
+
or os.getenv("BEARER_TOKEN")
|
|
79
|
+
or ""
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def audio_hook_url(self) -> str:
|
|
84
|
+
"""URL receiving device-driven listen captures as Ogg/Opus.
|
|
85
|
+
|
|
86
|
+
STACKCHAN_AUDIO_HOOK_URL enables the device-driven listen
|
|
87
|
+
capture path (wake word / button / LCD touch): the gateway
|
|
88
|
+
packs inbound Opus frames into an Ogg container and POSTs to
|
|
89
|
+
this URL on ``listen.stop``. The capture path is **disabled**
|
|
90
|
+
when this is unset — stackchan-mcp's primary listen model
|
|
91
|
+
remains MCP-client-driven (the ``listen()`` tool), and
|
|
92
|
+
device-driven capture only makes sense when an external
|
|
93
|
+
service is set up to receive the audio.
|
|
94
|
+
"""
|
|
95
|
+
return os.getenv("STACKCHAN_AUDIO_HOOK_URL", "")
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def audio_hook_token(self) -> str:
|
|
99
|
+
"""Bearer token expected by the audio hook endpoint.
|
|
100
|
+
|
|
101
|
+
STACKCHAN_AUDIO_HOOK_TOKEN can be set separately. Falls back to
|
|
102
|
+
STACKCHAN_TOKEN so a single-token setup works out of the box.
|
|
103
|
+
"""
|
|
104
|
+
return (
|
|
105
|
+
os.getenv("STACKCHAN_AUDIO_HOOK_TOKEN")
|
|
106
|
+
or os.getenv("STACKCHAN_TOKEN")
|
|
107
|
+
or os.getenv("BEARER_TOKEN")
|
|
108
|
+
or ""
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def pcm_token(self) -> str:
|
|
113
|
+
"""Bearer token expected by the /pcm HTTP endpoint.
|
|
114
|
+
|
|
115
|
+
Separate token from the ESP32 WebSocket / capture upload because
|
|
116
|
+
the /pcm endpoint authorises external PCM producers (e.g. the
|
|
117
|
+
SAIVerse voice-tts addon) — a different trust boundary from the
|
|
118
|
+
device-to-gateway authentication. Falls back to STACKCHAN_TOKEN
|
|
119
|
+
/ BEARER_TOKEN when STACKCHAN_PCM_TOKEN is not configured so
|
|
120
|
+
single-token local development keeps working.
|
|
121
|
+
"""
|
|
122
|
+
return (
|
|
123
|
+
os.getenv("STACKCHAN_PCM_TOKEN")
|
|
124
|
+
or os.getenv("STACKCHAN_TOKEN")
|
|
125
|
+
or os.getenv("BEARER_TOKEN")
|
|
126
|
+
or ""
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def start(self, *, advertise_mdns: bool = True) -> None:
|
|
130
|
+
"""Start the ESP32 WebSocket server and HTTP capture server."""
|
|
131
|
+
host = os.getenv("HOST", "0.0.0.0")
|
|
132
|
+
ws_port = int(os.getenv("WS_PORT", os.getenv("PORT", "8765")))
|
|
133
|
+
capture_port = int(os.getenv("CAPTURE_PORT", "8766"))
|
|
134
|
+
|
|
135
|
+
# Start WebSocket server for ESP32
|
|
136
|
+
await self.esp32.start(
|
|
137
|
+
host,
|
|
138
|
+
ws_port,
|
|
139
|
+
vision_url=self.vision_url,
|
|
140
|
+
vision_token=self.vision_token,
|
|
141
|
+
audio_hook_url=self.audio_hook_url,
|
|
142
|
+
audio_hook_token=self.audio_hook_token,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Start HTTP capture server. Hosts /capture, /pcm, and the
|
|
146
|
+
# Phase 4.5 avatar /avatar_set/{short_id} endpoint on the same
|
|
147
|
+
# web.Application. The PCM endpoint forwards into
|
|
148
|
+
# send_pcm_stream, so we hand it the active Gateway instance so
|
|
149
|
+
# it can reach esp32 + tts_lock.
|
|
150
|
+
app = create_capture_app(
|
|
151
|
+
capture_token=self.vision_token,
|
|
152
|
+
pcm_token=self.pcm_token,
|
|
153
|
+
gateway=self,
|
|
154
|
+
)
|
|
155
|
+
self._capture_app = app
|
|
156
|
+
self._http_runner = web.AppRunner(app)
|
|
157
|
+
await self._http_runner.setup()
|
|
158
|
+
site = web.TCPSite(self._http_runner, host, capture_port)
|
|
159
|
+
await site.start()
|
|
160
|
+
|
|
161
|
+
if advertise_mdns:
|
|
162
|
+
self._mdns_advertiser = MdnsAdvertiser()
|
|
163
|
+
try:
|
|
164
|
+
await self._mdns_advertiser.start(host=host, port=ws_port, path="/")
|
|
165
|
+
except Exception as exc: # pragma: no cover - exact zeroconf errors vary by host
|
|
166
|
+
logger.warning("mDNS advertisement failed: %s", exc)
|
|
167
|
+
self._mdns_advertiser = None
|
|
168
|
+
else:
|
|
169
|
+
self._mdns_advertiser = None
|
|
170
|
+
|
|
171
|
+
self._running = True
|
|
172
|
+
logger.info(
|
|
173
|
+
"Gateway started: WS on %s:%d, capture on %s:%d, vision_url=%s",
|
|
174
|
+
host, ws_port, host, capture_port, self.vision_url,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
async def stop(self) -> None:
|
|
178
|
+
"""Stop the gateway."""
|
|
179
|
+
self._running = False
|
|
180
|
+
if self._mdns_advertiser:
|
|
181
|
+
try:
|
|
182
|
+
await self._mdns_advertiser.stop()
|
|
183
|
+
except Exception as exc: # pragma: no cover - exact zeroconf errors vary by host
|
|
184
|
+
logger.warning("mDNS advertisement shutdown failed: %s", exc)
|
|
185
|
+
finally:
|
|
186
|
+
self._mdns_advertiser = None
|
|
187
|
+
if self._http_runner:
|
|
188
|
+
await self._http_runner.cleanup()
|
|
189
|
+
self._http_runner = None
|
|
190
|
+
self._capture_app = None
|
|
191
|
+
await self.esp32.stop()
|
|
192
|
+
logger.info("Gateway stopped")
|
|
193
|
+
|
|
194
|
+
# ---- Phase 4.5 avatar (saiverse-stackchan-addon) ------------------
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def avatar_set_base_url(self) -> str:
|
|
198
|
+
"""Base URL the device should hit for /avatar_set/{short_id}.
|
|
199
|
+
|
|
200
|
+
Reuses vision_url so the device reaches this gateway over the
|
|
201
|
+
same network path it already uses for camera POSTs (VISION_HOST
|
|
202
|
+
/ VISION_URL). The trailing /capture component is stripped.
|
|
203
|
+
"""
|
|
204
|
+
url = self.vision_url
|
|
205
|
+
if url.endswith("/capture"):
|
|
206
|
+
return url[: -len("/capture")]
|
|
207
|
+
return url
|
|
208
|
+
|
|
209
|
+
async def load_avatar_set(
|
|
210
|
+
self,
|
|
211
|
+
archive_path: str,
|
|
212
|
+
mode: str,
|
|
213
|
+
timeout: float = 60.0,
|
|
214
|
+
) -> dict:
|
|
215
|
+
"""Stage an avatar set + notify the device + await its reply.
|
|
216
|
+
|
|
217
|
+
See docs/intent/stackchan_avatar_pipeline.md §C in the SAIVerse
|
|
218
|
+
repository for the protocol. ``archive_path`` is the path to a
|
|
219
|
+
local file containing the raw RGB565 payload (gateway expects
|
|
220
|
+
the addon to have already converted PNG/PIL output to RGB565).
|
|
221
|
+
"""
|
|
222
|
+
if self._capture_app is None:
|
|
223
|
+
return {"ok": False, "error": "gateway_not_started"}
|
|
224
|
+
if not os.path.exists(archive_path):
|
|
225
|
+
return {"ok": False, "error": f"archive_not_found: {archive_path}"}
|
|
226
|
+
|
|
227
|
+
with open(archive_path, "rb") as f:
|
|
228
|
+
payload = f.read()
|
|
229
|
+
|
|
230
|
+
kimg_bytes = 160 * 120 * 2 # 38_400 — matches AvatarSet::kImageBytes
|
|
231
|
+
expected = {
|
|
232
|
+
"layered": 14 * kimg_bytes, # 537_600
|
|
233
|
+
"matrix": 90 * kimg_bytes, # 3_456_000
|
|
234
|
+
}.get(mode)
|
|
235
|
+
if expected is None:
|
|
236
|
+
return {"ok": False, "error": f"unknown_mode: {mode}"}
|
|
237
|
+
if len(payload) != expected:
|
|
238
|
+
return {
|
|
239
|
+
"ok": False,
|
|
240
|
+
"error": f"size_mismatch: got={len(payload)} expected={expected} (mode={mode})",
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
short_id, token, sha256 = await stage_avatar_set(
|
|
245
|
+
self._capture_app, mode, payload
|
|
246
|
+
)
|
|
247
|
+
except ValueError as exc:
|
|
248
|
+
return {"ok": False, "error": str(exc)}
|
|
249
|
+
|
|
250
|
+
url = f"{self.avatar_set_base_url}/avatar_set/{short_id}"
|
|
251
|
+
result = await self.esp32.send_avatar_set_fetch(
|
|
252
|
+
url=url,
|
|
253
|
+
token=token,
|
|
254
|
+
mode=mode,
|
|
255
|
+
checksum=sha256,
|
|
256
|
+
expected_size=len(payload),
|
|
257
|
+
timeout=timeout,
|
|
258
|
+
)
|
|
259
|
+
# Surface the staging metadata for caller-side observability.
|
|
260
|
+
result.setdefault("checksum", sha256)
|
|
261
|
+
result["bytes_transferred"] = len(payload) if result.get("ok") else 0
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# Singleton gateway instance, shared between stdio server and ESP32 manager
|
|
266
|
+
_gateway: Gateway | None = None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def get_gateway() -> Gateway:
|
|
270
|
+
"""Get or create the singleton gateway."""
|
|
271
|
+
global _gateway
|
|
272
|
+
if _gateway is None:
|
|
273
|
+
_gateway = Gateway()
|
|
274
|
+
return _gateway
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Audio handlers: volume control (stub)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..tools import SetVolumeParams
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_volume: int = 50
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_volume(args: dict[str, Any]) -> bool:
|
|
16
|
+
"""Set speaker volume (in-memory stub)."""
|
|
17
|
+
global _volume
|
|
18
|
+
params = SetVolumeParams(**args)
|
|
19
|
+
_volume = params.volume
|
|
20
|
+
logger.info("set_volume -> %d", _volume)
|
|
21
|
+
return True
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Camera handler: take_photo via ESP32 relay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def take_photo(esp32_call) -> dict[str, Any]:
|
|
12
|
+
"""Take a photo via ESP32 camera.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
esp32_call: async callable (name, arguments) -> (result, error)
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
MCP result content.
|
|
19
|
+
"""
|
|
20
|
+
result, error = await esp32_call(
|
|
21
|
+
"self.camera.take_photo", {}
|
|
22
|
+
)
|
|
23
|
+
if error:
|
|
24
|
+
raise RuntimeError(f"take_photo failed: {error.get('message', str(error))}")
|
|
25
|
+
return result
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Robot handlers: servo and LED (stub implementation with in-memory state)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..tools import SetHeadAnglesParams, SetLedColorParams
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# In-memory device state
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
_head_angles: dict[str, int] = {"yaw": 0, "pitch": 0}
|
|
17
|
+
_led_color: dict[str, int] = {"r": 0, "g": 0, "b": 0}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Handlers
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_head_angles(_args: dict[str, Any] | None = None) -> dict[str, int]:
|
|
26
|
+
"""Return current head angles."""
|
|
27
|
+
logger.info("get_head_angles -> %s", _head_angles)
|
|
28
|
+
return dict(_head_angles)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_head_angles(args: dict[str, Any]) -> bool:
|
|
32
|
+
"""Set head angles (in-memory stub)."""
|
|
33
|
+
params = SetHeadAnglesParams(**args)
|
|
34
|
+
_head_angles["yaw"] = params.yaw
|
|
35
|
+
_head_angles["pitch"] = params.pitch
|
|
36
|
+
logger.info(
|
|
37
|
+
"set_head_angles yaw=%d pitch=%d speed=%d",
|
|
38
|
+
params.yaw,
|
|
39
|
+
params.pitch,
|
|
40
|
+
params.speed,
|
|
41
|
+
)
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def set_led_color(args: dict[str, Any]) -> bool:
|
|
46
|
+
"""Set LED color (in-memory stub)."""
|
|
47
|
+
params = SetLedColorParams(**args)
|
|
48
|
+
_led_color["r"] = params.r
|
|
49
|
+
_led_color["g"] = params.g
|
|
50
|
+
_led_color["b"] = params.b
|
|
51
|
+
logger.info("set_led_color r=%d g=%d b=%d", params.r, params.g, params.b)
|
|
52
|
+
return True
|