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.
Files changed (43) hide show
  1. stackchan_mcp/__init__.py +81 -0
  2. stackchan_mcp/__main__.py +12 -0
  3. stackchan_mcp/_libs/SOURCES.md +130 -0
  4. stackchan_mcp/_libs/opus.dll +0 -0
  5. stackchan_mcp/audio_input_hook.py +432 -0
  6. stackchan_mcp/audio_stream.py +162 -0
  7. stackchan_mcp/capture_server.py +469 -0
  8. stackchan_mcp/cli.py +958 -0
  9. stackchan_mcp/esp32_client.py +983 -0
  10. stackchan_mcp/event_log.py +189 -0
  11. stackchan_mcp/gateway.py +274 -0
  12. stackchan_mcp/handlers/__init__.py +7 -0
  13. stackchan_mcp/handlers/audio.py +21 -0
  14. stackchan_mcp/handlers/camera.py +25 -0
  15. stackchan_mcp/handlers/robot.py +52 -0
  16. stackchan_mcp/http_server.py +398 -0
  17. stackchan_mcp/mcp_router.py +126 -0
  18. stackchan_mcp/mdns_advertiser.py +347 -0
  19. stackchan_mcp/notify.example.yml +21 -0
  20. stackchan_mcp/notify_config.py +235 -0
  21. stackchan_mcp/ownership.py +270 -0
  22. stackchan_mcp/protocol.py +95 -0
  23. stackchan_mcp/queue.py +191 -0
  24. stackchan_mcp/server.py +28 -0
  25. stackchan_mcp/stdio_server.py +1365 -0
  26. stackchan_mcp/stt/__init__.py +62 -0
  27. stackchan_mcp/stt/audio_utils.py +102 -0
  28. stackchan_mcp/stt/base.py +94 -0
  29. stackchan_mcp/stt/faster_whisper.py +217 -0
  30. stackchan_mcp/stt/openai_whisper.py +177 -0
  31. stackchan_mcp/stt/orchestrator.py +568 -0
  32. stackchan_mcp/tools.py +82 -0
  33. stackchan_mcp/tts/__init__.py +62 -0
  34. stackchan_mcp/tts/audio_utils.py +177 -0
  35. stackchan_mcp/tts/base.py +86 -0
  36. stackchan_mcp/tts/orchestrator.py +688 -0
  37. stackchan_mcp/tts/voicevox.py +184 -0
  38. stackchan_mcp-0.9.1.dist-info/METADATA +324 -0
  39. stackchan_mcp-0.9.1.dist-info/RECORD +43 -0
  40. stackchan_mcp-0.9.1.dist-info/WHEEL +5 -0
  41. stackchan_mcp-0.9.1.dist-info/entry_points.txt +2 -0
  42. stackchan_mcp-0.9.1.dist-info/licenses/LICENSE +39 -0
  43. 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
+ )
@@ -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,7 @@
1
+ """Tool handler implementations.
2
+
3
+ Submodules:
4
+ - robot: head servo + LED control
5
+ - audio: volume control (stub)
6
+ - camera: photo capture via ESP32
7
+ """
@@ -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