stackchan-mcp 0.9.0__tar.gz → 0.10.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.9.0 → stackchan_mcp-0.10.0}/PKG-INFO +1 -1
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/pyproject.toml +1 -1
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/mdns_advertiser.py +179 -9
- stackchan_mcp-0.10.0/tests/test_mdns_advertiser.py +913 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/uv.lock +1 -1
- stackchan_mcp-0.9.0/tests/test_mdns_advertiser.py +0 -379
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/.env.example +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/.gitignore +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/AGENTS.md +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/LICENSE +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/LICENSE-THIRD-PARTY +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/README.md +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/__init__.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/__main__.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/audio_input_hook.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/audio_stream.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/capture_server.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/cli.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/esp32_client.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/event_log.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/gateway.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/handlers/__init__.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/handlers/audio.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/handlers/camera.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/handlers/robot.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/http_server.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/mcp_router.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/notify.example.yml +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/notify_config.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/ownership.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/protocol.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/queue.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/server.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/stdio_server.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/stt/__init__.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/stt/audio_utils.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/stt/base.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/stt/orchestrator.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/tools.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/tts/__init__.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/tts/audio_utils.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/tts/base.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/tts/orchestrator.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/stackchan_mcp/tts/voicevox.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/_audio_fixtures.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/conftest.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_audio_input_hook.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_audio_stream.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_audio_utils.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_capture_server.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_cli.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_esp32_client.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_event_dispatch.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_event_log.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_gateway.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_http_server.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_mcp_router.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_notify_config.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_orchestrator.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_ownership.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_protocol.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_send_pcm_audio.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_send_pcm_stream.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_stackchan_event.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_stdio_server.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_stt_audio_utils.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_stt_framework.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_stt_orchestrator.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.0}/tests/test_tts_framework.py +0 -0
- {stackchan_mcp-0.9.0 → stackchan_mcp-0.10.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.10.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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import ipaddress
|
|
6
7
|
import logging
|
|
7
8
|
import socket
|
|
@@ -238,6 +239,26 @@ def _resolve_concrete_host_ipv4_addresses(host: str) -> list[str]:
|
|
|
238
239
|
return _select_advertised_addresses([(address, None) for address in addresses])
|
|
239
240
|
|
|
240
241
|
|
|
242
|
+
def _resolve_addresses_for_host(host: str) -> list[str]:
|
|
243
|
+
"""Resolve advertised addresses for ``host`` using the same logic as
|
|
244
|
+
:func:`build_advertisement`.
|
|
245
|
+
|
|
246
|
+
Wildcard hosts (``0.0.0.0`` / ``*`` / ``""``) enumerate every usable
|
|
247
|
+
non-loopback IPv4 address on the machine; concrete hosts only resolve
|
|
248
|
+
addresses for that specific host. The refresh loop calls this helper so
|
|
249
|
+
its comparison set matches what a fresh ``build_advertisement(...)``
|
|
250
|
+
would actually register — without this, a host started with a concrete
|
|
251
|
+
HOST in a multi-NIC / Tailscale environment would see every poll as an
|
|
252
|
+
"address change" against an unrelated extra interface and churn the
|
|
253
|
+
registration on every refresh interval.
|
|
254
|
+
"""
|
|
255
|
+
return (
|
|
256
|
+
_enumerate_usable_ipv4_addresses()
|
|
257
|
+
if _is_wildcard_host(host)
|
|
258
|
+
else _resolve_concrete_host_ipv4_addresses(host)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
241
262
|
def build_advertisement(
|
|
242
263
|
*,
|
|
243
264
|
host: str,
|
|
@@ -253,11 +274,7 @@ def build_advertisement(
|
|
|
253
274
|
return None
|
|
254
275
|
|
|
255
276
|
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
256
|
-
addresses = (
|
|
257
|
-
_enumerate_usable_ipv4_addresses()
|
|
258
|
-
if _is_wildcard_host(host)
|
|
259
|
-
else _resolve_concrete_host_ipv4_addresses(host)
|
|
260
|
-
)
|
|
277
|
+
addresses = _resolve_addresses_for_host(host)
|
|
261
278
|
if not addresses:
|
|
262
279
|
logger.warning(
|
|
263
280
|
"mDNS advertisement skipped: no usable non-loopback IPv4 address "
|
|
@@ -280,15 +297,57 @@ def build_advertisement(
|
|
|
280
297
|
class MdnsAdvertiser:
|
|
281
298
|
"""Registers the gateway's WebSocket endpoint via mDNS/DNS-SD."""
|
|
282
299
|
|
|
283
|
-
def __init__(self) -> None:
|
|
300
|
+
def __init__(self, *, refresh_interval: float = 30.0) -> None:
|
|
301
|
+
if refresh_interval < 10.0 or refresh_interval > 300.0:
|
|
302
|
+
raise ValueError("refresh_interval must be between 10.0 and 300.0 seconds")
|
|
303
|
+
self._lock: asyncio.Lock = asyncio.Lock()
|
|
284
304
|
self._zeroconf: Any | None = None
|
|
285
305
|
self._service_info: Any | None = None
|
|
306
|
+
self._refresh_task: asyncio.Task | None = None
|
|
307
|
+
self._last_advertised_addresses: tuple[str, ...] | None = None
|
|
308
|
+
self._refresh_interval: float = refresh_interval
|
|
309
|
+
self._start_args: dict[str, Any] | None = None
|
|
286
310
|
|
|
287
311
|
async def start(self, *, host: str, port: int, path: str = "/") -> None:
|
|
312
|
+
task = self._refresh_task
|
|
313
|
+
if task is not None and task is not asyncio.current_task():
|
|
314
|
+
task.cancel()
|
|
315
|
+
try:
|
|
316
|
+
await asyncio.wait_for(task, timeout=1.0)
|
|
317
|
+
except (asyncio.CancelledError, TimeoutError):
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
async with self._lock:
|
|
321
|
+
self._refresh_task = None
|
|
322
|
+
if self._zeroconf is not None:
|
|
323
|
+
await self._close_zeroconf_locked()
|
|
324
|
+
self._start_args = {"host": host, "port": port, "path": path}
|
|
325
|
+
try:
|
|
326
|
+
advertisement = await self._start_locked(host=host, port=port, path=path)
|
|
327
|
+
except Exception:
|
|
328
|
+
self._start_args = None
|
|
329
|
+
self._last_advertised_addresses = None
|
|
330
|
+
raise
|
|
331
|
+
if advertisement is None:
|
|
332
|
+
self._start_args = None
|
|
333
|
+
self._last_advertised_addresses = None
|
|
334
|
+
return
|
|
335
|
+
self._last_advertised_addresses = tuple(advertisement.parsed_addresses)
|
|
336
|
+
self._refresh_task = asyncio.create_task(self._refresh_loop())
|
|
337
|
+
|
|
338
|
+
async def _start_locked(
|
|
339
|
+
self, *, host: str, port: int, path: str = "/"
|
|
340
|
+
) -> MdnsAdvertisement | None:
|
|
288
341
|
advertisement = build_advertisement(host=host, port=port, path=path)
|
|
289
342
|
if advertisement is None:
|
|
290
|
-
return
|
|
343
|
+
return None
|
|
291
344
|
|
|
345
|
+
await self._register_advertisement_locked(advertisement)
|
|
346
|
+
return advertisement
|
|
347
|
+
|
|
348
|
+
async def _register_advertisement_locked(
|
|
349
|
+
self, advertisement: MdnsAdvertisement
|
|
350
|
+
) -> None:
|
|
292
351
|
AsyncZeroconf, ServiceInfo = _load_zeroconf_classes()
|
|
293
352
|
# Constrain zeroconf to the IPv4 interfaces we actually advertise on.
|
|
294
353
|
# The default ``InterfaceChoice.All`` makes zeroconf bind a socket on
|
|
@@ -309,7 +368,15 @@ class MdnsAdvertiser:
|
|
|
309
368
|
)
|
|
310
369
|
try:
|
|
311
370
|
await zeroconf.async_register_service(info, allow_name_change=True)
|
|
312
|
-
except
|
|
371
|
+
except BaseException:
|
|
372
|
+
# Catch BaseException (not just Exception) so that an
|
|
373
|
+
# ``asyncio.CancelledError`` arriving mid-registration —
|
|
374
|
+
# e.g. an external ``stop()`` or a double-``start()`` racing
|
|
375
|
+
# this registration — still closes the partially-constructed
|
|
376
|
+
# ``AsyncZeroconf``. Otherwise the bound multicast sockets
|
|
377
|
+
# and any partial registration would leak past the cancelled
|
|
378
|
+
# task. ``raise`` preserves the cancellation semantics for
|
|
379
|
+
# the surrounding ``async with self._lock`` caller.
|
|
313
380
|
await zeroconf.async_close()
|
|
314
381
|
raise
|
|
315
382
|
self._zeroconf = zeroconf
|
|
@@ -326,13 +393,28 @@ class MdnsAdvertiser:
|
|
|
326
393
|
advertisement.service_name,
|
|
327
394
|
)
|
|
328
395
|
logger.info(
|
|
329
|
-
"mDNS advertising %s on port %d with addresses %s",
|
|
396
|
+
"mDNS advertising %s on port %d with addresses %s reason=register",
|
|
330
397
|
registered_name,
|
|
331
398
|
advertisement.port,
|
|
332
399
|
", ".join(advertisement.parsed_addresses),
|
|
333
400
|
)
|
|
334
401
|
|
|
335
402
|
async def stop(self) -> None:
|
|
403
|
+
task = self._refresh_task
|
|
404
|
+
if task is not None and task is not asyncio.current_task():
|
|
405
|
+
task.cancel()
|
|
406
|
+
try:
|
|
407
|
+
await asyncio.wait_for(task, timeout=1.0)
|
|
408
|
+
except (asyncio.CancelledError, TimeoutError):
|
|
409
|
+
pass
|
|
410
|
+
|
|
411
|
+
async with self._lock:
|
|
412
|
+
await self._close_zeroconf_locked()
|
|
413
|
+
self._refresh_task = None
|
|
414
|
+
self._last_advertised_addresses = None
|
|
415
|
+
self._start_args = None
|
|
416
|
+
|
|
417
|
+
async def _close_zeroconf_locked(self) -> None:
|
|
336
418
|
zeroconf = self._zeroconf
|
|
337
419
|
info = self._service_info
|
|
338
420
|
self._zeroconf = None
|
|
@@ -345,3 +427,91 @@ class MdnsAdvertiser:
|
|
|
345
427
|
await zeroconf.async_unregister_service(info)
|
|
346
428
|
finally:
|
|
347
429
|
await zeroconf.async_close()
|
|
430
|
+
|
|
431
|
+
async def _reconfigure(self) -> None:
|
|
432
|
+
async with self._lock:
|
|
433
|
+
if self._start_args is None:
|
|
434
|
+
return
|
|
435
|
+
old_addresses = self._last_advertised_addresses
|
|
436
|
+
advertisement = build_advertisement(**self._start_args)
|
|
437
|
+
if advertisement is None:
|
|
438
|
+
logger.info(
|
|
439
|
+
"mDNS reconfigure skipped: build_advertisement returned None "
|
|
440
|
+
"(transient empty address state); refresh loop continues "
|
|
441
|
+
"reason=transient_empty_address_state old=%s",
|
|
442
|
+
old_addresses,
|
|
443
|
+
)
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
# Clear the cached advertised set BEFORE closing so that even a
|
|
447
|
+
# failure inside ``_close_zeroconf_locked()`` itself (e.g. the
|
|
448
|
+
# old interface has vanished mid-cycle and ``async_unregister``
|
|
449
|
+
# / ``async_close`` raise) still leaves the refresh loop in a
|
|
450
|
+
# "must retry" state. If the subsequent close raises, or the
|
|
451
|
+
# re-registration raises, or the host IP later reverts to
|
|
452
|
+
# ``old_addresses``, the refresh loop's
|
|
453
|
+
# ``current != _last_advertised_addresses`` check would
|
|
454
|
+
# otherwise compare against the stale value and stay quiet —
|
|
455
|
+
# the advertisement would remain dead until a manual restart.
|
|
456
|
+
# Setting this to ``None`` here guarantees the next refresh
|
|
457
|
+
# tick observes a divergence and retries registration
|
|
458
|
+
# regardless of which address state the host happens to be in.
|
|
459
|
+
self._last_advertised_addresses = None
|
|
460
|
+
await self._close_zeroconf_locked()
|
|
461
|
+
await self._register_advertisement_locked(advertisement)
|
|
462
|
+
new_addresses = tuple(advertisement.parsed_addresses)
|
|
463
|
+
self._last_advertised_addresses = new_addresses
|
|
464
|
+
registered_name = getattr(
|
|
465
|
+
self._service_info,
|
|
466
|
+
"name",
|
|
467
|
+
advertisement.service_name,
|
|
468
|
+
)
|
|
469
|
+
logger.info(
|
|
470
|
+
"mDNS reconfigured: reason=address_changed old=%s new=%s "
|
|
471
|
+
"registered_name=%s",
|
|
472
|
+
old_addresses,
|
|
473
|
+
new_addresses,
|
|
474
|
+
registered_name,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
async def _refresh_loop(self) -> None:
|
|
478
|
+
while True:
|
|
479
|
+
try:
|
|
480
|
+
await asyncio.sleep(self._refresh_interval)
|
|
481
|
+
start_args = self._start_args
|
|
482
|
+
if start_args is None:
|
|
483
|
+
continue
|
|
484
|
+
host = start_args["host"]
|
|
485
|
+
current_addresses = tuple(_resolve_addresses_for_host(host))
|
|
486
|
+
old_addresses = self._last_advertised_addresses
|
|
487
|
+
if current_addresses == old_addresses:
|
|
488
|
+
continue
|
|
489
|
+
|
|
490
|
+
logger.info(
|
|
491
|
+
"mDNS refresh observed address change: reason=address_changed "
|
|
492
|
+
"old=%s new=%s debounce=started",
|
|
493
|
+
old_addresses,
|
|
494
|
+
current_addresses,
|
|
495
|
+
)
|
|
496
|
+
await asyncio.sleep(self._refresh_interval)
|
|
497
|
+
start_args = self._start_args
|
|
498
|
+
if start_args is None:
|
|
499
|
+
continue
|
|
500
|
+
host = start_args["host"]
|
|
501
|
+
confirmed_addresses = tuple(_resolve_addresses_for_host(host))
|
|
502
|
+
if confirmed_addresses != current_addresses:
|
|
503
|
+
logger.info(
|
|
504
|
+
"mDNS refresh dropped transient address change: "
|
|
505
|
+
"reason=debounce_mismatch old=%s first=%s second=%s",
|
|
506
|
+
old_addresses,
|
|
507
|
+
current_addresses,
|
|
508
|
+
confirmed_addresses,
|
|
509
|
+
)
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
await self._reconfigure()
|
|
513
|
+
except asyncio.CancelledError:
|
|
514
|
+
return
|
|
515
|
+
except Exception:
|
|
516
|
+
logger.exception("mDNS refresh loop error; continuing")
|
|
517
|
+
continue
|