data: register meshcore channel mappings (#695)

* data: register meshcore channel mappings

* fix: use mc.commands.get_channel for MeshCore channel name probing

MeshCore exposes device commands via the commands sub-object
(CommandHandler), not directly on MeshCore instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: probe all channel indices regardless of ERROR responses

Removed the consecutive-error early-stop heuristic from
_ensure_channel_names so sparse channel configurations (e.g. slots 0
and 5 configured with slots 1–4 empty) are fully probed. Only a hard
exception aborts the loop early.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
l5y
2026-04-05 13:36:03 +02:00
committed by GitHub
parent d33ebd8f4c
commit adc122fce0
4 changed files with 318 additions and 3 deletions
+38
View File
@@ -273,6 +273,43 @@ def is_hidden_channel(channel_name_value: str | None) -> bool:
return False
def register_channel(channel_idx: int, channel_name_value: str) -> None:
"""Register a single channel index → name mapping.
Unlike :func:`capture_from_interface`, which scans a complete interface
object in one shot, this function registers entries one at a time. It is
intended for protocols (e.g. MeshCore) that expose channel metadata via
per-index requests rather than a bulk channel list.
Idempotent: silently skips if *channel_idx* is already cached or
*channel_name_value* is blank, matching the first-seen-wins semantics of
:func:`capture_from_interface`.
Parameters:
channel_idx: Zero-based channel index.
channel_name_value: Human-readable channel name reported by the device.
"""
global _CHANNEL_MAPPINGS, _CHANNEL_LOOKUP
if not isinstance(channel_name_value, str) or not channel_name_value.strip():
return
if channel_idx in _CHANNEL_LOOKUP:
return
name = channel_name_value.strip()
_CHANNEL_LOOKUP[channel_idx] = name
_CHANNEL_MAPPINGS = tuple(sorted(_CHANNEL_LOOKUP.items()))
config._debug_log(
"Registered channel",
context="channels.register",
severity="info",
channel_idx=channel_idx,
channel_name=name,
)
def _reset_channel_cache() -> None:
"""Clear cached channel data. Intended for use in tests only."""
@@ -285,6 +322,7 @@ __all__ = [
"capture_from_interface",
"channel_mappings",
"channel_name",
"register_channel",
"allowed_channel_names",
"hidden_channel_names",
"is_allowed_channel",
+68 -3
View File
@@ -442,6 +442,51 @@ class _MeshcoreInterface:
thread.join(timeout=5.0)
# ---------------------------------------------------------------------------
# Channel name resolution
# ---------------------------------------------------------------------------
async def _ensure_channel_names(mc: object, max_idx: int = 8) -> None:
"""Probe channel names from the device and populate the channel cache.
Iterates indices 0 through *max_idx* - 1, requesting each via
:meth:`~meshcore.MeshCore.commands.get_channel`. The responses arrive as
:attr:`~meshcore.EventType.CHANNEL_INFO` events and are registered into
the shared channel cache via :func:`~data.mesh_ingestor.channels.register_channel`.
Probes every index in ``range(max_idx)`` without early-stopping on
consecutive ``ERROR`` responses, so sparse configurations (e.g. slots 0
and 5 configured, slots 1-4 empty) are handled correctly. Only a hard
exception (connection loss, timeout) aborts the loop early.
Parameters:
mc: Connected :class:`~meshcore.MeshCore` instance.
max_idx: Upper bound (exclusive) for channel indices to probe.
MeshCore companion firmware typically configures at most 8
channels (indices 07).
"""
from .. import channels as _channels
for idx in range(max_idx):
try:
evt = await mc.commands.get_channel(idx)
if evt.type == EventType.CHANNEL_INFO:
name = (evt.payload or {}).get("channel_name", "")
if name:
_channels.register_channel(idx, name)
# ERROR response — unconfigured slot; continue to next index
except Exception as exc:
config._debug_log(
"Channel probe failed",
context="meshcore.channels",
severity="warning",
channel_idx=idx,
error=str(exc),
)
break
# ---------------------------------------------------------------------------
# Handler logic helpers (module-level to keep _make_event_handlers lean)
# ---------------------------------------------------------------------------
@@ -557,11 +602,19 @@ def _make_event_handlers(iface: _MeshcoreInterface, target: str | None) -> dict:
Returns:
Mapping of ``EventType`` member name → async callback coroutine.
"""
# Deferred import to avoid a circular dependency: meshcore.py is imported by
# providers/__init__.py which is imported by the top-level mesh_ingestor
# package, while handlers.py imports from that same package.
# Deferred imports to avoid a circular dependency: meshcore.py is imported by
# protocols/__init__.py which is imported by the top-level mesh_ingestor
# package, while handlers.py and channels.py import from that same package.
from .. import channels as _channels
from .. import handlers as _handlers
async def on_channel_info(evt) -> None:
payload = evt.payload or {}
idx = payload.get("channel_idx")
name = payload.get("channel_name", "")
if idx is not None and name:
_channels.register_channel(idx, name)
async def on_self_info(evt) -> None:
_process_self_info(evt.payload or {}, iface, _handlers)
@@ -645,6 +698,7 @@ def _make_event_handlers(iface: _MeshcoreInterface, target: str | None) -> dict:
)
return {
"CHANNEL_INFO": on_channel_info,
"SELF_INFO": on_self_info,
"CONTACTS": on_contacts,
"NEW_CONTACT": on_contact_update,
@@ -779,6 +833,17 @@ async def _run_meshcore(
error=str(exc),
)
try:
await _ensure_channel_names(mc)
except Exception as exc:
config._debug_log(
"Failed to fetch channel names",
context="meshcore.channels",
severity="warning",
always=True,
error=str(exc),
)
await mc.start_auto_message_fetching()
await stop_event.wait()
+51
View File
@@ -421,3 +421,54 @@ class TestIsHiddenChannel:
"""Non-configured names are not hidden."""
monkeypatch.setattr(config, "HIDDEN_CHANNELS", ("Chat",))
assert channels.is_hidden_channel("LongFast") is False
# ---------------------------------------------------------------------------
# register_channel
# ---------------------------------------------------------------------------
class TestRegisterChannel:
"""Tests for :func:`channels.register_channel`."""
def test_adds_to_lookup(self):
"""register_channel must make the name retrievable via channel_name."""
channels.register_channel(1, "Chat")
assert channels.channel_name(1) == "Chat"
def test_no_overwrite(self):
"""Second call with same index must not replace the first-registered name."""
channels.register_channel(0, "LongFast")
channels.register_channel(0, "Other")
assert channels.channel_name(0) == "LongFast"
def test_strips_whitespace(self):
"""Leading and trailing whitespace is stripped from the channel name."""
channels.register_channel(2, " Chat ")
assert channels.channel_name(2) == "Chat"
def test_ignores_empty_string(self):
"""Empty string is silently ignored and does not populate the cache."""
channels.register_channel(3, "")
assert channels.channel_name(3) is None
def test_ignores_whitespace_only_string(self):
"""Whitespace-only name is silently ignored."""
channels.register_channel(3, " ")
assert channels.channel_name(3) is None
def test_updates_mappings_tuple(self):
"""channel_mappings() reflects all registered entries, sorted by index."""
channels.register_channel(2, "Admin")
channels.register_channel(0, "LongFast")
assert channels.channel_mappings() == ((0, "LongFast"), (2, "Admin"))
def test_coexists_with_capture_from_interface(self):
"""Entries from register_channel and capture_from_interface merge correctly."""
# Simulate capture_from_interface populating index 0.
channels._CHANNEL_LOOKUP[0] = "LongFast"
channels._CHANNEL_MAPPINGS = ((0, "LongFast"),)
# register_channel should add index 1 without disturbing index 0.
channels.register_channel(1, "Chat")
assert channels.channel_name(0) == "LongFast"
assert channels.channel_name(1) == "Chat"
+161
View File
@@ -33,11 +33,13 @@ from data.mesh_ingestor.protocols.meshtastic import ( # noqa: E402 - path setup
)
from data.mesh_ingestor.connection import parse_tcp_target # noqa: E402 - path setup
from data.mesh_ingestor.protocols.meshcore import ( # noqa: E402 - path setup
EventType,
MeshcoreProvider,
_MeshcoreInterface,
_contact_to_node_dict,
_derive_message_id,
_derive_modem_preset,
_ensure_channel_names,
_make_connection,
_make_event_handlers,
_meshcore_adv_type_to_role,
@@ -1179,6 +1181,158 @@ def test_derive_modem_preset_none_on_missing():
assert _derive_modem_preset(12, 125.0, 0) is None
# ---------------------------------------------------------------------------
# _ensure_channel_names
# ---------------------------------------------------------------------------
def _make_fake_mc_for_channels(channel_map: dict):
"""Build a minimal fake MeshCore instance for channel-probe tests.
Parameters:
channel_map: Mapping of channel_idx channel_name string, or
``None`` to simulate an ERROR response for that index.
"""
import asyncio
class _FakeCommands:
async def get_channel(self, idx):
name = channel_map.get(idx)
if name is None:
return types.SimpleNamespace(
type=EventType.ERROR, payload={"reason": "not_found"}
)
return types.SimpleNamespace(
type=EventType.CHANNEL_INFO,
payload={"channel_idx": idx, "channel_name": name},
)
mc = types.SimpleNamespace(commands=_FakeCommands())
return mc
def test_ensure_channel_names_populates_cache(monkeypatch):
"""Channel names returned by the device must be registered in the cache."""
import asyncio
import data.mesh_ingestor.protocols.meshcore as _mod
import data.mesh_ingestor.channels as _channels
_channels._reset_channel_cache()
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
fake_mc = _make_fake_mc_for_channels({0: "LongFast", 1: "Chat"})
asyncio.run(_ensure_channel_names(fake_mc, max_idx=4))
assert _channels.channel_name(0) == "LongFast"
assert _channels.channel_name(1) == "Chat"
_channels._reset_channel_cache()
def test_ensure_channel_names_tolerates_error_response(monkeypatch):
"""An ERROR for one index must not prevent subsequent indices from registering."""
import asyncio
import data.mesh_ingestor.protocols.meshcore as _mod
import data.mesh_ingestor.channels as _channels
_channels._reset_channel_cache()
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
# Index 0 returns ERROR; index 1 returns a valid name.
fake_mc = _make_fake_mc_for_channels({1: "Chat"})
asyncio.run(_ensure_channel_names(fake_mc, max_idx=4))
assert _channels.channel_name(0) is None
assert _channels.channel_name(1) == "Chat"
_channels._reset_channel_cache()
def test_ensure_channel_names_probes_all_indices_on_sparse_config(monkeypatch):
"""All indices must be probed even when earlier slots return ERROR.
Sparse configurations (e.g. slots 0 and 5 configured, 1-4 empty) must
not be truncated by consecutive-error heuristics.
"""
import asyncio
import data.mesh_ingestor.protocols.meshcore as _mod
import data.mesh_ingestor.channels as _channels
_channels._reset_channel_cache()
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
# Slots 0-4 return ERROR; slot 5 is configured.
channel_map = {5: "Admin"}
class _FakeCommands:
async def get_channel(self, idx):
name = channel_map.get(idx)
if name is None:
return types.SimpleNamespace(
type=EventType.ERROR, payload={"reason": "not_found"}
)
return types.SimpleNamespace(
type=EventType.CHANNEL_INFO,
payload={"channel_idx": idx, "channel_name": name},
)
asyncio.run(
_ensure_channel_names(
types.SimpleNamespace(commands=_FakeCommands()), max_idx=8
)
)
# Slot 5 must be registered despite the preceding empty slots.
assert _channels.channel_name(5) == "Admin"
assert _channels.channel_name(0) is None
_channels._reset_channel_cache()
def test_ensure_channel_names_stops_on_exception(monkeypatch):
"""An exception during get_channel must abort the probe without propagating."""
import asyncio
import data.mesh_ingestor.protocols.meshcore as _mod
import data.mesh_ingestor.channels as _channels
_channels._reset_channel_cache()
logged: list = []
monkeypatch.setattr(
_mod.config,
"_debug_log",
lambda *_a, severity=None, **_k: logged.append(severity),
)
class _FakeCommands:
async def get_channel(self, idx):
raise OSError("serial port disconnected")
# Must complete without raising.
asyncio.run(
_ensure_channel_names(
types.SimpleNamespace(commands=_FakeCommands()), max_idx=4
)
)
assert "warning" in logged
_channels._reset_channel_cache()
def test_on_channel_info_handler_registers_channel(monkeypatch):
"""CHANNEL_INFO event delivered to the handler must populate the channel cache."""
import asyncio
import data.mesh_ingestor.channels as _channels
import data.mesh_ingestor.protocols.meshcore as _mod
_channels._reset_channel_cache()
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
iface = _MeshcoreInterface(target=None)
handlers_map = _make_event_handlers(iface, "/dev/ttyUSB0")
evt = types.SimpleNamespace(payload={"channel_idx": 2, "channel_name": "Admin"})
asyncio.run(handlers_map["CHANNEL_INFO"](evt))
assert _channels.channel_name(2) == "Admin"
_channels._reset_channel_cache()
# ---------------------------------------------------------------------------
# _process_contacts
# ---------------------------------------------------------------------------
@@ -1481,6 +1635,7 @@ def _make_fake_meshcore_mod(
EventType = enum.Enum(
"EventType",
[
"CHANNEL_INFO",
"SELF_INFO",
"CONTACTS",
"NEW_CONTACT",
@@ -1500,9 +1655,15 @@ def _make_fake_meshcore_mod(
],
)
class _FakeCommands:
async def get_channel(self, idx):
# Return ERROR for all channels — channel probing is not under test here.
return types.SimpleNamespace(type=EventType.ERROR, payload={})
class _FakeMeshCore:
def __init__(self, cx):
self._catch_all = None
self.commands = _FakeCommands()
def subscribe(self, event_type, callback):
if event_type is None: