Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/MESHCORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/meshcore/channel_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions src/meshcore/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,28 @@ 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,
*,
max_channels: int = DEFAULT_MAX_CHANNEL_SCAN,
) -> 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:
Expand All @@ -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


Expand Down
20 changes: 18 additions & 2 deletions src/ws_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
import logging
from typing import Callable, Optional
from urllib.parse import quote

logger = logging.getLogger(__name__)

Expand All @@ -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:
Expand All @@ -44,14 +46,20 @@ 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
self._loop: Optional[asyncio.AbstractEventLoop] = None
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."""
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 14 additions & 0 deletions test/meshcore/test_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down