stackchan-mcp 0.11.0__tar.gz → 0.12.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.11.0 → stackchan_mcp-0.12.0}/PKG-INFO +1 -1
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/pyproject.toml +1 -1
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/esp32_client.py +2 -0
- stackchan_mcp-0.12.0/stackchan_mcp/follow_pose_stream.py +606 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/gateway.py +9 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/http_server.py +3 -1
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/stdio_server.py +218 -0
- stackchan_mcp-0.12.0/tests/test_follow_pose_stream.py +1131 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_http_server.py +6 -1
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/uv.lock +1 -1
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/.env.example +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/.gitignore +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/AGENTS.md +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/LICENSE +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/LICENSE-THIRD-PARTY +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/README.md +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/__init__.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/__main__.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/audio_input_hook.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/audio_stream.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/capture_server.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/cli.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/event_log.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/handlers/__init__.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/handlers/audio.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/handlers/camera.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/handlers/robot.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/mcp_router.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/mdns_advertiser.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/notify.example.yml +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/notify_config.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/ownership.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/protocol.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/queue.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/server.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/stt/__init__.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/stt/audio_utils.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/stt/base.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/stt/orchestrator.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/tools.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/tts/__init__.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/tts/audio_utils.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/tts/base.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/tts/emoji_expression.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/tts/irodori.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/tts/orchestrator.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/stackchan_mcp/tts/voicevox.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/_audio_fixtures.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/conftest.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_audio_input_hook.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_audio_stream.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_audio_utils.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_capture_server.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_cli.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_emoji_expression.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_esp32_client.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_event_dispatch.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_event_log.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_gateway.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_irodori.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_mcp_router.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_mdns_advertiser.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_notify_config.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_orchestrator.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_ownership.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_protocol.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_send_pcm_audio.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_send_pcm_stream.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_stackchan_event.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_stdio_server.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_stt_audio_utils.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_stt_framework.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_stt_orchestrator.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.0}/tests/test_tts_framework.py +0 -0
- {stackchan_mcp-0.11.0 → stackchan_mcp-0.12.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.12.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
|
|
@@ -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:
|
|
@@ -27,7 +27,9 @@ from .notify_config import NotifyConfig
|
|
|
27
27
|
from .queue import CommandQueue, QueueFull, QueueItem, build_queue_full_error
|
|
28
28
|
from .stdio_server import _dispatch_mcp_tool, create_server
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
# Follower lifecycle operations must remain callable when the ESP32 is
|
|
31
|
+
# disconnected so HTTP clients can supervise the background task.
|
|
32
|
+
BYPASS_TOOLS = frozenset({"get_status", "stackchan_follow_pose_stream"})
|
|
31
33
|
MCP_HTTP_ALLOWED_HOSTS_ENV = "MCP_HTTP_ALLOWED_HOSTS"
|
|
32
34
|
AUTH_FAILURE_MESSAGE = "Unauthorized: missing or invalid bearer token"
|
|
33
35
|
HOST_FAILURE_MESSAGE = "Forbidden: invalid Host header"
|