From 24cb6a31df87cc45e17c26748c0ed51a6dac0c57 Mon Sep 17 00:00:00 2001 From: malice Date: Thu, 2 Jul 2026 16:49:59 -0600 Subject: [PATCH] feat(dashboard): MeshCore transport + per-family routing GUI controls (#10) * feat(dashboard): MeshCore transport + per-family routing GUI controls Add Transport mode selector (Meshtastic/MeshCore/Both) and MeshCore host/port fields to the Config Connection section, and an independent per-family "MeshCore channel" number input in Notifications (blank = not broadcast on MeshCore, sends null). Extends the ConnectionConfig and per-family toggle TS types. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(routing): MeshCore routing by channel name, not index MeshCore channels are {name,PSK} (up to 40+ slots, not Meshtastic's 0-7). The send index is a fragile slot position, so store the channel NAME per family and resolve name->slot against the companion's live channel table at send time; never blind-send to an unresolved slot. GUI field becomes a channel-name text box. meshtastic path unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(routing): thread per-family meshcore_channel through the broadcast send path MeshBroadcastChannel now carries the rule's meshcore_channel name and passes it to send_message, so per-family MeshCore routing actually fires end-to-end (dispatcher -> channel -> composite -> MeshCoreTransport). Meshtastic path unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.8 (1M context) --- work/dashboard-frontend/src/pages/Config.tsx | 40 +++++++ .../src/pages/Notifications.tsx | 12 ++ work/meshai/config.py | 5 +- work/meshai/connector.py | 4 +- work/meshai/notifications/channels.py | 10 +- .../notifications/pipeline/dispatcher.py | 1 + work/meshai/transport/base.py | 8 +- work/meshai/transport/composite_transport.py | 9 +- work/meshai/transport/meshcore_transport.py | 89 ++++++++++++++- work/tests/test_channel_rendering.py | 108 ++++++++++++++++++ work/tests/test_composite_transport.py | 16 +-- work/tests/test_config_loader.py | 25 +++- work/tests/test_meshcore_transport.py | 74 +++++++++--- work/tests/test_uniform_sizing.py | 2 - 14 files changed, 358 insertions(+), 45 deletions(-) diff --git a/work/dashboard-frontend/src/pages/Config.tsx b/work/dashboard-frontend/src/pages/Config.tsx index 1b2a455..b8da989 100644 --- a/work/dashboard-frontend/src/pages/Config.tsx +++ b/work/dashboard-frontend/src/pages/Config.tsx @@ -23,6 +23,9 @@ interface ConnectionConfig { serial_port: string tcp_host: string tcp_port: number + transport?: string + meshcore_host?: string + meshcore_port?: number } interface ResponseConfig { @@ -707,9 +710,23 @@ function BotSection({ data, onChange }: { data: BotConfig; onChange: (d: BotConf } function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChange: (d: ConnectionConfig) => void }) { + const transport = data.transport ?? 'meshtastic' + const showMeshCore = transport === 'meshcore' || transport === 'both' return (
+ onChange({ ...data, transport: v })} + options={[ + { value: 'meshtastic', label: 'Meshtastic' }, + { value: 'meshcore', label: 'MeshCore' }, + { value: 'both', label: 'Both' }, + ]} + helper="Which radio transport(s) MeshAI uses" + info="Meshtastic: connect to a Meshtastic radio only. MeshCore: connect to a MeshCore node only. Both: connect to both simultaneously for dual-transport operation." + />
)} + {showMeshCore && ( +
+
MeshCore Connection
+
+ onChange({ ...data, meshcore_host: v })} + placeholder="192.168.1.100" + helper="IP or hostname of the MeshCore node" + info="Address of the MeshCore node to connect to." + /> + onChange({ ...data, meshcore_port: v })} + min={1} + max={65535} + helper="MeshCore TCP port (default 5525)" + /> +
+
+ )} ) } diff --git a/work/dashboard-frontend/src/pages/Notifications.tsx b/work/dashboard-frontend/src/pages/Notifications.tsx index 5e49212..e5fe133 100644 --- a/work/dashboard-frontend/src/pages/Notifications.tsx +++ b/work/dashboard-frontend/src/pages/Notifications.tsx @@ -46,6 +46,7 @@ interface NotificationToggle { regions: string[] severity_channels: Record broadcast_channel: number | null + meshcore_channel?: string | null node_ids: string[] smtp_host: string smtp_port: number @@ -1611,6 +1612,17 @@ function MasterToggles({ toggles, onChange }: { upd(key, { regions: v })} placeholder="Add region..." />
Channel config
upd(key, { broadcast_channel: v })} /> +
+ + upd(key, { meshcore_channel: e.target.value === '' ? null : e.target.value })} + placeholder="" + className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent" + /> +

MeshCore channel name on your companion (e.g. AIDA); blank = not broadcast on MeshCore.

+
upd(key, { node_ids: v })} placeholder="!nodeid" /> upd(key, { recipients: v })} placeholder="ops@example.com" /> upd(key, { smtp_host: v })} placeholder="smtp.example.com" /> diff --git a/work/meshai/config.py b/work/meshai/config.py index 1b1491c..6c496bd 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -41,7 +41,6 @@ class ConnectionConfig: # --- MeshCore transport settings (used when transport="meshcore") --- meshcore_host: str = "100.64.0.9" # pyMC companion frame server host meshcore_port: int = 5050 # pyMC companion frame server port - meshcore_channel_index: int = 0 # default channel index for broadcasts meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited) @@ -555,6 +554,8 @@ class NotificationRuleConfig: # Mesh broadcast fields broadcast_channel: int = 0 + # Per-family MeshCore channel NAME on the companion; None = not broadcast on MeshCore. + meshcore_channel: Optional[str] = None # Mesh DM fields node_ids: list = field(default_factory=list) @@ -596,6 +597,8 @@ class NotificationToggle: cooldown_seconds: int = 0 # per (toggle, category, region) throttle window; 0 = disabled # per-channel delivery config (mirrors NotificationRuleConfig channel fields) broadcast_channel: Optional[int] = None + # Per-family MeshCore channel NAME on the companion; None = not broadcast on MeshCore. + meshcore_channel: Optional[str] = None node_ids: list = field(default_factory=list) smtp_host: str = "" smtp_port: int = 587 diff --git a/work/meshai/connector.py b/work/meshai/connector.py index a474225..cbed018 100644 --- a/work/meshai/connector.py +++ b/work/meshai/connector.py @@ -355,7 +355,7 @@ class MeshtasticTransport(MeshTransport): destination: Optional[str] = None, channel: int = 0, transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl - meshcore_channel: Optional[int] = None, # per-family MeshCore channel — accepted and IGNORED here + meshcore_channel: Optional[str] = None, # per-family MeshCore channel — accepted and IGNORED here ) -> bool: """Send a text message. @@ -364,7 +364,7 @@ class MeshtasticTransport(MeshTransport): destination: Node ID for DM, or None for broadcast channel: Channel index to send on transport: Optional routing hint (for CompositeTransport); ignored here. - meshcore_channel: Per-family MeshCore channel index; ignored by Meshtastic. + meshcore_channel: Per-family MeshCore channel name; ignored by Meshtastic. Returns: True if send was initiated successfully diff --git a/work/meshai/notifications/channels.py b/work/meshai/notifications/channels.py index 9b1a3c2..555d5d3 100644 --- a/work/meshai/notifications/channels.py +++ b/work/meshai/notifications/channels.py @@ -60,9 +60,13 @@ class MeshBroadcastChannel(NotificationChannel): channel_type = "mesh_broadcast" - def __init__(self, connector: "MeshConnector", channel_index: int = 0): + def __init__(self, connector: "MeshConnector", channel_index: int = 0, + meshcore_channel: Optional[str] = None): self._connector = connector self._channel = channel_index + # Per-family MeshCore channel NAME (None = MeshCore child skipped + # downstream). Ignored by Meshtastic; behavior-preserving there. + self._meshcore_channel = meshcore_channel _mc = getattr(connector, "max_chars", 200) self._renderer = MeshRenderer(char_limit=_mc if isinstance(_mc, int) else 200) @@ -79,6 +83,7 @@ class MeshBroadcastChannel(NotificationChannel): text=alert.message or "", destination=None, channel=self._channel, + meshcore_channel=self._meshcore_channel, ) logger.info("Broadcast pre-chunked alert to channel %d", self._channel) return True @@ -90,6 +95,7 @@ class MeshBroadcastChannel(NotificationChannel): text=chunk, destination=None, channel=self._channel, + meshcore_channel=self._meshcore_channel, ) logger.info("Broadcast %d chunk(s) to channel %d", len(chunks), self._channel) return True @@ -780,6 +786,7 @@ def create_channel(rule: "NotificationRuleConfig", connector=None) -> Notificati return MeshBroadcastChannel( connector=connector, channel_index=rule.broadcast_channel, + meshcore_channel=getattr(rule, "meshcore_channel", None), ) elif delivery_type == "mesh_dm": return MeshDMChannel( @@ -816,6 +823,7 @@ def create_channel_from_dict(config: dict, connector=None) -> NotificationChanne return MeshBroadcastChannel( connector=connector, channel_index=config.get("channel_index", 0), + meshcore_channel=config.get("meshcore_channel"), ) elif channel_type == "mesh_dm": return MeshDMChannel( diff --git a/work/meshai/notifications/pipeline/dispatcher.py b/work/meshai/notifications/pipeline/dispatcher.py index b93d7b3..a93bfa1 100644 --- a/work/meshai/notifications/pipeline/dispatcher.py +++ b/work/meshai/notifications/pipeline/dispatcher.py @@ -660,6 +660,7 @@ class Dispatcher: name=f"toggle:{getattr(tog, 'name', '')}", enabled=True, trigger_type="condition", delivery_type=ch_type, broadcast_channel=(getattr(tog, "broadcast_channel", None) or 0), + meshcore_channel=getattr(tog, "meshcore_channel", None), node_ids=list(getattr(tog, "node_ids", []) or []), smtp_host=getattr(tog, "smtp_host", ""), smtp_port=getattr(tog, "smtp_port", 587), smtp_user=getattr(tog, "smtp_user", ""), smtp_password=getattr(tog, "smtp_password", ""), diff --git a/work/meshai/transport/base.py b/work/meshai/transport/base.py index 77a0262..2d69d07 100644 --- a/work/meshai/transport/base.py +++ b/work/meshai/transport/base.py @@ -37,7 +37,7 @@ class MeshTransport(abc.ABC): destination: Optional[str] = None, channel: int = 0, transport: Optional[str] = None, - meshcore_channel: Optional[int] = None, + meshcore_channel: Optional[str] = None, ) -> bool: """Send a text message. @@ -49,9 +49,9 @@ class MeshTransport(abc.ABC): select the child mesh that originated an inbound DM). Single-transport implementations accept and IGNORE this parameter; it is always None in non-composite callers. - meshcore_channel: Per-family MeshCore channel index for broadcasts. - MeshtasticTransport ignores this; MeshCoreTransport uses - it in place of the global meshcore_channel_index when set. + meshcore_channel: Per-family MeshCore channel NAME for broadcasts. + MeshtasticTransport ignores this; MeshCoreTransport + resolves the name to a companion slot at send time. CompositeTransport uses it to route each child correctly. None = do not broadcast on MeshCore for this family. diff --git a/work/meshai/transport/composite_transport.py b/work/meshai/transport/composite_transport.py index 63aaa30..572b95e 100644 --- a/work/meshai/transport/composite_transport.py +++ b/work/meshai/transport/composite_transport.py @@ -196,7 +196,7 @@ class CompositeTransport(MeshTransport): destination: Optional[str] = None, channel: int = 0, transport: Optional[str] = None, - meshcore_channel: Optional[int] = None, + meshcore_channel: Optional[str] = None, ) -> bool: """Send a message, routing based on destination + hint. @@ -233,14 +233,15 @@ class CompositeTransport(MeshTransport): # --- Rule 3: unhinted DM --- return self._send_unhinted(text, destination, channel) - def _broadcast(self, text: str, channel: int, meshcore_channel: Optional[int] = None) -> bool: + def _broadcast(self, text: str, channel: int, meshcore_channel: Optional[str] = None) -> bool: """Fan text out to connected children with per-transport channel routing. For the Meshtastic child, ``channel`` (Meshtastic channel index) is used. For the MeshCore child: - - ``meshcore_channel`` set → use that channel index on MeshCore. + - ``meshcore_channel`` set → route that channel NAME to MeshCore, + which resolves it to a companion slot at send time. - ``meshcore_channel`` is None → skip the MeshCore child entirely - (family not configured for MeshCore; no fallback to global index). + (family not configured for MeshCore; no fallback to a default). Returns True if at least one child succeeded. """ diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index 3282cf8..d432345 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -51,6 +51,9 @@ class MeshCoreTransport(MeshTransport): self._message_callback: Optional[Callable] = None self._callback_loop: Optional[asyncio.AbstractEventLoop] = None self._loop_ready: threading.Event = threading.Event() + # Companion channel table: channel NAME -> slot index, built at + # connect time by _enumerate_channels(). Empty until connected. + self._chan_name_to_idx: dict[str, int] = {} # ------------------------------------------------------------------ # Internal helpers @@ -78,6 +81,63 @@ class MeshCoreTransport(MeshTransport): self._loop = None self._loop_thread = None + # ------------------------------------------------------------------ + # Channel table enumeration + # ------------------------------------------------------------------ + + def _enumerate_channels(self) -> None: + """Build ``self._chan_name_to_idx`` from the companion's channel table. + + MeshCore channels are {name, PSK} pairs living in numbered slots (up + to 40+, unlike Meshtastic's 0-7). We ask the companion for each slot + in turn via ``get_channel(idx)`` and record NAME → slot for every + named (non-empty) slot, so send_message can resolve a per-family + channel NAME to the right slot at send time. + + Robustness: + - Any error yields an empty (or partial) map — never raises out. + - Enumeration stops on the first error/None result (end of table) + or after 3 consecutive empty slots (contiguous provisioning), + with a hard cap of 40 slots. + """ + self._chan_name_to_idx = {} + try: + empty_run = 0 + for idx in range(40): + try: + event = self._run_coro(self._mc.commands.get_channel(idx)) + except Exception as exc: + logger.debug( + "MeshCore: get_channel(%d) failed, ending enumeration: %s", + idx, exc, + ) + break + # Falsy / None / ERROR event → end of enumeration. + if not event: + break + is_err = getattr(event, "is_error", None) + if callable(is_err) and event.is_error(): + break + payload = event.payload or {} + name = payload.get("channel_name", "") + slot = payload.get("channel_idx", idx) + if not name: + # Empty/unset slot; stop after a contiguous run of empties. + empty_run += 1 + if empty_run >= 3: + break + continue + # Named slot: exact, case-sensitive (firmware name is already + # null-truncated / utf-8-decoded — do NOT trim or lowercase). + self._chan_name_to_idx[name] = slot + empty_run = 0 + except Exception as exc: + logger.warning("MeshCore: channel enumeration error: %s", exc) + self._chan_name_to_idx = {} + logger.info( + "MeshCore: enumerated %d named channel(s)", len(self._chan_name_to_idx) + ) + # ------------------------------------------------------------------ # Internal coroutines (run on the dedicated loop) # ------------------------------------------------------------------ @@ -166,6 +226,10 @@ class MeshCoreTransport(MeshTransport): # Subscribe to inbound events on the dedicated loop. self._run_coro(self._setup_subscriptions()) + # Build the channel NAME → slot map from the live companion table so + # per-family broadcasts can resolve their channel name to a slot. + self._enumerate_channels() + logger.info( "MeshCoreTransport: connected as %s (pubkey %s)", self._self_info.get("name", "unknown"), @@ -194,7 +258,7 @@ class MeshCoreTransport(MeshTransport): destination: Optional[str] = None, channel: int = 0, transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl - meshcore_channel: Optional[int] = None, + meshcore_channel: Optional[str] = None, ) -> bool: """Send a message via MeshCore. @@ -204,10 +268,11 @@ class MeshCoreTransport(MeshTransport): destination: hex pubkey string for a DM, or None for channel send. channel: Channel index for channel sends (Meshtastic semantics; ignored here). transport: Optional routing hint (for CompositeTransport); ignored here. - meshcore_channel: Per-family MeshCore channel index for broadcasts. - When provided, overrides the global meshcore_channel_index. + meshcore_channel: Per-family MeshCore channel NAME for broadcasts. + Resolved to a companion slot via the live channel table. When None on a broadcast, the send is skipped (family not configured for MeshCore — no fallback, no default). + An unknown name is never blind-sent: it warns and returns False. Returns: True if the send succeeded (not an error event). @@ -231,7 +296,7 @@ class MeshCoreTransport(MeshTransport): # index 8) that have no relationship to MeshCore's separate # channel table. # - # Per-family routing: use meshcore_channel when provided. + # Per-family routing: meshcore_channel is a channel NAME. # If meshcore_channel is None, this family is not configured # for MeshCore → silent no-op (return True). if meshcore_channel is None: @@ -239,8 +304,22 @@ class MeshCoreTransport(MeshTransport): "MeshCoreTransport: meshcore_channel=None, skipping broadcast" ) return True + # Resolve NAME → slot against the live companion channel table. + idx = self._chan_name_to_idx.get(meshcore_channel) + if idx is None: + # One lazy re-enumeration in case the table changed since + # connect (e.g. a channel was provisioned after startup). + self._enumerate_channels() + idx = self._chan_name_to_idx.get(meshcore_channel) + if idx is None: + # Never blind-send to a guessed slot. + logger.warning( + "MeshCore channel '%s' not on companion; skipping", + meshcore_channel, + ) + return False result = self._run_coro( - self._mc.commands.send_chan_msg(meshcore_channel, text) + self._mc.commands.send_chan_msg(idx, text) ) success = not result.is_error() if not success: diff --git a/work/tests/test_channel_rendering.py b/work/tests/test_channel_rendering.py index c69c91a..945127d 100644 --- a/work/tests/test_channel_rendering.py +++ b/work/tests/test_channel_rendering.py @@ -212,3 +212,111 @@ def test_webhook_channel_uses_webhook_renderer(): assert "schema_version" in json_payload assert json_payload["schema_version"] == "1.0" assert json_payload["message"] == "Test webhook message" + + +# ============================================================ +# PER-FAMILY MESHCORE ROUTING — end-to-end threading guard +# (regression guard for the broadcast send-path gap) +# ============================================================ + +def test_broadcast_threads_meshcore_channel_through_factory(): + """create_channel(rule) -> MeshBroadcastChannel.deliver must pass BOTH + channel= AND meshcore_channel= to send_message. + + This is the regression guard for the gap where the rule's + meshcore_channel never reached connector.send_message. + """ + from meshai.config import NotificationRuleConfig + from meshai.notifications.channels import create_channel + + mock_connector = MagicMock() + rule = NotificationRuleConfig( + name="toggle:fire", + delivery_type="mesh_broadcast", + broadcast_channel=1, + meshcore_channel="AIDA", + ) + + channel = create_channel(rule, mock_connector) + + # Pre-chunked payload => exactly one deterministic send_message call. + payload = NotificationPayload( + message="fire alert", + category="fire", + severity="immediate", + timestamp=time.time(), + event_type="fire", + chunk_index=0, + ) + + assert asyncio.run(channel.deliver(payload, rule)) is True + + mock_connector.send_message.assert_called_once() + kwargs = mock_connector.send_message.call_args.kwargs + assert kwargs.get("channel") == 1 + # The load-bearing assertion: the name was NOT dropped. + assert kwargs.get("meshcore_channel") == "AIDA" + + +def test_broadcast_meshcore_channel_none_passed_through(): + """meshcore_channel=None (family not on MeshCore) => send_message still + receives meshcore_channel=None (MeshCore child skipped downstream).""" + from meshai.config import NotificationRuleConfig + from meshai.notifications.channels import create_channel + + mock_connector = MagicMock() + rule = NotificationRuleConfig( + name="toggle:weather", + delivery_type="mesh_broadcast", + broadcast_channel=0, + meshcore_channel=None, + ) + + channel = create_channel(rule, mock_connector) + + payload = NotificationPayload( + message="weather alert", + category="weather_warning", + severity="priority", + timestamp=time.time(), + event_type="weather_warning", + chunk_index=0, + ) + + assert asyncio.run(channel.deliver(payload, rule)) is True + + kwargs = mock_connector.send_message.call_args.kwargs + assert kwargs.get("channel") == 0 + assert "meshcore_channel" in kwargs + assert kwargs.get("meshcore_channel") is None + + +def test_broadcast_render_loop_threads_meshcore_channel(): + """Non-prechunked path (renderer loop) also threads meshcore_channel + on every chunk send.""" + from meshai.config import NotificationRuleConfig + from meshai.notifications.channels import create_channel + + mock_connector = MagicMock() + rule = NotificationRuleConfig( + name="toggle:fire", + delivery_type="mesh_broadcast", + broadcast_channel=2, + meshcore_channel="AIDA", + ) + channel = create_channel(rule, mock_connector) + + long_message = "This is a very long alert message that exceeds the limit. " * 5 + payload = NotificationPayload( + message=long_message, + category="fire", + severity="immediate", + timestamp=time.time(), + event_type="fire", + ) + + assert asyncio.run(channel.deliver(payload, rule)) is True + assert mock_connector.send_message.call_count >= 2 + for call in mock_connector.send_message.call_args_list: + assert call.kwargs.get("channel") == 2 + assert call.kwargs.get("meshcore_channel") == "AIDA" diff --git a/work/tests/test_composite_transport.py b/work/tests/test_composite_transport.py index 6bcf2ad..6ddd6f5 100644 --- a/work/tests/test_composite_transport.py +++ b/work/tests/test_composite_transport.py @@ -221,7 +221,7 @@ class TestBroadcast: mt = FakeChild("meshtastic") mc = FakeChild("meshcore") comp = CompositeTransport([mt, mc]) - result = comp.send_message("hello mesh", meshcore_channel=0) + result = comp.send_message("hello mesh", meshcore_channel="AIDA") assert result is True assert len(mt.send_calls) == 1 assert len(mc.send_calls) == 1 @@ -239,22 +239,22 @@ class TestBroadcast: assert len(mc.send_calls) == 0 # MeshCore was skipped def test_broadcast_meshcore_uses_meshcore_channel(self) -> None: - """MeshCore child receives meshcore_channel, Meshtastic receives channel.""" + """MeshCore child receives meshcore_channel NAME, Meshtastic receives channel.""" mt = FakeChild("meshtastic") mc = FakeChild("meshcore") comp = CompositeTransport([mt, mc]) - result = comp.send_message("hello", channel=1, meshcore_channel=3) + result = comp.send_message("hello", channel=1, meshcore_channel="Fire") assert result is True assert len(mt.send_calls) == 1 assert len(mc.send_calls) == 1 - assert mt.send_calls[0]["channel"] == 1 # Meshtastic gets `channel` - assert mc.send_calls[0]["channel"] == 3 # MeshCore gets `meshcore_channel` + assert mt.send_calls[0]["channel"] == 1 # Meshtastic gets `channel` + assert mc.send_calls[0]["channel"] == "Fire" # MeshCore gets `meshcore_channel` name def test_broadcast_meshtastic_only_when_no_meshcore_child(self) -> None: """When there is no MeshCore child, meshcore_channel is irrelevant.""" mt = FakeChild("meshtastic") comp = CompositeTransport([mt]) - result = comp.send_message("hello", channel=2, meshcore_channel=5) + result = comp.send_message("hello", channel=2, meshcore_channel="Fire") assert result is True assert len(mt.send_calls) == 1 assert mt.send_calls[0]["channel"] == 2 @@ -264,7 +264,7 @@ class TestBroadcast: mt = FakeChild("meshtastic", connected_val=False) mc = FakeChild("meshcore") comp = CompositeTransport([mt, mc]) - result = comp.send_message("hi", meshcore_channel=0) + result = comp.send_message("hi", meshcore_channel="AIDA") assert result is True assert len(mt.send_calls) == 0 assert len(mc.send_calls) == 1 @@ -275,7 +275,7 @@ class TestBroadcast: mt.send_message = MagicMock(return_value=False) mc = FakeChild("meshcore") comp = CompositeTransport([mt, mc]) - result = comp.send_message("test", meshcore_channel=0) + result = comp.send_message("test", meshcore_channel="AIDA") assert result is True diff --git a/work/tests/test_config_loader.py b/work/tests/test_config_loader.py index 727247f..9ea9d84 100644 --- a/work/tests/test_config_loader.py +++ b/work/tests/test_config_loader.py @@ -6,7 +6,13 @@ cfg.notifications.rules as raw dicts (which crashed Dispatcher._matching_rules on rule.enabled). config_loader.load_config uses this same _dict_to_dataclass. """ -from meshai.config import Config, NotificationRuleConfig, _dict_to_dataclass +from meshai.config import ( + Config, + NotificationRuleConfig, + NotificationToggle, + _dataclass_to_dict, + _dict_to_dataclass, +) def test_multifile_load_coerces_notification_rules(): @@ -61,3 +67,20 @@ def test_rules_attribute_access_does_not_raise(): _ = r.trigger_type _ = r.categories _ = r.min_severity + + +def test_toggle_meshcore_channel_name_round_trips(): + """A NotificationToggle's meshcore_channel NAME survives dict round-trip. + + _dict_to_dataclass drops unknown keys, so this guards that the new + meshcore_channel field is a real dataclass field and persists as a str. + """ + tog = NotificationToggle(name="fire", enabled=True, meshcore_channel="AIDA") + d = _dataclass_to_dict(tog) + assert d["meshcore_channel"] == "AIDA" + restored = _dict_to_dataclass(NotificationToggle, d) + assert restored.meshcore_channel == "AIDA" + + # Default stays None when unset. + default = _dict_to_dataclass(NotificationToggle, {"name": "weather"}) + assert default.meshcore_channel is None diff --git a/work/tests/test_meshcore_transport.py b/work/tests/test_meshcore_transport.py index 54add8c..d68c8d8 100644 --- a/work/tests/test_meshcore_transport.py +++ b/work/tests/test_meshcore_transport.py @@ -99,7 +99,6 @@ def _mc_config(**overrides): transport="meshcore", meshcore_host="127.0.0.1", meshcore_port=5050, - meshcore_channel_index=0, ) for k, v in overrides.items(): setattr(cfg, k, v) @@ -117,6 +116,7 @@ def _transport_with_mock_mc(mc_overrides=None): mc = MagicMock() mc.get_contact_by_key_prefix.return_value = None + _install_channel_table(mc) if mc_overrides: for k, v in mc_overrides.items(): setattr(mc, k, v) @@ -156,6 +156,31 @@ def _make_channel_event(text="chan msg", channel_idx=2, **extra): return e +# Default fake companion channel table: NAME -> slot. Slots not present here +# report an empty channel_name, so _enumerate_channels stops after the empty run. +_FAKE_CHANNEL_TABLE = {2: "AIDA", 3: "Fire"} + + +def _install_channel_table(mc, table=None): + """Wire ``mc.commands.get_channel`` to enumerate a fake channel table. + + ``table`` maps slot index -> channel name. get_channel(idx) returns a + non-error event whose payload carries ``channel_name``/``channel_idx`` + exactly like the real firmware; unlisted slots report an empty name so + _enumerate_channels ends on the contiguous-empty run. + """ + if table is None: + table = _FAKE_CHANNEL_TABLE + + async def _get_channel(idx): + e = MagicMock() + e.is_error.return_value = False + e.payload = {"channel_name": table.get(idx, ""), "channel_idx": idx} + return e + + mc.commands.get_channel = _get_channel + + # --------------------------------------------------------------------------- # 1. Factory / subclass tests # --------------------------------------------------------------------------- @@ -176,25 +201,25 @@ class TestBuildTransport: class TestSendMessageChannel: def test_returns_true_on_non_error_event(self): - """With meshcore_channel set, send_chan_msg is called and True returned.""" + """With a resolvable channel name, send_chan_msg is called and True returned.""" t, mc, _ = _transport_with_mock_mc() try: ok = MagicMock() ok.is_error.return_value = False mc.commands.send_chan_msg = AsyncMock(return_value=ok) - assert t.send_message("hello", meshcore_channel=0) is True + assert t.send_message("hello", meshcore_channel="AIDA") is True mc.commands.send_chan_msg.assert_awaited_once() finally: _cleanup(t) def test_returns_false_on_error_event(self): - """With meshcore_channel set, an error result returns False.""" + """With a resolvable channel name, an error result returns False.""" t, mc, _ = _transport_with_mock_mc() try: err = MagicMock() err.is_error.return_value = True mc.commands.send_chan_msg = AsyncMock(return_value=err) - assert t.send_message("hello", meshcore_channel=0) is False + assert t.send_message("hello", meshcore_channel="AIDA") is False finally: _cleanup(t) @@ -202,7 +227,7 @@ class TestSendMessageChannel: cfg = _mc_config() t = MeshCoreTransport(cfg) # _mc is None, no loop started — fails before channel check. - assert t.send_message("test", meshcore_channel=0) is False + assert t.send_message("test", meshcore_channel="AIDA") is False def test_meshcore_channel_none_skips_broadcast(self): """meshcore_channel=None → silent no-op (True) without calling send_chan_msg.""" @@ -227,7 +252,8 @@ class TestSendMessageChannel: _cleanup(t) def _transport_with_mock_send_chan_msg(self): - """Build a MeshCoreTransport with a mock mc and async send_chan_msg.""" + """Build a MeshCoreTransport with a mock mc, a fake channel table, and + an async send_chan_msg recorder.""" cfg = _mc_config() t = MeshCoreTransport(cfg) ok = MagicMock() @@ -235,6 +261,7 @@ class TestSendMessageChannel: loop = asyncio.new_event_loop() mc = MagicMock() + _install_channel_table(mc) # {"AIDA": 2, "Fire": 3} mc.commands.send_chan_msg = AsyncMock(return_value=ok) t._mc = mc t._connected = True @@ -244,30 +271,43 @@ class TestSendMessageChannel: t._loop_thread = thread return t, mc - def test_uses_meshcore_channel_for_broadcast(self): - """meshcore_channel=3 → send_chan_msg(3, text).""" + def test_resolves_name_to_slot_for_broadcast(self): + """meshcore_channel='AIDA' → resolves to slot 2 → send_chan_msg(2, text).""" t, mc = self._transport_with_mock_send_chan_msg() try: - t.send_message("hi", meshcore_channel=3) + assert t.send_message("hi", meshcore_channel="AIDA") is True + mc.commands.send_chan_msg.assert_awaited_once_with(2, "hi") + finally: + _cleanup(t) + + def test_resolves_second_named_channel(self): + """meshcore_channel='Fire' → resolves to slot 3 → send_chan_msg(3, text).""" + t, mc = self._transport_with_mock_send_chan_msg() + try: + assert t.send_message("hi", meshcore_channel="Fire") is True mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi") finally: _cleanup(t) - def test_ignores_meshtastic_channel_uses_meshcore_channel(self): - """channel=8 (Meshtastic) is irrelevant; meshcore_channel=3 is authoritative.""" + def test_ignores_meshtastic_channel_uses_meshcore_name(self): + """channel=8 (Meshtastic) is irrelevant; the MeshCore NAME is authoritative.""" t, mc = self._transport_with_mock_send_chan_msg() try: - t.send_message("hi", channel=8, meshcore_channel=3) + t.send_message("hi", channel=8, meshcore_channel="Fire") mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi") finally: _cleanup(t) - def test_meshcore_channel_zero_is_valid(self): - """meshcore_channel=0 is a valid channel (not falsy-skipped).""" + def test_unknown_name_warns_and_never_sends(self, caplog): + """An unresolved channel name → no send_chan_msg, returns False, warns.""" + import logging t, mc = self._transport_with_mock_send_chan_msg() try: - t.send_message("hi", meshcore_channel=0) - mc.commands.send_chan_msg.assert_awaited_once_with(0, "hi") + with caplog.at_level(logging.WARNING): + result = t.send_message("hi", meshcore_channel="Nonexistent") + assert result is False + mc.commands.send_chan_msg.assert_not_awaited() + assert any("Nonexistent" in r.getMessage() for r in caplog.records) finally: _cleanup(t) diff --git a/work/tests/test_uniform_sizing.py b/work/tests/test_uniform_sizing.py index 7a48414..16769da 100644 --- a/work/tests/test_uniform_sizing.py +++ b/work/tests/test_uniform_sizing.py @@ -102,7 +102,6 @@ def _mc_config(**overrides): transport="meshcore", meshcore_host="127.0.0.1", meshcore_port=5050, - meshcore_channel_index=0, ) for k, v in overrides.items(): setattr(cfg, k, v) @@ -178,7 +177,6 @@ class TestTransportMaxChars: tcp_port=4403, meshcore_host="127.0.0.1", meshcore_port=5050, - meshcore_channel_index=0, ) comp = build_transport(cfg_both) assert isinstance(comp, CompositeTransport)