diff --git a/data/mesh_ingestor/channels.py b/data/mesh_ingestor/channels.py index 10db5d1..7547354 100644 --- a/data/mesh_ingestor/channels.py +++ b/data/mesh_ingestor/channels.py @@ -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", diff --git a/data/mesh_ingestor/protocols/meshcore.py b/data/mesh_ingestor/protocols/meshcore.py index e24e8ed..ac34dea 100644 --- a/data/mesh_ingestor/protocols/meshcore.py +++ b/data/mesh_ingestor/protocols/meshcore.py @@ -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 0–7). + """ + 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() diff --git a/tests/test_channels_unit.py b/tests/test_channels_unit.py index c38a0e2..5428136 100644 --- a/tests/test_channels_unit.py +++ b/tests/test_channels_unit.py @@ -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" diff --git a/tests/test_provider_unit.py b/tests/test_provider_unit.py index 1bde298..072e3d5 100644 --- a/tests/test_provider_unit.py +++ b/tests/test_provider_unit.py @@ -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: