stackchan-mcp 0.1.0__py3-none-any.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 +7 -0
- stackchan_mcp/__main__.py +12 -0
- stackchan_mcp/audio_stream.py +34 -0
- stackchan_mcp/capture_server.py +91 -0
- stackchan_mcp/cli.py +57 -0
- stackchan_mcp/esp32_client.py +340 -0
- stackchan_mcp/gateway.py +123 -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/mcp_router.py +126 -0
- stackchan_mcp/protocol.py +95 -0
- stackchan_mcp/server.py +28 -0
- stackchan_mcp/stdio_server.py +344 -0
- stackchan_mcp/tools.py +82 -0
- stackchan_mcp-0.1.0.dist-info/METADATA +238 -0
- stackchan_mcp-0.1.0.dist-info/RECORD +21 -0
- stackchan_mcp-0.1.0.dist-info/WHEEL +4 -0
- stackchan_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- stackchan_mcp-0.1.0.dist-info/licenses/LICENSE +39 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Entry point: ``python -m stackchan_mcp``.
|
|
2
|
+
|
|
3
|
+
The actual implementation lives in :mod:`stackchan_mcp.cli` so that the
|
|
4
|
+
console script and ``python -m`` paths share a single side-effect-free
|
|
5
|
+
import surface.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .cli import main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
main()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Opus audio frame handling — skeleton for Phase 4 (planned).
|
|
2
|
+
|
|
3
|
+
This module will handle:
|
|
4
|
+
- Incoming Opus frames from the device (STT pipeline)
|
|
5
|
+
- Outgoing Opus frames to the device (TTS pipeline)
|
|
6
|
+
|
|
7
|
+
For now, binary frames are logged and discarded.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def handle_audio_frame(data: bytes, session_id: str) -> None:
|
|
18
|
+
"""Process an incoming binary Opus frame (stub).
|
|
19
|
+
|
|
20
|
+
Phase 4 will pipe this into an STT engine.
|
|
21
|
+
"""
|
|
22
|
+
logger.debug(
|
|
23
|
+
"audio_frame session=%s bytes=%d (discarded — Phase 4)",
|
|
24
|
+
session_id,
|
|
25
|
+
len(data),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def send_audio_frame(data: bytes) -> bytes:
|
|
30
|
+
"""Prepare an outgoing Opus frame (stub).
|
|
31
|
+
|
|
32
|
+
Phase 4 will generate this from a TTS engine.
|
|
33
|
+
"""
|
|
34
|
+
return data
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""HTTP capture server for receiving photos from ESP32.
|
|
2
|
+
|
|
3
|
+
ESP32's camera.Explain() POSTs multipart/form-data with:
|
|
4
|
+
- field 'question' (text)
|
|
5
|
+
- field 'file' (camera.jpg, JPEG image)
|
|
6
|
+
|
|
7
|
+
This server saves the JPEG and returns the file path so MCP client
|
|
8
|
+
can view the image via the Read tool.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from aiohttp import web
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
CAPTURE_DIR = os.path.expanduser("~/.stackchan/captures")
|
|
23
|
+
CAPTURE_TOKEN_KEY = web.AppKey("capture_token", str)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_authorized(auth_header: str, expected_token: str) -> bool:
|
|
27
|
+
"""Return whether the bearer auth header matches the expected token."""
|
|
28
|
+
return auth_header == f"Bearer {expected_token}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def handle_capture(request: web.Request) -> web.Response:
|
|
32
|
+
"""Handle photo upload from ESP32."""
|
|
33
|
+
expected_token = request.app[CAPTURE_TOKEN_KEY]
|
|
34
|
+
if expected_token and not _is_authorized(
|
|
35
|
+
request.headers.get("Authorization", ""), expected_token
|
|
36
|
+
):
|
|
37
|
+
logger.warning("Capture upload auth rejected")
|
|
38
|
+
return web.Response(
|
|
39
|
+
text='{"error": "Unauthorized"}',
|
|
40
|
+
status=401,
|
|
41
|
+
content_type="application/json",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
os.makedirs(CAPTURE_DIR, exist_ok=True)
|
|
45
|
+
|
|
46
|
+
reader = await request.multipart()
|
|
47
|
+
question = ""
|
|
48
|
+
image_path = ""
|
|
49
|
+
|
|
50
|
+
async for part in reader:
|
|
51
|
+
if part.name == "question":
|
|
52
|
+
question = (await part.read()).decode("utf-8")
|
|
53
|
+
elif part.name == "file":
|
|
54
|
+
timestamp = int(time.time() * 1000)
|
|
55
|
+
filename = f"capture_{timestamp}.jpg"
|
|
56
|
+
image_path = os.path.join(CAPTURE_DIR, filename)
|
|
57
|
+
with open(image_path, "wb") as f:
|
|
58
|
+
while True:
|
|
59
|
+
chunk = await part.read_chunk(8192)
|
|
60
|
+
if not chunk:
|
|
61
|
+
break
|
|
62
|
+
f.write(chunk)
|
|
63
|
+
|
|
64
|
+
if image_path and os.path.exists(image_path):
|
|
65
|
+
file_size = os.path.getsize(image_path)
|
|
66
|
+
logger.info(
|
|
67
|
+
"Captured photo: %s (%d bytes), question: %s",
|
|
68
|
+
image_path,
|
|
69
|
+
file_size,
|
|
70
|
+
question,
|
|
71
|
+
)
|
|
72
|
+
result = json.dumps({
|
|
73
|
+
"image_path": image_path,
|
|
74
|
+
"size_bytes": file_size,
|
|
75
|
+
"question": question,
|
|
76
|
+
})
|
|
77
|
+
return web.Response(text=result, content_type="application/json")
|
|
78
|
+
|
|
79
|
+
return web.Response(
|
|
80
|
+
text='{"error": "No image received"}',
|
|
81
|
+
status=400,
|
|
82
|
+
content_type="application/json",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_capture_app(capture_token: str = "") -> web.Application:
|
|
87
|
+
"""Create the HTTP capture application."""
|
|
88
|
+
app = web.Application()
|
|
89
|
+
app[CAPTURE_TOKEN_KEY] = capture_token
|
|
90
|
+
app.router.add_post("/capture", handle_capture)
|
|
91
|
+
return app
|
stackchan_mcp/cli.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Console entry point for stackchan-mcp.
|
|
2
|
+
|
|
3
|
+
This module exists so that `import stackchan_mcp` (or any of its
|
|
4
|
+
submodules) does not trigger import-time side effects like
|
|
5
|
+
`load_dotenv()` or logging configuration. All such side effects live
|
|
6
|
+
inside :func:`main`, which is registered as the `stackchan-mcp`
|
|
7
|
+
console script in ``pyproject.toml`` and is also re-exported through
|
|
8
|
+
``stackchan_mcp.__main__`` so that ``python -m stackchan_mcp`` keeps
|
|
9
|
+
working.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _run() -> None:
|
|
21
|
+
"""Start both the ESP32 WebSocket server and the stdio MCP server."""
|
|
22
|
+
from .gateway import get_gateway
|
|
23
|
+
from .stdio_server import run_stdio_server
|
|
24
|
+
|
|
25
|
+
gateway = get_gateway()
|
|
26
|
+
|
|
27
|
+
await gateway.start()
|
|
28
|
+
logger.info("Gateway started, waiting for ESP32 connections...")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
# Run stdio MCP server (blocks until MCP client disconnects)
|
|
32
|
+
await run_stdio_server()
|
|
33
|
+
finally:
|
|
34
|
+
await gateway.stop()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main() -> None:
|
|
38
|
+
"""Console-script entry point.
|
|
39
|
+
|
|
40
|
+
Loads ``.env``, configures logging, and starts the gateway. Side
|
|
41
|
+
effects are intentionally scoped to this function so that
|
|
42
|
+
``import stackchan_mcp`` stays clean.
|
|
43
|
+
"""
|
|
44
|
+
from dotenv import load_dotenv
|
|
45
|
+
|
|
46
|
+
load_dotenv()
|
|
47
|
+
|
|
48
|
+
logging.basicConfig(
|
|
49
|
+
level=logging.INFO,
|
|
50
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
asyncio.run(_run())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""ESP32 connection manager.
|
|
2
|
+
|
|
3
|
+
Acts as a WebSocket server that ESP32 connects TO,
|
|
4
|
+
and as an MCP client that sends commands TO the ESP32.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import websockets
|
|
17
|
+
from websockets.asyncio.server import ServerConnection
|
|
18
|
+
|
|
19
|
+
from .protocol import HelloResponse, make_mcp_message, parse_jsonrpc_response
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Timeout for waiting for ESP32 responses
|
|
24
|
+
RESPONSE_TIMEOUT = 10.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ESP32Connection:
|
|
28
|
+
"""Manages a single ESP32 device connection."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, ws: ServerConnection, session_id: str):
|
|
31
|
+
self._ws = ws
|
|
32
|
+
self.session_id = session_id
|
|
33
|
+
self.device_id: str = "unknown"
|
|
34
|
+
self.tools: list[dict[str, Any]] = []
|
|
35
|
+
self._request_id = 0
|
|
36
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
37
|
+
self._connected = True
|
|
38
|
+
self._initialized = False
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def connected(self) -> bool:
|
|
42
|
+
return self._connected
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def initialized(self) -> bool:
|
|
46
|
+
return self._initialized
|
|
47
|
+
|
|
48
|
+
def _next_id(self) -> int:
|
|
49
|
+
self._request_id += 1
|
|
50
|
+
return self._request_id
|
|
51
|
+
|
|
52
|
+
async def send_mcp_request(
|
|
53
|
+
self, method: str, params: dict[str, Any]
|
|
54
|
+
) -> tuple[Any, dict[str, Any] | None]:
|
|
55
|
+
"""Send an MCP request to ESP32 and wait for response.
|
|
56
|
+
|
|
57
|
+
Returns (result, error).
|
|
58
|
+
"""
|
|
59
|
+
if not self._connected:
|
|
60
|
+
return None, {"code": -32000, "message": "ESP32 not connected"}
|
|
61
|
+
|
|
62
|
+
req_id = self._next_id()
|
|
63
|
+
message = make_mcp_message(self.session_id, method, params, req_id)
|
|
64
|
+
|
|
65
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
|
|
66
|
+
self._pending[req_id] = future
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
await self._ws.send(json.dumps(message))
|
|
70
|
+
response = await asyncio.wait_for(future, timeout=RESPONSE_TIMEOUT)
|
|
71
|
+
return parse_jsonrpc_response(response)
|
|
72
|
+
except asyncio.TimeoutError:
|
|
73
|
+
self._pending.pop(req_id, None)
|
|
74
|
+
return None, {"code": -32000, "message": f"Timeout waiting for ESP32 response (method={method})"}
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
self._pending.pop(req_id, None)
|
|
77
|
+
return None, {"code": -32000, "message": f"ESP32 communication error: {exc}"}
|
|
78
|
+
|
|
79
|
+
async def initialize(self, vision_url: str = "", vision_token: str = "") -> bool:
|
|
80
|
+
"""Send MCP initialize to ESP32."""
|
|
81
|
+
capabilities: dict[str, Any] = {}
|
|
82
|
+
if vision_url:
|
|
83
|
+
vision: dict[str, Any] = {"url": vision_url}
|
|
84
|
+
if vision_token:
|
|
85
|
+
vision["token"] = vision_token
|
|
86
|
+
capabilities["vision"] = vision
|
|
87
|
+
result, error = await self.send_mcp_request("initialize", {"capabilities": capabilities})
|
|
88
|
+
if error:
|
|
89
|
+
logger.error("ESP32 initialize failed: %s", error)
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
logger.info(
|
|
93
|
+
"ESP32 initialized: protocol=%s server=%s",
|
|
94
|
+
result.get("protocolVersion", "?"),
|
|
95
|
+
result.get("serverInfo", {}),
|
|
96
|
+
)
|
|
97
|
+
self._initialized = True
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
async def discover_tools(self) -> list[dict[str, Any]]:
|
|
101
|
+
"""Discover tools available on ESP32."""
|
|
102
|
+
all_tools: list[dict[str, Any]] = []
|
|
103
|
+
cursor = ""
|
|
104
|
+
|
|
105
|
+
while True:
|
|
106
|
+
params: dict[str, Any] = {"cursor": cursor}
|
|
107
|
+
result, error = await self.send_mcp_request("tools/list", params)
|
|
108
|
+
|
|
109
|
+
if error:
|
|
110
|
+
logger.error("tools/list failed: %s", error)
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
tools = result.get("tools", [])
|
|
114
|
+
all_tools.extend(tools)
|
|
115
|
+
|
|
116
|
+
next_cursor = result.get("nextCursor", "")
|
|
117
|
+
if not next_cursor:
|
|
118
|
+
break
|
|
119
|
+
cursor = next_cursor
|
|
120
|
+
|
|
121
|
+
self.tools = all_tools
|
|
122
|
+
logger.info("Discovered %d tools on ESP32", len(all_tools))
|
|
123
|
+
return all_tools
|
|
124
|
+
|
|
125
|
+
async def call_tool(
|
|
126
|
+
self, name: str, arguments: dict[str, Any]
|
|
127
|
+
) -> tuple[Any, dict[str, Any] | None]:
|
|
128
|
+
"""Call a tool on ESP32."""
|
|
129
|
+
return await self.send_mcp_request(
|
|
130
|
+
"tools/call", {"name": name, "arguments": arguments}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def handle_response(self, payload: dict[str, Any]) -> None:
|
|
134
|
+
"""Handle an incoming MCP response from ESP32."""
|
|
135
|
+
req_id = payload.get("id")
|
|
136
|
+
if req_id is not None and req_id in self._pending:
|
|
137
|
+
future = self._pending.pop(req_id)
|
|
138
|
+
if not future.done():
|
|
139
|
+
future.set_result(payload)
|
|
140
|
+
else:
|
|
141
|
+
# Notification (no id) — log and discard for now
|
|
142
|
+
method = payload.get("method", "")
|
|
143
|
+
logger.info("ESP32 notification: %s", method)
|
|
144
|
+
|
|
145
|
+
def disconnect(self) -> None:
|
|
146
|
+
"""Mark connection as disconnected."""
|
|
147
|
+
self._connected = False
|
|
148
|
+
self._initialized = False
|
|
149
|
+
# Cancel all pending futures
|
|
150
|
+
for future in self._pending.values():
|
|
151
|
+
if not future.done():
|
|
152
|
+
future.set_exception(ConnectionError("ESP32 disconnected"))
|
|
153
|
+
self._pending.clear()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ESP32Manager:
|
|
157
|
+
"""Manages ESP32 device connections.
|
|
158
|
+
|
|
159
|
+
Runs a WebSocket server that ESP32 devices connect to.
|
|
160
|
+
Currently supports a single device connection.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self):
|
|
164
|
+
self._connection: ESP32Connection | None = None
|
|
165
|
+
self._server: Any = None
|
|
166
|
+
self._lock = asyncio.Lock()
|
|
167
|
+
self._init_tasks: list[asyncio.Task] = []
|
|
168
|
+
self._vision_url: str = ""
|
|
169
|
+
self._vision_token: str = ""
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def device_connected(self) -> bool:
|
|
173
|
+
return self._connection is not None and self._connection.connected
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def connection(self) -> ESP32Connection | None:
|
|
177
|
+
return self._connection
|
|
178
|
+
|
|
179
|
+
async def start(
|
|
180
|
+
self,
|
|
181
|
+
host: str = "0.0.0.0",
|
|
182
|
+
port: int = 8765,
|
|
183
|
+
vision_url: str = "",
|
|
184
|
+
vision_token: str = "",
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Start the WebSocket server for ESP32 connections."""
|
|
187
|
+
self._vision_url = vision_url
|
|
188
|
+
self._vision_token = vision_token
|
|
189
|
+
logger.info("ESP32 WebSocket server starting on ws://%s:%d", host, port)
|
|
190
|
+
self._server = await websockets.serve(
|
|
191
|
+
self._handler,
|
|
192
|
+
host,
|
|
193
|
+
port,
|
|
194
|
+
process_request=self._check_auth,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
async def stop(self) -> None:
|
|
198
|
+
"""Stop the WebSocket server."""
|
|
199
|
+
# Cancel any pending initialization tasks
|
|
200
|
+
for task in self._init_tasks:
|
|
201
|
+
task.cancel()
|
|
202
|
+
self._init_tasks.clear()
|
|
203
|
+
|
|
204
|
+
if self._server:
|
|
205
|
+
self._server.close()
|
|
206
|
+
await self._server.wait_closed()
|
|
207
|
+
self._server = None
|
|
208
|
+
|
|
209
|
+
def _check_auth(
|
|
210
|
+
self, connection: ServerConnection, request: websockets.http11.Request
|
|
211
|
+
) -> None | websockets.http11.Response:
|
|
212
|
+
"""Validate Bearer token.
|
|
213
|
+
|
|
214
|
+
websockets 16+ passes (connection, request) to process_request.
|
|
215
|
+
"""
|
|
216
|
+
expected = os.getenv("STACKCHAN_TOKEN") or os.getenv("BEARER_TOKEN")
|
|
217
|
+
if not expected:
|
|
218
|
+
logger.warning("STACKCHAN_TOKEN not set — accepting all connections")
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
auth = request.headers.get("Authorization", "")
|
|
222
|
+
if auth == f"Bearer {expected}":
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
logger.warning("ESP32 auth rejected")
|
|
226
|
+
return websockets.http11.Response(
|
|
227
|
+
401, "Unauthorized", websockets.datastructures.Headers()
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
async def _handler(self, ws: ServerConnection) -> None:
|
|
231
|
+
"""Handle an incoming ESP32 WebSocket connection.
|
|
232
|
+
|
|
233
|
+
Architecture: the message read loop runs continuously, dispatching
|
|
234
|
+
MCP responses to pending futures. Initialization (initialize + tools/list)
|
|
235
|
+
runs as a separate task so it doesn't block the read loop.
|
|
236
|
+
"""
|
|
237
|
+
session_id = str(uuid.uuid4())
|
|
238
|
+
device_id = (
|
|
239
|
+
ws.request.headers.get("Device-Id", "unknown") if ws.request else "unknown"
|
|
240
|
+
)
|
|
241
|
+
logger.info("ESP32 connecting: device=%s", device_id)
|
|
242
|
+
|
|
243
|
+
connection = ESP32Connection(ws, session_id)
|
|
244
|
+
connection.device_id = device_id
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
async for message in ws:
|
|
248
|
+
if isinstance(message, bytes):
|
|
249
|
+
# Binary = audio frame, ignore for now
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
data = json.loads(message)
|
|
254
|
+
except json.JSONDecodeError:
|
|
255
|
+
logger.warning("Invalid JSON from ESP32: %s", str(message)[:100])
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
msg_type = data.get("type", "")
|
|
259
|
+
|
|
260
|
+
if msg_type == "hello":
|
|
261
|
+
# ESP32 hello handshake
|
|
262
|
+
features = data.get("features", {})
|
|
263
|
+
if not features.get("mcp"):
|
|
264
|
+
logger.warning("ESP32 does not support MCP, rejecting")
|
|
265
|
+
await ws.close()
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
# Send hello response
|
|
269
|
+
resp = HelloResponse(session_id=session_id)
|
|
270
|
+
await ws.send(resp.model_dump_json())
|
|
271
|
+
|
|
272
|
+
# Register connection
|
|
273
|
+
async with self._lock:
|
|
274
|
+
if self._connection and self._connection.connected:
|
|
275
|
+
logger.warning("Replacing existing ESP32 connection")
|
|
276
|
+
self._connection.disconnect()
|
|
277
|
+
self._connection = connection
|
|
278
|
+
|
|
279
|
+
# Start initialization as a separate task so the read loop
|
|
280
|
+
# continues to pump messages (responses to initialize/tools_list)
|
|
281
|
+
task = asyncio.create_task(self._init_device(connection, device_id))
|
|
282
|
+
self._init_tasks.append(task)
|
|
283
|
+
task.add_done_callback(lambda t: self._init_tasks.remove(t) if t in self._init_tasks else None)
|
|
284
|
+
|
|
285
|
+
elif msg_type == "mcp":
|
|
286
|
+
# MCP response from ESP32
|
|
287
|
+
payload = data.get("payload", {})
|
|
288
|
+
connection.handle_response(payload)
|
|
289
|
+
|
|
290
|
+
else:
|
|
291
|
+
logger.debug("ESP32 message type=%s (ignored)", msg_type)
|
|
292
|
+
|
|
293
|
+
except websockets.exceptions.ConnectionClosed:
|
|
294
|
+
logger.info("ESP32 disconnected: device=%s", device_id)
|
|
295
|
+
finally:
|
|
296
|
+
connection.disconnect()
|
|
297
|
+
async with self._lock:
|
|
298
|
+
if self._connection is connection:
|
|
299
|
+
self._connection = None
|
|
300
|
+
|
|
301
|
+
async def _init_device(self, connection: ESP32Connection, device_id: str) -> None:
|
|
302
|
+
"""Initialize MCP session with a newly connected device."""
|
|
303
|
+
if await connection.initialize(
|
|
304
|
+
vision_url=self._vision_url,
|
|
305
|
+
vision_token=self._vision_token,
|
|
306
|
+
):
|
|
307
|
+
await connection.discover_tools()
|
|
308
|
+
logger.info(
|
|
309
|
+
"ESP32 ready: device=%s tools=%d",
|
|
310
|
+
device_id,
|
|
311
|
+
len(connection.tools),
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
logger.error("ESP32 MCP initialization failed")
|
|
315
|
+
|
|
316
|
+
async def call_tool(
|
|
317
|
+
self, name: str, arguments: dict[str, Any]
|
|
318
|
+
) -> tuple[Any, dict[str, Any] | None]:
|
|
319
|
+
"""Call a tool on the connected ESP32 device."""
|
|
320
|
+
if not self._connection or not self._connection.connected:
|
|
321
|
+
return None, {"code": -32000, "message": "No ESP32 device connected"}
|
|
322
|
+
if not self._connection.initialized:
|
|
323
|
+
return None, {"code": -32000, "message": "ESP32 not initialized"}
|
|
324
|
+
return await self._connection.call_tool(name, arguments)
|
|
325
|
+
|
|
326
|
+
def get_status(self) -> dict[str, Any]:
|
|
327
|
+
"""Get current connection status."""
|
|
328
|
+
if not self._connection or not self._connection.connected:
|
|
329
|
+
return {
|
|
330
|
+
"connected": False,
|
|
331
|
+
"device_id": None,
|
|
332
|
+
"tools_count": 0,
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
"connected": True,
|
|
336
|
+
"device_id": self._connection.device_id,
|
|
337
|
+
"initialized": self._connection.initialized,
|
|
338
|
+
"tools_count": len(self._connection.tools),
|
|
339
|
+
"tools": [t.get("name", "") for t in self._connection.tools],
|
|
340
|
+
}
|
stackchan_mcp/gateway.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
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
|
|
16
|
+
from .esp32_client import ESP32Manager
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Gateway:
|
|
22
|
+
"""Main gateway orchestrator.
|
|
23
|
+
|
|
24
|
+
Holds the ESP32 manager and provides the bridge between
|
|
25
|
+
the stdio MCP server (MCP client side) and the ESP32 device.
|
|
26
|
+
|
|
27
|
+
Also runs an HTTP capture server for receiving photos from ESP32.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self.esp32 = ESP32Manager()
|
|
32
|
+
self._running = False
|
|
33
|
+
self._http_runner: web.AppRunner | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def vision_url(self) -> str:
|
|
37
|
+
"""URL for ESP32 to POST captured photos to.
|
|
38
|
+
|
|
39
|
+
VISION_URL can be set to a complete public capture URL for remote
|
|
40
|
+
access setups such as Tailscale Funnel. Otherwise VISION_HOST should
|
|
41
|
+
be the LAN IP of the host running this gateway, as seen from the ESP32
|
|
42
|
+
(e.g. something like 192.168.x.y on a typical home network). Falls
|
|
43
|
+
back to "127.0.0.1" with a warning if unset; in that case the ESP32
|
|
44
|
+
will not be able to reach the capture endpoint over the network.
|
|
45
|
+
"""
|
|
46
|
+
explicit_url = os.getenv("VISION_URL")
|
|
47
|
+
if explicit_url:
|
|
48
|
+
return explicit_url
|
|
49
|
+
|
|
50
|
+
host = os.getenv("VISION_HOST")
|
|
51
|
+
if not host:
|
|
52
|
+
logger.warning(
|
|
53
|
+
"VISION_URL/VISION_HOST not set; defaulting to 127.0.0.1. "
|
|
54
|
+
"ESP32 will not reach the capture endpoint unless "
|
|
55
|
+
"VISION_HOST is set to this host's LAN IP or VISION_URL is "
|
|
56
|
+
"set to a full capture URL."
|
|
57
|
+
)
|
|
58
|
+
host = "127.0.0.1"
|
|
59
|
+
port = int(os.getenv("CAPTURE_PORT", "8766"))
|
|
60
|
+
return f"http://{host}:{port}/capture"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def vision_token(self) -> str:
|
|
64
|
+
"""Bearer token expected by the capture endpoint.
|
|
65
|
+
|
|
66
|
+
VISION_TOKEN can be set separately. By default, reuse the ESP32
|
|
67
|
+
WebSocket token so remote capture uploads are protected whenever the
|
|
68
|
+
gateway itself is protected.
|
|
69
|
+
"""
|
|
70
|
+
return (
|
|
71
|
+
os.getenv("VISION_TOKEN")
|
|
72
|
+
or os.getenv("STACKCHAN_TOKEN")
|
|
73
|
+
or os.getenv("BEARER_TOKEN")
|
|
74
|
+
or ""
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def start(self) -> None:
|
|
78
|
+
"""Start the ESP32 WebSocket server and HTTP capture server."""
|
|
79
|
+
host = os.getenv("HOST", "0.0.0.0")
|
|
80
|
+
ws_port = int(os.getenv("WS_PORT", os.getenv("PORT", "8765")))
|
|
81
|
+
capture_port = int(os.getenv("CAPTURE_PORT", "8766"))
|
|
82
|
+
|
|
83
|
+
# Start WebSocket server for ESP32
|
|
84
|
+
await self.esp32.start(
|
|
85
|
+
host,
|
|
86
|
+
ws_port,
|
|
87
|
+
vision_url=self.vision_url,
|
|
88
|
+
vision_token=self.vision_token,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Start HTTP capture server
|
|
92
|
+
app = create_capture_app(capture_token=self.vision_token)
|
|
93
|
+
self._http_runner = web.AppRunner(app)
|
|
94
|
+
await self._http_runner.setup()
|
|
95
|
+
site = web.TCPSite(self._http_runner, host, capture_port)
|
|
96
|
+
await site.start()
|
|
97
|
+
|
|
98
|
+
self._running = True
|
|
99
|
+
logger.info(
|
|
100
|
+
"Gateway started: WS on %s:%d, capture on %s:%d, vision_url=%s",
|
|
101
|
+
host, ws_port, host, capture_port, self.vision_url,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def stop(self) -> None:
|
|
105
|
+
"""Stop the gateway."""
|
|
106
|
+
self._running = False
|
|
107
|
+
if self._http_runner:
|
|
108
|
+
await self._http_runner.cleanup()
|
|
109
|
+
self._http_runner = None
|
|
110
|
+
await self.esp32.stop()
|
|
111
|
+
logger.info("Gateway stopped")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Singleton gateway instance, shared between stdio server and ESP32 manager
|
|
115
|
+
_gateway: Gateway | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_gateway() -> Gateway:
|
|
119
|
+
"""Get or create the singleton gateway."""
|
|
120
|
+
global _gateway
|
|
121
|
+
if _gateway is None:
|
|
122
|
+
_gateway = Gateway()
|
|
123
|
+
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
|