stackchan-mcp 0.11.0__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/PKG-INFO +3 -1
  2. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/pyproject.toml +3 -1
  3. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/cli.py +4 -0
  4. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/esp32_client.py +2 -0
  5. stackchan_mcp-0.13.0/stackchan_mcp/follow_pose_stream.py +606 -0
  6. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/gateway.py +9 -0
  7. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/http_server.py +3 -1
  8. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stdio_server.py +376 -4
  9. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/__init__.py +7 -0
  10. stackchan_mcp-0.13.0/stackchan_mcp/tts/edge_tts.py +158 -0
  11. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/orchestrator.py +2 -0
  12. stackchan_mcp-0.13.0/stackchan_mcp/user_defaults.py +254 -0
  13. stackchan_mcp-0.13.0/tests/test_follow_pose_stream.py +1131 -0
  14. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_http_server.py +6 -1
  15. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_stdio_server.py +368 -0
  16. stackchan_mcp-0.13.0/tests/test_user_defaults.py +99 -0
  17. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/uv.lock +14 -1
  18. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/.env.example +0 -0
  19. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/.gitignore +0 -0
  20. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/AGENTS.md +0 -0
  21. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/LICENSE +0 -0
  22. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/LICENSE-THIRD-PARTY +0 -0
  23. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/README.md +0 -0
  24. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/__init__.py +0 -0
  25. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/__main__.py +0 -0
  26. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
  27. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/audio_input_hook.py +0 -0
  28. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/audio_stream.py +0 -0
  29. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/capture_server.py +0 -0
  30. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/event_log.py +0 -0
  31. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/handlers/__init__.py +0 -0
  32. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/handlers/audio.py +0 -0
  33. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/handlers/camera.py +0 -0
  34. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/handlers/robot.py +0 -0
  35. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/mcp_router.py +0 -0
  36. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/mdns_advertiser.py +0 -0
  37. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/notify.example.yml +0 -0
  38. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/notify_config.py +0 -0
  39. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/ownership.py +0 -0
  40. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/protocol.py +0 -0
  41. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/queue.py +0 -0
  42. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/server.py +0 -0
  43. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/__init__.py +0 -0
  44. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/audio_utils.py +0 -0
  45. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/base.py +0 -0
  46. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
  47. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
  48. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/orchestrator.py +0 -0
  49. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tools.py +0 -0
  50. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/audio_utils.py +0 -0
  51. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/base.py +0 -0
  52. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/emoji_expression.py +0 -0
  53. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/irodori.py +0 -0
  54. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/voicevox.py +0 -0
  55. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/_audio_fixtures.py +0 -0
  56. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/conftest.py +0 -0
  57. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_audio_input_hook.py +0 -0
  58. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_audio_stream.py +0 -0
  59. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_audio_utils.py +0 -0
  60. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_capture_server.py +0 -0
  61. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_cli.py +0 -0
  62. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_emoji_expression.py +0 -0
  63. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_esp32_client.py +0 -0
  64. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_event_dispatch.py +0 -0
  65. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_event_log.py +0 -0
  66. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_gateway.py +0 -0
  67. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_irodori.py +0 -0
  68. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_mcp_router.py +0 -0
  69. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_mdns_advertiser.py +0 -0
  70. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_notify_config.py +0 -0
  71. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_orchestrator.py +0 -0
  72. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_ownership.py +0 -0
  73. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_protocol.py +0 -0
  74. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_send_pcm_audio.py +0 -0
  75. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_send_pcm_stream.py +0 -0
  76. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_stackchan_event.py +0 -0
  77. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_stt_audio_utils.py +0 -0
  78. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_stt_framework.py +0 -0
  79. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_stt_orchestrator.py +0 -0
  80. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.0}/tests/test_tts_framework.py +0 -0
  81. {stackchan_mcp-0.11.0 → stackchan_mcp-0.13.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.11.0
3
+ Version: 0.13.0
4
4
  Summary: Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP.
5
5
  Project-URL: Homepage, https://github.com/kisaragi-mochi/stackchan-mcp
6
6
  Project-URL: Repository, https://github.com/kisaragi-mochi/stackchan-mcp
@@ -25,9 +25,11 @@ Classifier: Topic :: System :: Hardware
25
25
  Requires-Python: >=3.10
26
26
  Requires-Dist: aiohttp>=3
27
27
  Requires-Dist: mcp<2.0,>=1.27
28
+ Requires-Dist: platformdirs<5.0,>=4.0
28
29
  Requires-Dist: pydantic>=2
29
30
  Requires-Dist: python-dotenv
30
31
  Requires-Dist: pyyaml>=6
32
+ Requires-Dist: tomli<3.0,>=2.0; python_version < '3.11'
31
33
  Requires-Dist: websockets>=12
32
34
  Requires-Dist: zeroconf>=0.149
33
35
  Provides-Extra: stt
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackchan-mcp"
3
- version = "0.11.0"
3
+ version = "0.13.0"
4
4
  description = "Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -34,6 +34,8 @@ dependencies = [
34
34
  "pydantic>=2",
35
35
  "python-dotenv",
36
36
  "PyYAML>=6",
37
+ "platformdirs>=4.0,<5.0",
38
+ "tomli>=2.0,<3.0; python_version < '3.11'",
37
39
  # The stdio server captures the active ServerSession via a subclassed
38
40
  # Server.run() loop because the public MCP SDK does not yet expose a
39
41
  # stable hook for server-side notifications. That subclass relies on
@@ -745,6 +745,10 @@ def _configure_gateway_startup() -> None:
745
745
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
746
746
  )
747
747
 
748
+ from .user_defaults import log_user_defaults_startup
749
+
750
+ log_user_defaults_startup()
751
+
748
752
 
749
753
  def _acquire_startup_lock(
750
754
  *,
@@ -45,6 +45,7 @@ ToolCallResult = tuple[Any, dict[str, Any] | None]
45
45
 
46
46
  _TOOL_LANES = {
47
47
  "self.robot.": "servo",
48
+ "self.wifi.": "wifi",
48
49
  "self.led.": "led",
49
50
  "self.display.": "avatar",
50
51
  "self.screen.": "display",
@@ -416,6 +417,7 @@ class ESP32Manager:
416
417
  self._device_driven_session_id: str | None = None
417
418
  self._tool_lane_locks = {
418
419
  "servo": asyncio.Lock(),
420
+ "wifi": asyncio.Lock(),
419
421
  "led": asyncio.Lock(),
420
422
  "avatar": asyncio.Lock(),
421
423
  "display": asyncio.Lock(),
@@ -0,0 +1,606 @@
1
+ """Pose-stream subscriber that drives head servos from WebSocket frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections import deque
7
+ from dataclasses import dataclass
8
+ import json
9
+ import math
10
+ from typing import Any, Optional
11
+
12
+ import websockets
13
+
14
+ SERVO_YAW_MIN, SERVO_YAW_MAX = -90, 90
15
+ SERVO_PITCH_MIN, SERVO_PITCH_MAX = 5, 85
16
+ SERVO_MAX_SPEED_DPS = 240
17
+ WIFI_PS_STREAM_MODE = "none"
18
+ WIFI_PS_IDLE_MODE = "min_modem"
19
+
20
+
21
+ def _clamp(value: float, lo: float, hi: float) -> float:
22
+ return max(lo, min(hi, value))
23
+
24
+
25
+ def _is_finite_number(value: Any) -> bool:
26
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
27
+ return False
28
+ try:
29
+ return math.isfinite(float(value))
30
+ except (OverflowError, ValueError):
31
+ return False
32
+
33
+
34
+ @dataclass
35
+ class FollowPoseStreamConfig:
36
+ url: str
37
+ source_filter: Optional[str] = None
38
+ frame_filter: Optional[str] = None
39
+ flip_yaw: int = 1
40
+ flip_pitch: int = 1
41
+ pitch_center_deg: int = 45
42
+ downsample_hz: float = 20.0
43
+ max_step_deg: float = 12.0
44
+ speed_dps: int = 240
45
+ smoothing_window: int = 5
46
+ seed_from_device: bool = True
47
+ reconnect_initial_backoff_s: float = 1.5
48
+ reconnect_max_backoff_s: float = 30.0
49
+
50
+ def __post_init__(self) -> None:
51
+ if (
52
+ not isinstance(self.flip_yaw, int)
53
+ or isinstance(self.flip_yaw, bool)
54
+ or self.flip_yaw not in (-1, 1)
55
+ ):
56
+ raise ValueError("flip_yaw must be -1 or 1")
57
+ if (
58
+ not isinstance(self.flip_pitch, int)
59
+ or isinstance(self.flip_pitch, bool)
60
+ or self.flip_pitch not in (-1, 1)
61
+ ):
62
+ raise ValueError("flip_pitch must be -1 or 1")
63
+ if (
64
+ not isinstance(self.smoothing_window, int)
65
+ or isinstance(self.smoothing_window, bool)
66
+ or self.smoothing_window < 1
67
+ ):
68
+ raise ValueError("smoothing_window must be an integer >= 1")
69
+ if self.downsample_hz <= 0:
70
+ raise ValueError("downsample_hz must be > 0")
71
+ if self.max_step_deg <= 0:
72
+ raise ValueError("max_step_deg must be > 0")
73
+ if self.reconnect_initial_backoff_s <= 0:
74
+ raise ValueError("reconnect_initial_backoff_s must be > 0")
75
+ if self.reconnect_max_backoff_s <= 0:
76
+ raise ValueError("reconnect_max_backoff_s must be > 0")
77
+
78
+
79
+ def map_sensor_to_servo(
80
+ sensor_yaw: float,
81
+ sensor_pitch: float,
82
+ *,
83
+ flip_yaw: int,
84
+ flip_pitch: int,
85
+ pitch_center_deg: int,
86
+ ) -> tuple[int, int]:
87
+ servo_yaw = int(
88
+ round(_clamp(sensor_yaw * flip_yaw, SERVO_YAW_MIN, SERVO_YAW_MAX))
89
+ )
90
+ servo_pitch = int(
91
+ round(
92
+ _clamp(
93
+ pitch_center_deg + sensor_pitch * flip_pitch,
94
+ SERVO_PITCH_MIN,
95
+ SERVO_PITCH_MAX,
96
+ )
97
+ )
98
+ )
99
+ return servo_yaw, servo_pitch
100
+
101
+
102
+ def step_clamp(target: float, last: float, max_step_deg: float) -> float:
103
+ return _clamp(target, last - max_step_deg, last + max_step_deg)
104
+
105
+
106
+ class FollowPoseStream:
107
+ def __init__(self, gateway: Any, cfg: FollowPoseStreamConfig) -> None:
108
+ self._gateway = gateway
109
+ self._cfg = cfg
110
+ self._stop_event = asyncio.Event()
111
+ self._task: asyncio.Task[None] | None = None
112
+ self._connect_state = "init"
113
+ self._frames_received = 0
114
+ self._frames_accepted = 0
115
+ self._commands_sent = 0
116
+ self._last_frame_ts: int | None = None
117
+ self._last_error: str | None = None
118
+ self._last_servo_yaw = 0
119
+ self._last_servo_pitch = int(
120
+ round(_clamp(cfg.pitch_center_deg, SERVO_PITCH_MIN, SERVO_PITCH_MAX))
121
+ )
122
+ self._initial_pose_seeded = False
123
+ self._last_sent_at: float | None = None
124
+ self._samples: deque[tuple[float, float]] = deque(
125
+ maxlen=cfg.smoothing_window
126
+ )
127
+ self._wifi_ps_apply_result: dict[str, Any] | None = None
128
+ self._wifi_ps_restore_result: dict[str, Any] | None = None
129
+ self._wifi_ps_previous: str | None = None
130
+
131
+ @property
132
+ def url(self) -> str:
133
+ return self._cfg.url
134
+
135
+ def status(self) -> dict[str, Any]:
136
+ running = self._task is not None and not self._task.done()
137
+ return {
138
+ "running": running,
139
+ "url": self._cfg.url,
140
+ "source_filter": self._cfg.source_filter,
141
+ "frame_filter": self._cfg.frame_filter,
142
+ "connect_state": self._connect_state,
143
+ "frames_received": self._frames_received,
144
+ "frames_accepted": self._frames_accepted,
145
+ "commands_sent": self._commands_sent,
146
+ "last_frame_ts": self._last_frame_ts,
147
+ "last_error": self._last_error,
148
+ "wifi_ps_apply_result": self._wifi_ps_apply_result,
149
+ "wifi_ps_restore_result": self._wifi_ps_restore_result,
150
+ "wifi_ps_previous": self._wifi_ps_previous,
151
+ "last_servo": {
152
+ "yaw": self._last_servo_yaw,
153
+ "pitch": self._last_servo_pitch,
154
+ },
155
+ }
156
+
157
+ async def start(self) -> None:
158
+ if self._task is not None and not self._task.done():
159
+ return
160
+ self._stop_event.clear()
161
+ self._connect_state = "init"
162
+ self._wifi_ps_previous = None
163
+ self._wifi_ps_apply_result = await self._apply_wifi_ps(WIFI_PS_STREAM_MODE)
164
+ if isinstance(self._wifi_ps_apply_result, dict):
165
+ previous = self._wifi_ps_apply_result.get("previous")
166
+ if isinstance(previous, str) and previous and previous != "unknown":
167
+ self._wifi_ps_previous = previous
168
+ self._task = asyncio.create_task(
169
+ self._run(),
170
+ name="stackchan-follow-pose-stream",
171
+ )
172
+
173
+ async def stop(self) -> None:
174
+ self._stop_event.set()
175
+ task = self._task
176
+ if task is not None and not task.done():
177
+ task.cancel()
178
+ try:
179
+ if task is not None:
180
+ try:
181
+ await task
182
+ except asyncio.CancelledError:
183
+ pass
184
+ except Exception as exc: # pragma: no cover - defensive
185
+ self._last_error = str(exc)
186
+ finally:
187
+ restore_mode = (
188
+ self._wifi_ps_previous
189
+ if self._wifi_ps_previous
190
+ and self._wifi_ps_previous != "unknown"
191
+ else WIFI_PS_IDLE_MODE
192
+ )
193
+ self._wifi_ps_restore_result = await self._apply_wifi_ps(restore_mode)
194
+ self._connect_state = "stopped"
195
+
196
+ def _invalidate_device_state(self) -> None:
197
+ """F6: forget cached device state after a transport / connect
198
+ error so the next reachable frame re-seeds from the live head
199
+ pose and re-applies WiFi PS=none. Used when the device is
200
+ suspected to have disconnected or rebooted mid-stream.
201
+ """
202
+ if self._cfg.seed_from_device:
203
+ self._initial_pose_seeded = False
204
+ if isinstance(self._wifi_ps_apply_result, dict):
205
+ self._wifi_ps_apply_result = {
206
+ **self._wifi_ps_apply_result,
207
+ "ok": False,
208
+ }
209
+
210
+ @staticmethod
211
+ def _is_device_disconnect_error(error: Any) -> bool:
212
+ """Heuristic for the gateway-side error envelope that signals
213
+ the ESP32 is not currently reachable (vs. servo-bus or
214
+ validation errors that should not invalidate the cached pose).
215
+ """
216
+ if not isinstance(error, dict):
217
+ return False
218
+ message = error.get("message")
219
+ if not isinstance(message, str):
220
+ return False
221
+ lowered = message.lower()
222
+ return (
223
+ "not connected" in lowered
224
+ or "not initialized" in lowered
225
+ or "device" in lowered and "connect" in lowered
226
+ )
227
+
228
+ async def _maybe_reapply_wifi_ps(self) -> None:
229
+ """Retry the start-time WiFi PS apply if it failed.
230
+
231
+ Used by both _run() (initial seed success path) and _consume()
232
+ (in-stream seed retry path). If the start-time apply already
233
+ succeeded, this is a no-op; otherwise the stream mode is sent
234
+ again now that the device is reachable, and the apply_result /
235
+ previous fields are refreshed.
236
+ """
237
+ if (
238
+ isinstance(self._wifi_ps_apply_result, dict)
239
+ and self._wifi_ps_apply_result.get("ok")
240
+ ):
241
+ return
242
+ self._wifi_ps_apply_result = await self._apply_wifi_ps(
243
+ WIFI_PS_STREAM_MODE
244
+ )
245
+ if isinstance(self._wifi_ps_apply_result, dict):
246
+ previous = self._wifi_ps_apply_result.get("previous")
247
+ if (
248
+ isinstance(previous, str)
249
+ and previous
250
+ and previous != "unknown"
251
+ ):
252
+ self._wifi_ps_previous = previous
253
+
254
+ async def _apply_wifi_ps(self, mode: str) -> dict[str, Any]:
255
+ """Best-effort WiFi PS toggle. Never raises."""
256
+ try:
257
+ result, error = await self._gateway.esp32.call_tool(
258
+ "self.wifi.set_power_save", {"mode": mode}
259
+ )
260
+ except Exception as exc:
261
+ return {"ok": False, "error": f"call_raised: {exc}"}
262
+ if error:
263
+ return {"ok": False, "error": str(error)}
264
+ return self._extract_wifi_ps_result(result)
265
+
266
+ @staticmethod
267
+ def _extract_wifi_ps_result(result: Any) -> dict[str, Any]:
268
+ if isinstance(result, dict):
269
+ if "ok" in result:
270
+ return {
271
+ "ok": bool(result.get("ok")),
272
+ "previous": result.get("previous"),
273
+ "current": result.get("current"),
274
+ }
275
+ payload = FollowPoseStream._decode_call_result_payload(result)
276
+ if isinstance(payload, dict):
277
+ return {
278
+ "ok": bool(payload.get("ok")),
279
+ "previous": payload.get("previous"),
280
+ "current": payload.get("current"),
281
+ }
282
+ return {"ok": False, "error": "unrecognised result shape"}
283
+
284
+ async def _run(self) -> None:
285
+ backoff = self._cfg.reconnect_initial_backoff_s
286
+ try:
287
+ while not self._stop_event.is_set():
288
+ try:
289
+ self._connect_state = "connecting"
290
+ async with websockets.connect(self._cfg.url) as ws:
291
+ self._connect_state = "connected"
292
+ backoff = self._cfg.reconnect_initial_backoff_s
293
+ if self._cfg.seed_from_device and not self._initial_pose_seeded:
294
+ seeded_now = await self._seed_from_device()
295
+ if seeded_now:
296
+ # F4: device is reachable for the first time
297
+ # in this stream; retry the WiFi PS apply if
298
+ # it failed at start time.
299
+ await self._maybe_reapply_wifi_ps()
300
+ await self._consume(ws)
301
+ except asyncio.CancelledError:
302
+ raise
303
+ except Exception as exc:
304
+ if self._stop_event.is_set():
305
+ break
306
+ self._last_error = str(exc)
307
+ self._connect_state = "reconnecting"
308
+
309
+ if self._stop_event.is_set():
310
+ break
311
+
312
+ self._connect_state = "reconnecting"
313
+ try:
314
+ await asyncio.wait_for(
315
+ self._stop_event.wait(),
316
+ timeout=backoff,
317
+ )
318
+ except asyncio.TimeoutError:
319
+ pass
320
+ backoff = min(backoff * 2, self._cfg.reconnect_max_backoff_s)
321
+ finally:
322
+ self._connect_state = "stopped"
323
+
324
+ async def _seed_from_device(self) -> bool:
325
+ try:
326
+ result, error = await self._gateway.esp32.call_tool(
327
+ "self.robot.get_head_angles",
328
+ {},
329
+ )
330
+ except Exception as exc:
331
+ self._last_error = str(exc)
332
+ return False
333
+
334
+ if error:
335
+ if isinstance(error, dict):
336
+ self._last_error = str(error.get("message", error))
337
+ else:
338
+ self._last_error = str(error)
339
+ return False
340
+
341
+ if not isinstance(result, dict):
342
+ return False
343
+
344
+ if result.get("isError"):
345
+ self._last_error = "self.robot.get_head_angles returned isError"
346
+ return False
347
+
348
+ angles = self._extract_head_angles(result)
349
+ if angles is None:
350
+ return False
351
+
352
+ yaw, pitch = angles
353
+ if not (_is_finite_number(yaw) and _is_finite_number(pitch)):
354
+ return False
355
+ self._last_servo_yaw = int(
356
+ round(_clamp(float(yaw), SERVO_YAW_MIN, SERVO_YAW_MAX))
357
+ )
358
+ self._last_servo_pitch = int(
359
+ round(_clamp(float(pitch), SERVO_PITCH_MIN, SERVO_PITCH_MAX))
360
+ )
361
+ self._initial_pose_seeded = True
362
+ return True
363
+
364
+ @staticmethod
365
+ def _decode_call_result_payload(result: dict[str, Any]) -> Optional[dict[str, Any]]:
366
+ """Decode a firmware CallToolResult or return a plain dict fallback."""
367
+ if "content" not in result:
368
+ return result
369
+ content = result.get("content")
370
+ if not isinstance(content, list) or not content:
371
+ return None
372
+ first = content[0]
373
+ if not isinstance(first, dict):
374
+ return None
375
+ text = first.get("text")
376
+ if not isinstance(text, str):
377
+ return None
378
+ try:
379
+ payload = json.loads(text)
380
+ except (TypeError, ValueError):
381
+ return None
382
+ if not isinstance(payload, dict):
383
+ return None
384
+ return payload
385
+
386
+ @staticmethod
387
+ def _extract_head_angles(result: Any) -> Optional[tuple[Any, Any]]:
388
+ """Extract yaw/pitch from a get_head_angles CallToolResult.
389
+
390
+ The MCP transport wraps tool replies as
391
+ ``{"content": [{"text": "<json>"}], "isError": ...}`` where the
392
+ text is a JSON-encoded payload from the firmware side. Also
393
+ tolerate a plain ``{"yaw": ..., "pitch": ...}`` dict so compact
394
+ test stubs keep working. Returns ``None`` if the shape is not
395
+ recognised so the caller keeps the seed defaults.
396
+ """
397
+ if not isinstance(result, dict):
398
+ return None
399
+ if "yaw" in result or "pitch" in result:
400
+ return result.get("yaw"), result.get("pitch")
401
+
402
+ payload = FollowPoseStream._decode_call_result_payload(result)
403
+ if payload is None or ("yaw" not in payload and "pitch" not in payload):
404
+ return None
405
+ return payload.get("yaw"), payload.get("pitch")
406
+
407
+ async def _consume(self, ws: Any) -> None:
408
+ async for msg in ws:
409
+ if self._stop_event.is_set():
410
+ break
411
+ self._frames_received += 1
412
+ try:
413
+ frame = json.loads(msg)
414
+ except (TypeError, ValueError):
415
+ continue
416
+ if not isinstance(frame, dict):
417
+ continue
418
+ if (
419
+ self._cfg.source_filter is not None
420
+ and frame.get("source") != self._cfg.source_filter
421
+ ):
422
+ continue
423
+ if (
424
+ self._cfg.frame_filter is not None
425
+ and frame.get("frame") != self._cfg.frame_filter
426
+ ):
427
+ continue
428
+
429
+ yaw = frame.get("yaw")
430
+ pitch = frame.get("pitch")
431
+ if not _is_finite_number(yaw) or not _is_finite_number(pitch):
432
+ continue
433
+
434
+ self._frames_accepted += 1
435
+ ts = frame.get("ts")
436
+ if _is_finite_number(ts):
437
+ self._last_frame_ts = int(ts)
438
+
439
+ if self._cfg.seed_from_device and not self._initial_pose_seeded:
440
+ seeded = await self._seed_from_device()
441
+ if not seeded:
442
+ continue
443
+ # F3 fix: if the start-time WiFi PS apply failed (e.g.
444
+ # ESP32 was disconnected at start), retry it now that
445
+ # the device is reachable. Otherwise the ~800 ms DTIM
446
+ # send-jitter this tool exists to avoid would persist
447
+ # for the rest of the stream. F4 mirrors the same gate
448
+ # on the _run() initial-seed success path.
449
+ await self._maybe_reapply_wifi_ps()
450
+
451
+ self._samples.append((float(yaw), float(pitch)))
452
+ loop = asyncio.get_running_loop()
453
+ now = loop.time()
454
+ if (
455
+ self._last_sent_at is not None
456
+ and now - self._last_sent_at < 1.0 / self._cfg.downsample_hz
457
+ ):
458
+ continue
459
+
460
+ avg_yaw = sum(sample[0] for sample in self._samples) / len(
461
+ self._samples
462
+ )
463
+ avg_pitch = sum(sample[1] for sample in self._samples) / len(
464
+ self._samples
465
+ )
466
+ target_yaw, target_pitch = map_sensor_to_servo(
467
+ avg_yaw,
468
+ avg_pitch,
469
+ flip_yaw=self._cfg.flip_yaw,
470
+ flip_pitch=self._cfg.flip_pitch,
471
+ pitch_center_deg=self._cfg.pitch_center_deg,
472
+ )
473
+ stepped_yaw = step_clamp(
474
+ target_yaw,
475
+ self._last_servo_yaw,
476
+ self._cfg.max_step_deg,
477
+ )
478
+ stepped_pitch = step_clamp(
479
+ target_pitch,
480
+ self._last_servo_pitch,
481
+ self._cfg.max_step_deg,
482
+ )
483
+ servo_yaw = int(
484
+ round(_clamp(stepped_yaw, SERVO_YAW_MIN, SERVO_YAW_MAX))
485
+ )
486
+ servo_pitch = int(
487
+ round(_clamp(stepped_pitch, SERVO_PITCH_MIN, SERVO_PITCH_MAX))
488
+ )
489
+ speed_dps = int(_clamp(self._cfg.speed_dps, 1, SERVO_MAX_SPEED_DPS))
490
+
491
+ try:
492
+ result, error = await self._gateway.esp32.call_tool(
493
+ "self.robot.set_head_angles",
494
+ {
495
+ "yaw": servo_yaw,
496
+ "pitch": servo_pitch,
497
+ "speed_dps": speed_dps,
498
+ },
499
+ )
500
+ except Exception as exc:
501
+ self._last_error = str(exc)
502
+ # F6: device transport raised — assume the ESP32 is no
503
+ # longer reachable. Invalidate seed + WiFi PS state so
504
+ # the next frame that lands re-seeds from the device
505
+ # and reapplies WiFi PS to "none".
506
+ self._invalidate_device_state()
507
+ continue
508
+
509
+ if error:
510
+ if isinstance(error, dict):
511
+ self._last_error = str(error.get("message", error))
512
+ else:
513
+ self._last_error = str(error)
514
+ # F6: device-connect errors mean the cached seed and
515
+ # WiFi PS state are stale relative to whatever the
516
+ # device booted into; reset both so the next reachable
517
+ # frame re-seeds before issuing a swing-prone command.
518
+ if self._is_device_disconnect_error(error):
519
+ self._invalidate_device_state()
520
+ continue
521
+
522
+ if isinstance(result, dict):
523
+ if result.get("isError"):
524
+ self._last_error = "set_head_angles reported isError"
525
+ continue
526
+ payload = self._decode_call_result_payload(result)
527
+ if isinstance(payload, dict):
528
+ if payload.get("isError"):
529
+ self._last_error = (
530
+ "set_head_angles payload reported isError"
531
+ )
532
+ continue
533
+ if payload.get("ok") is False:
534
+ self._last_error = "set_head_angles payload reported ok=false"
535
+ continue
536
+ if payload.get("servo_init_ok") is False:
537
+ self._last_error = (
538
+ "set_head_angles payload reported "
539
+ "servo_init_ok=false"
540
+ )
541
+ continue
542
+ if payload.get("servo_ok") is False:
543
+ self._last_error = (
544
+ "set_head_angles payload reported servo_ok=false"
545
+ )
546
+ continue
547
+
548
+ self._last_sent_at = now
549
+ self._last_servo_yaw = servo_yaw
550
+ self._last_servo_pitch = servo_pitch
551
+ self._commands_sent += 1
552
+
553
+
554
+ _follower: Optional[FollowPoseStream] = None
555
+ # F5: serialise start_follow / stop_follow so an interleaving stop
556
+ # cannot clear _follower while start is mid-await, leaving start's
557
+ # final status() call to dereference None. get_follow_status() is
558
+ # intentionally lock-free (it snapshots _follower into a local).
559
+ _follower_lock: Optional[asyncio.Lock] = None
560
+
561
+
562
+ def _get_follower_lock() -> asyncio.Lock:
563
+ global _follower_lock
564
+ if _follower_lock is None:
565
+ _follower_lock = asyncio.Lock()
566
+ return _follower_lock
567
+
568
+
569
+ async def start_follow(gateway: Any, cfg: FollowPoseStreamConfig) -> dict[str, Any]:
570
+ """Cancel previous follower if running, then start a new one.
571
+
572
+ Returns the initial status snapshot.
573
+ """
574
+ global _follower
575
+ async with _get_follower_lock():
576
+ if _follower is not None:
577
+ await _follower.stop()
578
+ follower = FollowPoseStream(gateway, cfg)
579
+ _follower = follower
580
+ await follower.start()
581
+ # Use the local `follower` reference rather than _follower so a
582
+ # racing stop_follow() that cleared _follower cannot make this
583
+ # final status() dereference None.
584
+ return follower.status()
585
+
586
+
587
+ async def stop_follow() -> dict[str, Any]:
588
+ """Cancel and clear the singleton; return the final status."""
589
+ global _follower
590
+ async with _get_follower_lock():
591
+ if _follower is None:
592
+ return {"running": False}
593
+ follower = _follower
594
+ await follower.stop()
595
+ status = follower.status()
596
+ status["running"] = False
597
+ _follower = None
598
+ return status
599
+
600
+
601
+ def get_follow_status() -> dict[str, Any]:
602
+ """Snapshot; returns {"running": False} if no follower is registered."""
603
+ follower = _follower
604
+ if follower is None:
605
+ return {"running": False}
606
+ return follower.status()
@@ -176,6 +176,15 @@ class Gateway:
176
176
 
177
177
  async def stop(self) -> None:
178
178
  """Stop the gateway."""
179
+ # Cancel any active pose-stream follower before the rest of the
180
+ # shutdown sequence closes gateway-side services.
181
+ try:
182
+ from .follow_pose_stream import stop_follow
183
+
184
+ await stop_follow()
185
+ except Exception as exc: # pragma: no cover - defensive
186
+ logger.warning("follow_pose_stream shutdown failed: %s", exc)
187
+
179
188
  self._running = False
180
189
  if self._mdns_advertiser:
181
190
  try: