From cf918c9b2b2927c31ec6ce32677aea1cbf8dc86f Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Mon, 6 Jul 2026 05:40:54 +0000 Subject: [PATCH] feat(meshcore): auto-add contacts so AIDA can DM anyone it hears Enable firmware auto-add (set_autoadd_config CMD 58) at connect when connection.meshcore_auto_add_contacts is set (default on), and refresh the contact roster on NEW_CONTACT so replies resolve immediately. GUI toggle on the MeshCore Connection page. So the USB AIDA companion adds every node it hears an advert from and can send/decrypt DMs without manual contact exchange. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/pages/MeshCoreConnection.tsx | 7 ++ work/meshai/config.py | 1 + work/meshai/transport/meshcore_transport.py | 27 ++++++ work/tests/test_meshcore_transport.py | 93 +++++++++++++++++++ 4 files changed, 128 insertions(+) diff --git a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx index 3c27d2f..48c9a7b 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx @@ -23,6 +23,7 @@ interface ConnectionConfig { meshcore_serial_port?: string meshcore_baud?: number meshcore_ble_address?: string + meshcore_auto_add_contacts?: boolean [key: string]: unknown } @@ -310,6 +311,12 @@ export default function MeshCoreConnection() { → Meshtastic connection + upd({ meshcore_auto_add_contacts: v })} + helper="Enables firmware CMD 58 (set_autoadd_config) at connect so AIDA automatically adds every node it hears an advert from as a contact, enabling DM send/decrypt without manual contact exchange" + />
diff --git a/work/meshai/config.py b/work/meshai/config.py index c7a0173..949f674 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -49,6 +49,7 @@ class ConnectionConfig: meshcore_serial_port: str = "" # prefer stable /dev/serial/by-id/... path meshcore_baud: int = 115200 meshcore_ble_address: str = "" # optional; for ble + meshcore_auto_add_contacts: bool = True # firmware auto-adds every node it hears an advert from (so AIDA can DM anyone) def __post_init__(self): if self.meshcore_conn_type not in {"tcp", "serial", "ble"}: diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index bf1e402..f5a4787 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -435,10 +435,17 @@ class MeshCoreTransport(MeshTransport): self._mc.subscribe(EventType.DISCONNECTED, self._on_disconnect_event) self._mc.subscribe(EventType.CONNECTED, self._on_connect_event) self._mc.subscribe(EventType.ACK, self._on_ack_event) + self._mc.subscribe(EventType.NEW_CONTACT, self._on_new_contact) try: await self._mc.ensure_contacts() except Exception: logger.debug("MeshCore: ensure_contacts failed (non-fatal)", exc_info=True) + if getattr(self.config, "meshcore_auto_add_contacts", True): + try: + await self._mc.commands.set_autoadd_config(1) + logger.info("MeshCore: firmware auto-add-contacts ENABLED (AIDA will auto-add any node it hears)") + except Exception as exc: # older firmware may not support CMD 58 + logger.warning("MeshCore: set_autoadd_config not supported/failed (non-fatal): %s", exc) await self._mc.start_auto_message_fetching() logger.info("MeshCore: subscriptions registered; auto message-fetch started") @@ -814,6 +821,26 @@ class MeshCoreTransport(MeshTransport): def _on_ack_event(self, event) -> None: logger.info("MeshCore: ACK event received: %r", getattr(event, "payload", None)) + def _on_new_contact(self, event) -> None: + """Firmware auto-added a node from a fresh advert — log it and refresh our roster.""" + try: + payload = getattr(event, "payload", None) or {} + name = payload.get("adv_name") or payload.get("public_key", "")[:12] or "?" + logger.info("MeshCore: new contact auto-added: %s", name) + except Exception: + logger.debug("MeshCore: _on_new_contact logging failed", exc_info=True) + # Refresh roster on the transport's own loop (fire-and-forget, non-blocking). + # We must NOT use _run_coro here: the lib calls this callback from within + # the asyncio event loop, so blocking with .result() would deadlock. + # asyncio.run_coroutine_threadsafe without .result() is safe from any context. + try: + ensure = getattr(self._mc, "ensure_contacts", None) + loop = getattr(self, "_loop", None) + if ensure is not None and loop is not None and loop.is_running(): + asyncio.run_coroutine_threadsafe(ensure(), loop) + except Exception: + logger.debug("MeshCore: roster refresh after new contact failed", exc_info=True) + def _on_disconnect_event(self, event=None) -> None: """Track link state: DISCONNECTED.""" self._connected = False diff --git a/work/tests/test_meshcore_transport.py b/work/tests/test_meshcore_transport.py index b3dc617..192ee81 100644 --- a/work/tests/test_meshcore_transport.py +++ b/work/tests/test_meshcore_transport.py @@ -29,6 +29,7 @@ def _build_fake_meshcore(): DISCONNECTED = "DISCONNECTED" CONNECTED = "CONNECTED" ACK = "ACK" + NEW_CONTACT = "NEW_CONTACT" mod.EventType = EventType @@ -79,6 +80,12 @@ def _build_fake_meshcore(): # No return value required for advert. pass + @staticmethod + async def set_autoadd_config(value): + result = MagicMock() + result.is_error.return_value = False + return result + mod.MeshCore = _FakeMeshCore return mod @@ -818,3 +825,89 @@ class TestPeriodicAdvertScheduler: assert t._advert_task is not None t.disconnect() assert t._advert_task is None, "_advert_task should be None after disconnect" + + +# --------------------------------------------------------------------------- +# 12. Auto-add contacts (CMD 58 + NEW_CONTACT roster refresh) +# --------------------------------------------------------------------------- + +class TestAutoAddContacts: + """set_autoadd_config called (or not) at connect; _on_new_contact refreshes roster.""" + + def _run_subscriptions(self, t, mc): + """Drive _setup_subscriptions on the transport's dedicated loop.""" + mc.ensure_contacts = AsyncMock(return_value=True) + mc.start_auto_message_fetching = AsyncMock() + t._run_coro(t._setup_subscriptions()) + + def test_set_autoadd_config_called_when_enabled(self): + """set_autoadd_config(1) is called during _setup_subscriptions when toggle is True.""" + t, mc, _ = _transport_with_mock_mc() + try: + mc.commands.set_autoadd_config = AsyncMock(return_value=MagicMock()) + # Default config has meshcore_auto_add_contacts=True (default from ConnectionConfig). + t.config.meshcore_auto_add_contacts = True + self._run_subscriptions(t, mc) + mc.commands.set_autoadd_config.assert_awaited_once_with(1) + finally: + _cleanup(t) + + def test_set_autoadd_config_not_called_when_disabled(self): + """set_autoadd_config is NOT called during _setup_subscriptions when toggle is False.""" + cfg = _mc_config() + cfg.meshcore_auto_add_contacts = False + t = MeshCoreTransport(cfg) + mc = MagicMock() + mc.get_contact_by_key_prefix.return_value = None + _install_channel_table(mc) + mc.commands.set_autoadd_config = AsyncMock(return_value=MagicMock()) + t._mc = mc + t._connected = True + loop = asyncio.new_event_loop() + t._loop = loop + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + t._loop_thread = thread + try: + mc.ensure_contacts = AsyncMock(return_value=True) + mc.start_auto_message_fetching = AsyncMock() + t._run_coro(t._setup_subscriptions()) + mc.commands.set_autoadd_config.assert_not_awaited() + finally: + _cleanup(t) + + def test_on_new_contact_does_not_raise(self): + """_on_new_contact handles a well-formed event payload without raising.""" + t, mc, _ = _transport_with_mock_mc() + try: + event = MagicMock() + event.payload = {"adv_name": "K7ZVX-Node", "public_key": "abc123deadbeef"} + t._on_new_contact(event) # must not raise + finally: + _cleanup(t) + + def test_on_new_contact_triggers_roster_refresh(self): + """_on_new_contact schedules ensure_contacts on the transport loop.""" + import time + t, mc, loop = _transport_with_mock_mc() + try: + mc.ensure_contacts = AsyncMock(return_value=True) + event = MagicMock() + event.payload = {"adv_name": "NewNode", "public_key": "cafef00d"} + t._on_new_contact(event) + # Drain the dedicated loop so the fire-and-forget coroutine runs. + drain = asyncio.run_coroutine_threadsafe(asyncio.sleep(0.05), loop) + drain.result(timeout=2.0) + mc.ensure_contacts.assert_called() + finally: + _cleanup(t) + + def test_on_new_contact_handles_missing_payload_gracefully(self): + """_on_new_contact survives when event.payload is None or absent.""" + t, mc, _ = _transport_with_mock_mc() + try: + event = MagicMock() + event.payload = None + t._on_new_contact(event) # must not raise + finally: + _cleanup(t)