stackchan-mcp 0.9.1__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. stackchan_mcp/__init__.py +81 -0
  2. stackchan_mcp/__main__.py +12 -0
  3. stackchan_mcp/_libs/SOURCES.md +130 -0
  4. stackchan_mcp/_libs/opus.dll +0 -0
  5. stackchan_mcp/audio_input_hook.py +432 -0
  6. stackchan_mcp/audio_stream.py +162 -0
  7. stackchan_mcp/capture_server.py +469 -0
  8. stackchan_mcp/cli.py +958 -0
  9. stackchan_mcp/esp32_client.py +983 -0
  10. stackchan_mcp/event_log.py +189 -0
  11. stackchan_mcp/gateway.py +274 -0
  12. stackchan_mcp/handlers/__init__.py +7 -0
  13. stackchan_mcp/handlers/audio.py +21 -0
  14. stackchan_mcp/handlers/camera.py +25 -0
  15. stackchan_mcp/handlers/robot.py +52 -0
  16. stackchan_mcp/http_server.py +398 -0
  17. stackchan_mcp/mcp_router.py +126 -0
  18. stackchan_mcp/mdns_advertiser.py +347 -0
  19. stackchan_mcp/notify.example.yml +21 -0
  20. stackchan_mcp/notify_config.py +235 -0
  21. stackchan_mcp/ownership.py +270 -0
  22. stackchan_mcp/protocol.py +95 -0
  23. stackchan_mcp/queue.py +191 -0
  24. stackchan_mcp/server.py +28 -0
  25. stackchan_mcp/stdio_server.py +1365 -0
  26. stackchan_mcp/stt/__init__.py +62 -0
  27. stackchan_mcp/stt/audio_utils.py +102 -0
  28. stackchan_mcp/stt/base.py +94 -0
  29. stackchan_mcp/stt/faster_whisper.py +217 -0
  30. stackchan_mcp/stt/openai_whisper.py +177 -0
  31. stackchan_mcp/stt/orchestrator.py +568 -0
  32. stackchan_mcp/tools.py +82 -0
  33. stackchan_mcp/tts/__init__.py +62 -0
  34. stackchan_mcp/tts/audio_utils.py +177 -0
  35. stackchan_mcp/tts/base.py +86 -0
  36. stackchan_mcp/tts/orchestrator.py +688 -0
  37. stackchan_mcp/tts/voicevox.py +184 -0
  38. stackchan_mcp-0.9.1.dist-info/METADATA +324 -0
  39. stackchan_mcp-0.9.1.dist-info/RECORD +43 -0
  40. stackchan_mcp-0.9.1.dist-info/WHEEL +5 -0
  41. stackchan_mcp-0.9.1.dist-info/entry_points.txt +2 -0
  42. stackchan_mcp-0.9.1.dist-info/licenses/LICENSE +39 -0
  43. stackchan_mcp-0.9.1.dist-info/licenses/LICENSE-THIRD-PARTY +65 -0
@@ -0,0 +1,270 @@
1
+ """Gateway ownership lock - refuse-mode MVP (#177 Phase A)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import socket
8
+ import sys
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Literal, TypedDict
13
+
14
+ LOCK_DIR = Path.home() / ".stackchan-mcp"
15
+ LOCK_PATH = LOCK_DIR / "owner.lock"
16
+
17
+
18
+ LockMode = Literal["stdio", "streamable-http"]
19
+
20
+
21
+ class _BaseLockInfo(TypedDict):
22
+ owner_id: str
23
+ pid: int
24
+ start_ts: str
25
+ host: str
26
+
27
+
28
+ class LockInfo(_BaseLockInfo, total=False):
29
+ mode: LockMode
30
+ http_endpoint: str | None
31
+ started_by: str | None
32
+
33
+
34
+ class OwnershipError(RuntimeError):
35
+ """Raised when ownership cannot be acquired."""
36
+
37
+
38
+ def _now_iso() -> str:
39
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
40
+
41
+
42
+ def generate_owner_id() -> str:
43
+ env = os.environ.get("STACKCHAN_OWNER_ID")
44
+ if env:
45
+ return env
46
+ return f"stackchan-mcp-{uuid.uuid4().hex[:8]}"
47
+
48
+
49
+ def is_pid_alive(pid: int) -> bool:
50
+ """Return whether pid is alive without disturbing the target process.
51
+
52
+ On Windows, os.kill(pid, 0) calls TerminateProcess(..., 0), which would
53
+ kill the target process; use a non-destructive Win32 API check instead.
54
+ """
55
+ if pid <= 0:
56
+ return False
57
+ if sys.platform == "win32":
58
+ return _is_pid_alive_windows(pid)
59
+ try:
60
+ os.kill(pid, 0)
61
+ except ProcessLookupError:
62
+ return False
63
+ except PermissionError:
64
+ return True
65
+ return True
66
+
67
+
68
+ def _is_pid_alive_windows(pid: int) -> bool:
69
+ import ctypes
70
+ import ctypes.wintypes
71
+
72
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
73
+ STILL_ACTIVE = 259
74
+
75
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
76
+ kernel32.OpenProcess.argtypes = [
77
+ ctypes.wintypes.DWORD,
78
+ ctypes.wintypes.BOOL,
79
+ ctypes.wintypes.DWORD,
80
+ ]
81
+ kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
82
+ kernel32.GetExitCodeProcess.argtypes = [
83
+ ctypes.wintypes.HANDLE,
84
+ ctypes.POINTER(ctypes.wintypes.DWORD),
85
+ ]
86
+ kernel32.GetExitCodeProcess.restype = ctypes.wintypes.BOOL
87
+ kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
88
+ kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
89
+
90
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
91
+ if not handle:
92
+ return False
93
+
94
+ try:
95
+ exit_code = ctypes.wintypes.DWORD()
96
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
97
+ return False
98
+ return exit_code.value == STILL_ACTIVE
99
+ finally:
100
+ kernel32.CloseHandle(handle)
101
+
102
+
103
+ def read_lock(path: Path = LOCK_PATH) -> LockInfo | None:
104
+ if not path.exists():
105
+ return None
106
+ try:
107
+ raw = json.loads(path.read_text(encoding="utf-8"))
108
+ except (json.JSONDecodeError, OSError):
109
+ return None
110
+
111
+ if not isinstance(raw, dict):
112
+ return None
113
+
114
+ owner_id = raw.get("owner_id")
115
+ pid = raw.get("pid")
116
+ start_ts = raw.get("start_ts")
117
+ host = raw.get("host")
118
+ if (
119
+ not isinstance(owner_id, str)
120
+ or not isinstance(pid, int)
121
+ or not isinstance(start_ts, str)
122
+ or not isinstance(host, str)
123
+ ):
124
+ return None
125
+
126
+ info: LockInfo = {
127
+ "owner_id": owner_id,
128
+ "pid": pid,
129
+ "start_ts": start_ts,
130
+ "host": host,
131
+ }
132
+
133
+ # Optional metadata: silently ignore unknown or malformed values so a
134
+ # schema-drift writer (for example a future-mode lock file or a
135
+ # partially compatible external writer) cannot cause read_lock to
136
+ # return None and trick acquire_lock into unlinking a live owner's
137
+ # lock file. The four required #177 base fields above are
138
+ # authoritative for the claim/refuse decision; validating optional
139
+ # metadata is a separate diagnostic concern.
140
+
141
+ mode = raw.get("mode")
142
+ if mode in ("stdio", "streamable-http"):
143
+ info["mode"] = mode
144
+
145
+ if "http_endpoint" in raw:
146
+ http_endpoint = raw["http_endpoint"]
147
+ if http_endpoint is None or isinstance(http_endpoint, str):
148
+ info["http_endpoint"] = http_endpoint
149
+
150
+ if "started_by" in raw:
151
+ started_by = raw["started_by"]
152
+ if started_by is None or isinstance(started_by, str):
153
+ info["started_by"] = started_by
154
+
155
+ return info
156
+
157
+
158
+ def _write_lock_atomic(info: LockInfo, path: Path = LOCK_PATH) -> None:
159
+ path.parent.mkdir(parents=True, exist_ok=True)
160
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
161
+ tmp.write_text(json.dumps(info, indent=2), encoding="utf-8")
162
+ try:
163
+ # Link the complete temp file into place only if no owner file exists.
164
+ # This keeps readers from seeing partial JSON and lets exactly one
165
+ # simultaneous startup win the initial claim.
166
+ os.link(tmp, path)
167
+ finally:
168
+ tmp.unlink(missing_ok=True)
169
+
170
+
171
+ def acquire_lock(
172
+ owner_id: str,
173
+ path: Path = LOCK_PATH,
174
+ *,
175
+ mode: LockMode = "stdio",
176
+ http_endpoint: str | None = None,
177
+ started_by: str | None = None,
178
+ ) -> LockInfo:
179
+ """Acquire the ownership lock. Raise OwnershipError on refuse.
180
+
181
+ The default stdio-mode call writes the original #177 lock shape so
182
+ older lock readers and ``stackchan-mcp --check`` output remain
183
+ compatible. Daemon transports can attach optional metadata for
184
+ diagnostics without changing the atomic hardlink claim.
185
+ """
186
+ if mode not in ("stdio", "streamable-http"):
187
+ raise ValueError(f"unsupported lock mode: {mode!r}")
188
+ if mode == "stdio" and (http_endpoint is not None or started_by is not None):
189
+ raise ValueError(
190
+ "stdio-mode ownership locks must not carry http_endpoint or "
191
+ "started_by; these fields are reserved for non-stdio transports"
192
+ )
193
+
194
+ while True:
195
+ existing = read_lock(path)
196
+ if existing is not None:
197
+ if is_pid_alive(existing["pid"]):
198
+ raise OwnershipError(
199
+ "stackchan-mcp: device already owned by "
200
+ f"{existing['owner_id']} "
201
+ f"(pid {existing['pid']}, since {existing['start_ts']})"
202
+ )
203
+ print(
204
+ f"stackchan-mcp: removed stale lock from dead pid {existing['pid']}",
205
+ file=sys.stderr,
206
+ )
207
+ path.unlink(missing_ok=True)
208
+ elif path.exists():
209
+ path.unlink()
210
+
211
+ info: LockInfo = {
212
+ "owner_id": owner_id,
213
+ "pid": os.getpid(),
214
+ "start_ts": _now_iso(),
215
+ "host": socket.gethostname(),
216
+ }
217
+ if mode != "stdio":
218
+ info["mode"] = mode
219
+ if http_endpoint is not None:
220
+ info["http_endpoint"] = http_endpoint
221
+ if started_by is not None:
222
+ info["started_by"] = started_by
223
+
224
+ try:
225
+ _write_lock_atomic(info, path)
226
+ except FileExistsError:
227
+ continue
228
+ return info
229
+
230
+
231
+ def release_lock(path: Path = LOCK_PATH) -> None:
232
+ """Remove the lock file. Idempotent.
233
+
234
+ This is the legacy, owner-unaware release primitive kept for backward
235
+ compatibility. New callers should prefer :func:`release_lock_if_owner`
236
+ so that a stale cleanup callback cannot unlink a successor process's
237
+ live lock.
238
+ """
239
+ try:
240
+ path.unlink()
241
+ except FileNotFoundError:
242
+ pass
243
+
244
+
245
+ def release_lock_if_owner(info: LockInfo, path: Path = LOCK_PATH) -> bool:
246
+ """Remove the lock file only if it still belongs to ``info``.
247
+
248
+ Returns ``True`` if the lock was removed, ``False`` if the on-disk
249
+ lock has a different ``owner_id`` / ``pid`` / ``start_ts`` or no
250
+ longer exists. This is the owner-scoped counterpart to
251
+ :func:`release_lock` and is intended for cleanup paths (``finally``
252
+ blocks, ``atexit.register``) where the caller may have lost ownership
253
+ between claim and cleanup — for example after the gateway exited and
254
+ a second process acquired the lock before the first process's
255
+ interpreter exit callbacks ran.
256
+ """
257
+ existing = read_lock(path)
258
+ if existing is None:
259
+ return False
260
+ if (
261
+ existing.get("owner_id") != info.get("owner_id")
262
+ or existing.get("pid") != info.get("pid")
263
+ or existing.get("start_ts") != info.get("start_ts")
264
+ ):
265
+ return False
266
+ try:
267
+ path.unlink()
268
+ return True
269
+ except FileNotFoundError:
270
+ return False
@@ -0,0 +1,95 @@
1
+ """xiaozhi-esp32 protocol definitions.
2
+
3
+ Defines message formats for communication between the gateway and ESP32 device.
4
+ Based on: xiaozhi-esp32/docs/mcp-protocol.md
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Transport-level messages (hello handshake)
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ class AudioParams(BaseModel):
20
+ format: str = "opus"
21
+ sample_rate: int = 16000
22
+ channels: int = 1
23
+ frame_duration: int = 60
24
+
25
+
26
+ class HelloMessage(BaseModel):
27
+ """ESP32 -> Gateway: hello (connection announcement)."""
28
+
29
+ type: str = "hello"
30
+ version: int = 1
31
+ features: dict[str, Any] = Field(default_factory=lambda: {"mcp": True})
32
+ transport: str = "websocket"
33
+ audio_params: AudioParams = Field(default_factory=AudioParams)
34
+ session_id: str | None = None
35
+
36
+
37
+ class HelloResponse(BaseModel):
38
+ """Gateway -> ESP32: hello response."""
39
+
40
+ type: str = "hello"
41
+ version: int = 1
42
+ transport: str = "websocket"
43
+ session_id: str | None = None
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # MCP message wrapper (over transport)
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ class McpMessage(BaseModel):
52
+ """MCP message wrapper for transport.
53
+
54
+ All MCP communication is wrapped in this envelope.
55
+ """
56
+
57
+ session_id: str = ""
58
+ type: str = "mcp"
59
+ payload: dict[str, Any] = Field(default_factory=dict)
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # JSON-RPC 2.0 helpers
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def make_jsonrpc_request(method: str, params: dict[str, Any], req_id: int) -> dict[str, Any]:
68
+ """Create a JSON-RPC 2.0 request payload."""
69
+ return {
70
+ "jsonrpc": "2.0",
71
+ "method": method,
72
+ "params": params,
73
+ "id": req_id,
74
+ }
75
+
76
+
77
+ def make_mcp_message(
78
+ session_id: str, method: str, params: dict[str, Any], req_id: int
79
+ ) -> dict[str, Any]:
80
+ """Create a full MCP transport message with JSON-RPC payload."""
81
+ return {
82
+ "session_id": session_id,
83
+ "type": "mcp",
84
+ "payload": make_jsonrpc_request(method, params, req_id),
85
+ }
86
+
87
+
88
+ def parse_jsonrpc_response(payload: dict[str, Any]) -> tuple[Any, dict[str, Any] | None]:
89
+ """Parse a JSON-RPC 2.0 response.
90
+
91
+ Returns (result, error) — one of them will be None.
92
+ """
93
+ if "error" in payload:
94
+ return None, payload["error"]
95
+ return payload.get("result"), None
stackchan_mcp/queue.py ADDED
@@ -0,0 +1,191 @@
1
+ """Bounded command queue for Issue #178 Phase B chunk 1.
2
+
3
+ This module follows the command-queue design in
4
+ ``docs/178-http-transport-spike.md`` and intentionally stays independent from
5
+ the HTTP MCP server, MCP SDK objects, and ESP32 gateway wiring.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import time
13
+ import uuid
14
+ from collections.abc import Awaitable, Callable
15
+ from contextlib import suppress
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ COMMAND_QUEUE_SIZE_ENV = "STACKCHAN_COMMAND_QUEUE_SIZE"
20
+ DEFAULT_COMMAND_QUEUE_CAPACITY = 32
21
+ QUEUE_FULL_ERROR_CODE = -32000
22
+ QUEUE_FULL_MESSAGE = "stackchan command queue is full"
23
+ QUEUE_FULL_RETRY_AFTER_MS = 250
24
+
25
+ DispatchFn = Callable[["QueueItem"], Awaitable[Any]]
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class QueueItem:
30
+ """One ESP32-bound command and the future that receives its response."""
31
+
32
+ correlation_id: str
33
+ client_session_id: str | None
34
+ client_request_id: int | str
35
+ tool_name: str
36
+ arguments: dict[str, Any]
37
+ response_future: asyncio.Future[Any]
38
+ enqueued_at: float
39
+
40
+
41
+ class QueueFull(Exception):
42
+ """Raised by CommandQueue.enqueue when capacity is reached."""
43
+
44
+ def __init__(self, queue_depth: int, capacity: int) -> None:
45
+ self.queue_depth = queue_depth
46
+ self.capacity = capacity
47
+ super().__init__(
48
+ f"{QUEUE_FULL_MESSAGE} (queue_depth={queue_depth}, capacity={capacity})"
49
+ )
50
+
51
+
52
+ class CommandQueue:
53
+ """Asyncio-backed bounded FIFO queue for serialized command dispatch."""
54
+
55
+ def __init__(self, capacity: int | None = None) -> None:
56
+ self._capacity = capacity if capacity is not None else _capacity_from_env()
57
+ if self._capacity < 1:
58
+ raise ValueError("command queue capacity must be at least 1")
59
+ self._queue: asyncio.Queue[QueueItem] = asyncio.Queue(
60
+ maxsize=self._capacity
61
+ )
62
+
63
+ @property
64
+ def capacity(self) -> int:
65
+ """Return the maximum number of queued commands."""
66
+ return self._capacity
67
+
68
+ @property
69
+ def depth(self) -> int:
70
+ """Return the current number of queued commands."""
71
+ return self._queue.qsize()
72
+
73
+ def enqueue(self, item: QueueItem) -> None:
74
+ """Add an item without blocking, raising QueueFull on saturation."""
75
+ try:
76
+ self._queue.put_nowait(item)
77
+ except asyncio.QueueFull as exc:
78
+ raise QueueFull(self.depth, self.capacity) from exc
79
+
80
+ async def get(self) -> QueueItem:
81
+ """Await the next queued item in FIFO order."""
82
+ return await self._queue.get()
83
+
84
+ async def run_dispatcher(self, dispatch_fn: DispatchFn) -> None:
85
+ """Dispatch commands one at a time and complete each response future.
86
+
87
+ The injected dispatch function is awaited to completion before the next
88
+ queue item is fetched. Exceptions from dispatch_fn are delivered to the
89
+ item's response_future so the dispatcher loop can continue.
90
+ """
91
+ while True:
92
+ item = await self.get()
93
+ try:
94
+ result = await dispatch_fn(item)
95
+ except Exception as exc:
96
+ if not item.response_future.done():
97
+ item.response_future.set_exception(exc)
98
+ else:
99
+ if not item.response_future.done():
100
+ item.response_future.set_result(result)
101
+ finally:
102
+ self._queue.task_done()
103
+
104
+
105
+ def build_queue_full_error(
106
+ queue_depth: int,
107
+ retry_after_ms: int = QUEUE_FULL_RETRY_AFTER_MS,
108
+ ) -> dict[str, Any]:
109
+ """Build the JSON-RPC inner error payload for queue saturation."""
110
+ return {
111
+ "code": QUEUE_FULL_ERROR_CODE,
112
+ "message": QUEUE_FULL_MESSAGE,
113
+ "data": {
114
+ "queue_depth": queue_depth,
115
+ "retry_after_ms": retry_after_ms,
116
+ },
117
+ }
118
+
119
+
120
+ def _capacity_from_env() -> int:
121
+ raw_capacity = os.getenv(COMMAND_QUEUE_SIZE_ENV)
122
+ if raw_capacity is None or raw_capacity == "":
123
+ return DEFAULT_COMMAND_QUEUE_CAPACITY
124
+ try:
125
+ capacity = int(raw_capacity)
126
+ except ValueError as exc:
127
+ raise ValueError(
128
+ f"{COMMAND_QUEUE_SIZE_ENV} must be an integer"
129
+ ) from exc
130
+ if capacity < 1:
131
+ raise ValueError(f"{COMMAND_QUEUE_SIZE_ENV} must be at least 1")
132
+ return capacity
133
+
134
+
135
+ def _make_smoke_item(
136
+ response_future: asyncio.Future[Any],
137
+ tool_name: str = "smoke.tool",
138
+ ) -> QueueItem:
139
+ return QueueItem(
140
+ correlation_id=str(uuid.uuid4()),
141
+ client_session_id="smoke-session",
142
+ client_request_id=1,
143
+ tool_name=tool_name,
144
+ arguments={"value": "smoke"},
145
+ response_future=response_future,
146
+ enqueued_at=time.monotonic(),
147
+ )
148
+
149
+
150
+ async def _run_smoke() -> None:
151
+ queue = CommandQueue(capacity=1)
152
+ response_future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
153
+ queue.enqueue(_make_smoke_item(response_future))
154
+
155
+ async def dispatch_fn(item: QueueItem) -> dict[str, Any]:
156
+ return {
157
+ "ok": True,
158
+ "correlation_id": item.correlation_id,
159
+ "tool_name": item.tool_name,
160
+ }
161
+
162
+ dispatcher = asyncio.create_task(queue.run_dispatcher(dispatch_fn))
163
+ result = await asyncio.wait_for(response_future, timeout=1.0)
164
+ assert result["ok"] is True
165
+ assert result["tool_name"] == "smoke.tool"
166
+
167
+ dispatcher.cancel()
168
+ with suppress(asyncio.CancelledError):
169
+ await dispatcher
170
+
171
+ full_queue = CommandQueue(capacity=1)
172
+ full_queue.enqueue(
173
+ _make_smoke_item(asyncio.get_running_loop().create_future(), "full.first")
174
+ )
175
+ try:
176
+ full_queue.enqueue(
177
+ _make_smoke_item(
178
+ asyncio.get_running_loop().create_future(),
179
+ "full.second",
180
+ )
181
+ )
182
+ except QueueFull as exc:
183
+ assert exc.queue_depth == 1
184
+ assert build_queue_full_error(exc.queue_depth)["code"] == -32000
185
+ else:
186
+ raise AssertionError("QueueFull was not raised")
187
+
188
+
189
+ if __name__ == "__main__":
190
+ asyncio.run(_run_smoke())
191
+ print("smoke: PASS")
@@ -0,0 +1,28 @@
1
+ """WebSocket server for ESP32 connections.
2
+
3
+ This module is retained for backward compatibility and testing.
4
+ In production, use the Gateway (gateway.py) which orchestrates
5
+ both the ESP32 WebSocket server and the stdio MCP server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from .esp32_client import ESP32Manager
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ async def run_server(host: str = "0.0.0.0", port: int = 8765) -> None:
18
+ """Start the ESP32 WebSocket server standalone (for testing)."""
19
+ manager = ESP32Manager()
20
+ await manager.start(host, port)
21
+ logger.info("ESP32 WebSocket server running on ws://%s:%d", host, port)
22
+
23
+ # Keep running until interrupted
24
+ try:
25
+ import asyncio
26
+ await asyncio.Future() # Run forever
27
+ finally:
28
+ await manager.stop()