feat(meshcore): auto-add contacts so AIDA can DM anyone it hears (#55)

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: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-05 23:48:41 -06:00 committed by GitHub
commit 2593e50aee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 128 additions and 0 deletions

View file

@ -23,6 +23,7 @@ interface ConnectionConfig {
meshcore_serial_port?: string meshcore_serial_port?: string
meshcore_baud?: number meshcore_baud?: number
meshcore_ble_address?: string meshcore_ble_address?: string
meshcore_auto_add_contacts?: boolean
[key: string]: unknown [key: string]: unknown
} }
@ -310,6 +311,12 @@ export default function MeshCoreConnection() {
&rarr; Meshtastic connection &rarr; Meshtastic connection
</Link> </Link>
</div> </div>
<Toggle
label="Auto-add contacts (AIDA adds any node it hears — required to DM anyone)"
checked={config.meshcore_auto_add_contacts ?? true}
onChange={(v) => 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"
/>
<details className="group"> <details className="group">
<summary className="flex items-center gap-2 cursor-pointer text-sm text-slate-400 hover:text-slate-200"> <summary className="flex items-center gap-2 cursor-pointer text-sm text-slate-400 hover:text-slate-200">
<ChevronRight size={14} className="group-open:rotate-90 transition-transform" /> <ChevronRight size={14} className="group-open:rotate-90 transition-transform" />

View file

@ -49,6 +49,7 @@ class ConnectionConfig:
meshcore_serial_port: str = "" # prefer stable /dev/serial/by-id/... path meshcore_serial_port: str = "" # prefer stable /dev/serial/by-id/... path
meshcore_baud: int = 115200 meshcore_baud: int = 115200
meshcore_ble_address: str = "" # optional; for ble 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): def __post_init__(self):
if self.meshcore_conn_type not in {"tcp", "serial", "ble"}: if self.meshcore_conn_type not in {"tcp", "serial", "ble"}:

View file

@ -435,10 +435,17 @@ class MeshCoreTransport(MeshTransport):
self._mc.subscribe(EventType.DISCONNECTED, self._on_disconnect_event) self._mc.subscribe(EventType.DISCONNECTED, self._on_disconnect_event)
self._mc.subscribe(EventType.CONNECTED, self._on_connect_event) self._mc.subscribe(EventType.CONNECTED, self._on_connect_event)
self._mc.subscribe(EventType.ACK, self._on_ack_event) self._mc.subscribe(EventType.ACK, self._on_ack_event)
self._mc.subscribe(EventType.NEW_CONTACT, self._on_new_contact)
try: try:
await self._mc.ensure_contacts() await self._mc.ensure_contacts()
except Exception: except Exception:
logger.debug("MeshCore: ensure_contacts failed (non-fatal)", exc_info=True) 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() await self._mc.start_auto_message_fetching()
logger.info("MeshCore: subscriptions registered; auto message-fetch started") logger.info("MeshCore: subscriptions registered; auto message-fetch started")
@ -814,6 +821,26 @@ class MeshCoreTransport(MeshTransport):
def _on_ack_event(self, event) -> None: def _on_ack_event(self, event) -> None:
logger.info("MeshCore: ACK event received: %r", getattr(event, "payload", 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: def _on_disconnect_event(self, event=None) -> None:
"""Track link state: DISCONNECTED.""" """Track link state: DISCONNECTED."""
self._connected = False self._connected = False

View file

@ -29,6 +29,7 @@ def _build_fake_meshcore():
DISCONNECTED = "DISCONNECTED" DISCONNECTED = "DISCONNECTED"
CONNECTED = "CONNECTED" CONNECTED = "CONNECTED"
ACK = "ACK" ACK = "ACK"
NEW_CONTACT = "NEW_CONTACT"
mod.EventType = EventType mod.EventType = EventType
@ -79,6 +80,12 @@ def _build_fake_meshcore():
# No return value required for advert. # No return value required for advert.
pass pass
@staticmethod
async def set_autoadd_config(value):
result = MagicMock()
result.is_error.return_value = False
return result
mod.MeshCore = _FakeMeshCore mod.MeshCore = _FakeMeshCore
return mod return mod
@ -818,3 +825,89 @@ class TestPeriodicAdvertScheduler:
assert t._advert_task is not None assert t._advert_task is not None
t.disconnect() t.disconnect()
assert t._advert_task is None, "_advert_task should be None after 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)