mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-06 17:03:27 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user