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.
@@ -0,0 +1,7 @@
1
+ """stackchan-mcp: Two-faced gateway for StackChan (xiaozhi-esp32).
2
+
3
+ MCP client side: stdio MCP server (mcp Python SDK)
4
+ ESP32 side: WebSocket server (MCP client over JSON-RPC 2.0)
5
+ """
6
+
7
+ __version__ = "0.1.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
+ }
@@ -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,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