diff --git a/docs/MESHCORE.md b/docs/MESHCORE.md index 442716e..2d701e9 100644 --- a/docs/MESHCORE.md +++ b/docs/MESHCORE.md @@ -37,7 +37,7 @@ Without `MESHCORE_UPLOAD_ENABLED`, `STORAGE_API_*` is ignored. When `MESHCORE_UPLOAD_ENABLED=true` and `STORAGE_API_*` are set, the bot also: 1. **On connect** — reads the device channel table (`meshcore.commands.get_channel`) and `POST`s `/api/meshcore/feeders/{prefix}/mc-channel-sync/` (device is source of truth for names/types). -2. **WebSocket** — connects to `ws/nodes/?api_key=…` for `apply_mc_channel_config` (UI “apply to radio”); writes channels via `set_channel`, then re-syncs to the API. +2. **WebSocket** — connects to `ws/nodes/?api_key=…` (URL derived from `STORAGE_API_ROOT` when `MESHFLOW_WS_URL` is unset). MeshCore feeders automatically append `feeder_pubkey_prefix` from the device pubkey after connect (no env var). Used for UI **apply to radio** (`apply_mc_channel_config`): the REST endpoint only dispatches to the bot over WS; the bot writes the device via `set_channel`, then re-syncs to the API. Traceroute commands remain Meshtastic-only; MC feeders ignore `traceroute` WS messages. diff --git a/src/main.py b/src/main.py index 5491bc3..0aaf0d8 100644 --- a/src/main.py +++ b/src/main.py @@ -205,6 +205,11 @@ def main() -> None: on_apply_mc_channel_config=( bot.on_apply_mc_channel_config if RADIO_PROTOCOL == "meshcore" else None ), + feeder_pubkey_prefix_provider=( + (lambda: getattr(radio, "feeder_mc_pubkey_prefix", None)) + if RADIO_PROTOCOL == "meshcore" + else None + ), ) try: diff --git a/src/meshcore/channel_sync.py b/src/meshcore/channel_sync.py index a3f3a1a..18fc835 100644 --- a/src/meshcore/channel_sync.py +++ b/src/meshcore/channel_sync.py @@ -2,9 +2,12 @@ from __future__ import annotations +import asyncio import logging from typing import TYPE_CHECKING, Optional +CHANNEL_READ_DELAY_S = 2.0 + from src.meshcore.channels import ( log_device_channels, read_device_channels, @@ -31,6 +34,7 @@ async def sync_channels_to_api_async( channels: list[dict] = [] else: try: + await asyncio.sleep(CHANNEL_READ_DELAY_S) channels = await read_device_channels(mc) except Exception as exc: logger.exception("MeshCore read_device_channels failed: %s", exc) diff --git a/src/meshcore/channels.py b/src/meshcore/channels.py index c430637..7595bde 100644 --- a/src/meshcore/channels.py +++ b/src/meshcore/channels.py @@ -33,6 +33,17 @@ def _channel_entry_from_info(idx: int, payload: dict) -> Optional[dict]: } +def _describe_channel_event(idx: int, evt: Any) -> str: + if evt.type == EventType.ERROR: + payload = evt.payload if isinstance(evt.payload, dict) else {} + return f"[{idx}] ERROR {payload.get('reason', payload)}" + if evt.type == EventType.CHANNEL_INFO: + payload = evt.payload if isinstance(evt.payload, dict) else {} + name = payload.get("channel_name", "") + return f"[{idx}] CHANNEL_INFO name={name!r}" + return f"[{idx}] {evt.type}" + + async def read_device_channels( meshcore: MeshCore, *, @@ -40,8 +51,10 @@ async def read_device_channels( ) -> list[dict]: """Return channel snapshot rows for API mc-channel-sync.""" channels: list[dict] = [] + scan_lines: list[str] = [] for idx in range(max_channels): evt = await meshcore.commands.get_channel(idx) + scan_lines.append(_describe_channel_event(idx, evt)) if evt.type == EventType.ERROR: continue if evt.type != EventType.CHANNEL_INFO: @@ -50,6 +63,17 @@ async def read_device_channels( entry = _channel_entry_from_info(idx, payload) if entry: channels.append(entry) + elif str(payload.get("channel_name", "") or "").strip(): + logger.info( + "MeshCore channel [%s] has name %r but was not mapped", + idx, + payload.get("channel_name"), + ) + if not channels: + logger.warning( + "MeshCore get_channel scan found 0 named channels: %s", + "; ".join(scan_lines) or "(no responses)", + ) return channels diff --git a/src/ws_client.py b/src/ws_client.py index 31f7c46..3034570 100644 --- a/src/ws_client.py +++ b/src/ws_client.py @@ -9,6 +9,7 @@ import json import logging from typing import Callable, Optional +from urllib.parse import quote logger = logging.getLogger(__name__) @@ -29,6 +30,7 @@ def __init__( on_apply_mc_channel_config: Optional[Callable[[list], None]] = None, on_connect: Optional[Callable[[], None]] = None, on_disconnect: Optional[Callable[[], None]] = None, + feeder_pubkey_prefix_provider: Optional[Callable[[], Optional[str]]] = None, ): """ Args: @@ -44,6 +46,7 @@ def __init__( self.on_apply_mc_channel_config = on_apply_mc_channel_config self.on_connect = on_connect self.on_disconnect = on_disconnect + self._feeder_pubkey_prefix_provider = feeder_pubkey_prefix_provider self._running = False self._task: Optional[asyncio.Task] = None @@ -51,7 +54,12 @@ def __init__( self._backoff = 1.0 # Reset on successful connect so reconnects start fast def _get_ws_endpoint(self) -> str: - return f"{self.ws_url}/ws/nodes/?api_key={self.api_key}" + url = f"{self.ws_url}/ws/nodes/?api_key={self.api_key}" + if self._feeder_pubkey_prefix_provider: + prefix = self._feeder_pubkey_prefix_provider() + if prefix: + url += f"&feeder_pubkey_prefix={quote(prefix, safe='')}" + return url def start(self): """Start the WebSocket client in a background thread.""" @@ -133,7 +141,15 @@ async def _connect_and_receive(self): ping_timeout=10, ) as ws: self._backoff = 1.0 # Reset so next reconnect starts with short delay - logger.info("MeshflowWSClient: connected") + prefix = ( + self._feeder_pubkey_prefix_provider() + if self._feeder_pubkey_prefix_provider + else None + ) + logger.info( + "MeshflowWSClient: connected (feeder_pubkey_prefix=%s)", + prefix or "none", + ) if self.on_connect: try: self.on_connect() diff --git a/test/meshcore/test_channels.py b/test/meshcore/test_channels.py index c5c7e53..10388b7 100644 --- a/test/meshcore/test_channels.py +++ b/test/meshcore/test_channels.py @@ -69,6 +69,20 @@ def test_channel_entry_empty_name_returns_none() -> None: assert _channel_entry_from_info(0, {"channel_name": " "}) is None +def test_read_device_channels_logs_scan_when_empty(caplog) -> None: + import logging + + caplog.set_level(logging.WARNING) + mc = MagicMock() + mc.commands.get_channel = AsyncMock( + return_value=Event(EventType.ERROR, {"reason": "not_found"}, {}) + ) + channels = asyncio.run(read_device_channels(mc, max_channels=2)) + assert channels == [] + assert "get_channel scan found 0 named channels" in caplog.text + assert "[0] ERROR" in caplog.text + + def test_read_device_channels_collects_public_and_skips_errors() -> None: mc = MagicMock() mc.commands.get_channel = AsyncMock(