mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
7709fc1ec0
commit
2593e50aee
4 changed files with 128 additions and 0 deletions
|
|
@ -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
|
||||
</Link>
|
||||
</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">
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -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"}:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue