From 4138ebfdc30be28e20ca83a7459af513351eb7a4 Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Fri, 3 Jul 2026 22:06:09 +0000 Subject: [PATCH] feat(meshcore): self-advertisement (send-advert + advert-on-connect + periodic) AIDA now announces itself: send_advert(flood=True) on every connect, an optional periodic auto-advert (meshcore_advert_interval_seconds), and a manual "Send Advert" button + POST /api/meshcore/advert. Makes the companion discoverable/DM-able on the mesh. --- work/dashboard-frontend/src/lib/api.ts | 13 ++ .../src/pages/MeshCoreCompanion.tsx | 156 ++++++++++++++++- work/meshai/config.py | 1 + work/meshai/dashboard/api/mesh_send_routes.py | 21 +++ work/meshai/transport/composite_transport.py | 5 + work/meshai/transport/meshcore_transport.py | 91 ++++++++++ work/tests/test_mesh_send_api.py | 88 ++++++++++ work/tests/test_meshcore_transport.py | 165 ++++++++++++++++++ 8 files changed, 538 insertions(+), 2 deletions(-) diff --git a/work/dashboard-frontend/src/lib/api.ts b/work/dashboard-frontend/src/lib/api.ts index 39672c3..3e47345 100644 --- a/work/dashboard-frontend/src/lib/api.ts +++ b/work/dashboard-frontend/src/lib/api.ts @@ -512,6 +512,7 @@ export interface MeshcoreSelf { host?: string port?: number channel_count?: number + last_advert_sent?: number | null // epoch seconds; null/absent = never advertised } export async function fetchMeshcoreContacts(): Promise { @@ -521,6 +522,18 @@ export async function fetchMeshcoreSelf(): Promise { return fetchJson('/api/meshcore/self') } +export async function sendMeshcoreAdvert(): Promise { + const response = await fetch('/api/meshcore/advert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`) + } + return response.json() +} + export async function sendTestMessage(body: { transport: 'meshtastic' | 'meshcore' channel: string | number diff --git a/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx b/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx index c095a5c..52eeb00 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx @@ -1,18 +1,43 @@ -import { useState, useEffect } from 'react' -import { Bot } from 'lucide-react' +import { useState, useEffect, useCallback } from 'react' +import { Bot, Radio } from 'lucide-react' import { fetchMeshcoreSelf, getMeshcoreChannels, + sendMeshcoreAdvert, + updateConfig, type MeshcoreSelf, type MeshcoreChannels, + type TestSendResult, } from '../lib/api' +/** Format epoch seconds as a human-readable relative time string. */ +function relativeTime(epochSec: number): string { + const diffSec = Math.floor(Date.now() / 1000 - epochSec) + if (diffSec < 5) return 'just now' + if (diffSec < 60) return `${diffSec}s ago` + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + const diffHr = Math.floor(diffMin / 60) + if (diffHr < 24) return `${diffHr}h ago` + return `${Math.floor(diffHr / 24)}d ago` +} + export default function MeshCoreCompanion() { const [self, setSelf] = useState(null) const [channels, setChannels] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + // Send Advert state + const [advertSending, setAdvertSending] = useState(false) + const [advertResult, setAdvertResult] = useState(null) + + // Auto-advert control state — interval in hours (0 = disabled) + // Loaded from connection config; editable in-page and PUTted back. + const [advertIntervalHours, setAdvertIntervalHours] = useState(3) + const [advertIntervalSaving, setAdvertIntervalSaving] = useState(false) + const [advertIntervalSaved, setAdvertIntervalSaved] = useState(false) + useEffect(() => { document.title = 'Companion & Channels - MeshAI' }, []) @@ -42,6 +67,64 @@ export default function MeshCoreCompanion() { } }, []) + // Load advert interval from connection config on mount. + useEffect(() => { + ;(async () => { + try { + const resp = await fetch('/api/config/connection') + if (resp.ok) { + const data = await resp.json() as Record + const sec = data['meshcore_advert_interval_seconds'] + if (typeof sec === 'number') { + setAdvertIntervalHours(sec > 0 ? sec / 3600 : 0) + } + } + } catch { + // non-fatal — keep default + } + })() + }, []) + + const handleSendAdvert = useCallback(async () => { + setAdvertSending(true) + setAdvertResult(null) + try { + const result = await sendMeshcoreAdvert() + setAdvertResult(result) + if (result.sent) { + // Refresh self to pick up updated last_advert_sent. + try { + const updated = await fetchMeshcoreSelf() + setSelf(updated) + } catch { + // non-fatal + } + } + } catch (err) { + setAdvertResult({ + sent: false, + detail: err instanceof Error ? err.message : 'Request failed', + }) + } finally { + setAdvertSending(false) + } + }, []) + + const handleSaveAdvertInterval = useCallback(async () => { + setAdvertIntervalSaving(true) + setAdvertIntervalSaved(false) + try { + const seconds = Math.round(advertIntervalHours * 3600) + await updateConfig('connection', { meshcore_advert_interval_seconds: seconds }) + setAdvertIntervalSaved(true) + setTimeout(() => setAdvertIntervalSaved(false), 2000) + } catch { + // keep saving=false, let UI show failure implicitly + } finally { + setAdvertIntervalSaving(false) + } + }, [advertIntervalHours]) + const connected = self?.connected === true const channelNames = channels?.active ? channels.channels : [] @@ -100,7 +183,37 @@ export default function MeshCoreCompanion() {
Channels joined
{self?.channel_count ?? 0}
+ {self?.last_advert_sent != null && ( +
+
Last advertised
+
{relativeTime(self.last_advert_sent)}
+
+ )} + + {/* Send Advert */} +
+
+ + {advertResult != null && ( + + {advertResult.sent ? 'Advert sent' : advertResult.detail} + + )} +
+

+ Announce this node to the mesh so others can discover and DM it. +

+
) : (
@@ -116,6 +229,45 @@ export default function MeshCoreCompanion() { )}
+ {/* Advertising settings */} +
+
+

Advertising

+
+
+
+ +
+ + +
+

+ AIDA sends a flood advertisement at this interval so it stays discoverable. + Stored in connection.meshcore_advert_interval_seconds. +

+
+
+
+ {/* Channel list */}
diff --git a/work/meshai/config.py b/work/meshai/config.py index c53f710..da93c0b 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -42,6 +42,7 @@ class ConnectionConfig: meshcore_port: int = 5050 # pyMC companion frame server port meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited) + meshcore_advert_interval_seconds: int = 10800 # periodic self-advert interval (0 = disabled) @dataclass diff --git a/work/meshai/dashboard/api/mesh_send_routes.py b/work/meshai/dashboard/api/mesh_send_routes.py index 2326851..c51d805 100644 --- a/work/meshai/dashboard/api/mesh_send_routes.py +++ b/work/meshai/dashboard/api/mesh_send_routes.py @@ -66,6 +66,27 @@ async def meshcore_self(request: Request): return {"connected": False} +@router.post("/meshcore/advert") +async def meshcore_send_advert(request: Request): + """Broadcast a signed self-advertisement (flood=True) via MeshCore. + + Returns {sent: bool, detail: str}. Returns {sent: false} when MeshCore + is not connected. + """ + connector = getattr(request.app.state, "connector", None) + mc = _find_child(connector, "meshcore") + if mc is None or not getattr(mc, "connected", False): + return {"sent": False, "detail": "MeshCore not connected"} + try: + ok = bool(mc.send_advert()) + detail = "Self-advert sent" if ok else "send_advert returned False" + logger.info("dashboard: meshcore manual advert sent=%s", ok) + return {"sent": ok, "detail": detail} + except Exception as exc: + logger.error("dashboard: meshcore advert error: %s", exc) + return {"sent": False, "detail": str(exc)} + + class TestSendRequest(BaseModel): transport: str channel: Union[str, int] diff --git a/work/meshai/transport/composite_transport.py b/work/meshai/transport/composite_transport.py index 8e5bcb8..f358dd7 100644 --- a/work/meshai/transport/composite_transport.py +++ b/work/meshai/transport/composite_transport.py @@ -102,6 +102,11 @@ class CompositeTransport(MeshTransport): child = self.meshcore_child() return child.self_info() if child is not None else {"connected": False} + def send_advert(self) -> bool: + """Passthrough to the MeshCore child's send_advert(); False if no meshcore child.""" + child = self.meshcore_child() + return child.send_advert() if child is not None else False + # ------------------------------------------------------------------ # Routing decision helpers (factored out for unit-test access) # ------------------------------------------------------------------ diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index 70beb5b..45b76ef 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -13,6 +13,7 @@ imported (and the test suite can run) without the lib installed. import asyncio import logging import threading +import time as _time from typing import Callable, Optional from .base import MeshTransport @@ -82,6 +83,10 @@ class MeshCoreTransport(MeshTransport): # 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] = {} + # Self-advertisement tracking. + self._last_advert_sent: Optional[float] = None # epoch seconds or None + # asyncio.Task handle for the periodic advert loop; None when inactive. + self._advert_task = None # ------------------------------------------------------------------ # Internal helpers @@ -208,6 +213,7 @@ class MeshCoreTransport(MeshTransport): "host": getattr(self.config, "meshcore_host", "100.64.0.9"), "port": getattr(self.config, "meshcore_port", 5050), "channel_count": len(self.known_channels()), + "last_advert_sent": self._last_advert_sent, } def set_context_config(self, cfg) -> None: @@ -217,6 +223,75 @@ class MeshCoreTransport(MeshTransport): """ self._mc_context = cfg + # ------------------------------------------------------------------ + # Self-advertisement + # ------------------------------------------------------------------ + + def send_advert(self) -> bool: + """Broadcast a signed self-advertisement to the mesh (flood=True). + + Bridges the async ``mc.commands.send_advert`` call to the dedicated + event loop via ``_run_coro``. Safe no-op returning False when not + connected or when the lib command raises. + + Callers must log the human-readable context (manual / on-connect); + this method is intentionally silent on success to avoid duplicate + log lines across call sites. + """ + if self._mc is None or not self._connected: + logger.debug("MeshCore: send_advert skipped — not connected") + return False + try: + self._run_coro(self._mc.commands.send_advert(flood=True)) + self._last_advert_sent = _time.time() + return True + except Exception as exc: + logger.warning("MeshCore: send_advert failed: %s", exc) + return False + + async def _periodic_advert_loop(self, interval: int) -> None: + """Periodic self-advertisement coroutine (runs as a Task on the dedicated loop). + + Sleeps *interval* seconds, sends one flood advert, repeats. Stops on + CancelledError (raised by ``_cancel_periodic_advert`` at disconnect) or + when the transport drops its connection. No overlap is possible because + the loop awaits the sleep before each send. + """ + try: + while True: + await asyncio.sleep(interval) + if not self._connected or self._mc is None: + return + try: + await self._mc.commands.send_advert(flood=True) + self._last_advert_sent = _time.time() + logger.info("MeshCore: sent periodic self-advert") + except Exception as exc: + logger.warning("MeshCore: periodic send_advert failed: %s", exc) + except asyncio.CancelledError: + logger.debug("MeshCore: periodic advert task cancelled") + raise + + def _schedule_periodic_advert(self, interval: int) -> None: + """Create the periodic advert asyncio.Task on the dedicated loop (thread-safe). + + Called from the main thread after connect(); the Task is created ON the + dedicated loop via call_soon_threadsafe so asyncio.create_task() fires + in the right context. + """ + def _arm() -> None: + self._advert_task = asyncio.get_event_loop().create_task( + self._periodic_advert_loop(interval) + ) + self._loop.call_soon_threadsafe(_arm) + + def _cancel_periodic_advert(self) -> None: + """Cancel the periodic advert task (thread-safe). Called at disconnect.""" + task = self._advert_task + self._advert_task = None + if task is not None and self._loop is not None and self._loop.is_running(): + self._loop.call_soon_threadsafe(task.cancel) + # ------------------------------------------------------------------ # Internal coroutines (run on the dedicated loop) # ------------------------------------------------------------------ @@ -309,6 +384,20 @@ class MeshCoreTransport(MeshTransport): # per-family broadcasts can resolve their channel name to a slot. self._enumerate_channels() + # Announce ourselves so other nodes can discover and DM us. + try: + if self.send_advert(): + logger.info("MeshCore: sent self-advert on connect") + else: + logger.warning("MeshCore: send_advert on connect returned False") + except Exception as exc: + logger.warning("MeshCore: send_advert on connect error: %s", exc) + + # Arm periodic re-advertisement if configured (default 3 h; 0 = disabled). + interval = getattr(self.config, "meshcore_advert_interval_seconds", 10800) + if interval > 0: + self._schedule_periodic_advert(interval) + logger.info( "MeshCoreTransport: connected as %s (pubkey %s)", self._self_info.get("name", "unknown"), @@ -317,6 +406,8 @@ class MeshCoreTransport(MeshTransport): def disconnect(self) -> None: """Disconnect and stop the event loop thread.""" + # Cancel periodic advert before tearing down the loop. + self._cancel_periodic_advert() if self._mc is not None: try: self._run_coro(self._do_disconnect(), timeout=10.0) diff --git a/work/tests/test_mesh_send_api.py b/work/tests/test_mesh_send_api.py index be45aa4..78fafb0 100644 --- a/work/tests/test_mesh_send_api.py +++ b/work/tests/test_mesh_send_api.py @@ -262,3 +262,91 @@ def test_meshcore_self_disconnected(): r = client.get("/api/meshcore/self") assert r.status_code == 200 assert r.json() == {"connected": False} + + +# ============================================================================ +# POST /api/meshcore/advert +# ============================================================================ + + +def test_meshcore_advert_connected_returns_sent_true(): + """POST /api/meshcore/advert → {sent: true} when meshcore is connected.""" + mc = _child("meshcore", connected=True) + mc.send_advert.return_value = True + connector = _composite([mc]) + client = _client(connector) + + r = client.post("/api/meshcore/advert") + assert r.status_code == 200 + body = r.json() + assert body["sent"] is True + assert "detail" in body + mc.send_advert.assert_called_once() + + +def test_meshcore_advert_connected_send_returns_false(): + """POST /api/meshcore/advert → {sent: false} when send_advert() returns False.""" + mc = _child("meshcore", connected=True) + mc.send_advert.return_value = False + connector = _composite([mc]) + client = _client(connector) + + r = client.post("/api/meshcore/advert") + assert r.status_code == 200 + body = r.json() + assert body["sent"] is False + + +def test_meshcore_advert_not_connected(): + """POST /api/meshcore/advert → {sent: false, detail: 'MeshCore not connected'}.""" + mc = _child("meshcore", connected=False) + connector = _composite([mc]) + client = _client(connector) + + r = client.post("/api/meshcore/advert") + assert r.status_code == 200 + body = r.json() + assert body["sent"] is False + assert body["detail"] == "MeshCore not connected" + + +def test_meshcore_advert_no_meshcore_child(): + """POST /api/meshcore/advert → {sent: false} when there is no meshcore transport.""" + mt = _child("meshtastic", connected=True) + connector = _composite([mt]) + client = _client(connector) + + r = client.post("/api/meshcore/advert") + assert r.status_code == 200 + body = r.json() + assert body["sent"] is False + assert body["detail"] == "MeshCore not connected" + + +# ============================================================================ +# Config round-trip: meshcore_advert_interval_seconds +# ============================================================================ + + +def test_connection_config_advert_interval_default(): + """meshcore_advert_interval_seconds defaults to 10800 (3 h).""" + from meshai.config import ConnectionConfig + cfg = ConnectionConfig() + assert cfg.meshcore_advert_interval_seconds == 10800 + + +def test_connection_config_advert_interval_zero(): + """meshcore_advert_interval_seconds = 0 disables periodic advert.""" + from meshai.config import ConnectionConfig + cfg = ConnectionConfig(meshcore_advert_interval_seconds=0) + assert cfg.meshcore_advert_interval_seconds == 0 + + +def test_connection_config_advert_interval_round_trips_yaml(): + """meshcore_advert_interval_seconds survives YAML serialize → deserialize.""" + from meshai.config import ConnectionConfig, _dataclass_to_dict, _dict_to_dataclass + cfg = ConnectionConfig(meshcore_advert_interval_seconds=7200) + data = _dataclass_to_dict(cfg) + assert data["meshcore_advert_interval_seconds"] == 7200 + cfg2 = _dict_to_dataclass(ConnectionConfig, data) + assert cfg2.meshcore_advert_interval_seconds == 7200 diff --git a/work/tests/test_meshcore_transport.py b/work/tests/test_meshcore_transport.py index f5f12ac..36b2d2e 100644 --- a/work/tests/test_meshcore_transport.py +++ b/work/tests/test_meshcore_transport.py @@ -70,6 +70,11 @@ def _build_fake_meshcore(): result.is_error.return_value = False return result + @staticmethod + async def send_advert(flood=False): + # No return value required for advert. + pass + mod.MeshCore = _FakeMeshCore return mod @@ -629,3 +634,163 @@ class TestSelfInfo: """A fresh, unconnected transport (_mc is None) returns {connected: False}.""" t = MeshCoreTransport(_mc_config()) assert t.self_info() == {"connected": False} + + def test_connected_includes_last_advert_sent(self): + """self_info() includes last_advert_sent (None before first advert).""" + t, mc, _ = _transport_with_mock_mc() + try: + t._self_info = {"public_key": "abc123", "name": "TestNode"} + info = t.self_info() + assert "last_advert_sent" in info + assert info["last_advert_sent"] is None # no advert sent yet + finally: + _cleanup(t) + + def test_self_info_last_advert_sent_updated_after_send_advert(self): + """self_info() reflects last_advert_sent after send_advert() succeeds.""" + import time + t, mc, _ = _transport_with_mock_mc() + try: + mc.commands.send_advert = AsyncMock(return_value=None) + t._self_info = {"public_key": "abc123", "name": "TestNode"} + before = time.time() + t.send_advert() + info = t.self_info() + assert info["last_advert_sent"] is not None + assert info["last_advert_sent"] >= before + finally: + _cleanup(t) + + +# --------------------------------------------------------------------------- +# 9. send_advert() +# --------------------------------------------------------------------------- + +class TestSendAdvert: + def test_connected_calls_lib_command_and_returns_true(self): + """send_advert() awaits mc.commands.send_advert(flood=True) and returns True.""" + t, mc, _ = _transport_with_mock_mc() + try: + mc.commands.send_advert = AsyncMock(return_value=None) + result = t.send_advert() + assert result is True + mc.commands.send_advert.assert_awaited_once_with(flood=True) + finally: + _cleanup(t) + + def test_not_connected_returns_false(self): + """send_advert() returns False when _mc is None (transport not connected).""" + t = MeshCoreTransport(_mc_config()) + assert t.send_advert() is False + + def test_connected_but_flag_false_returns_false(self): + """send_advert() returns False when _connected is False.""" + t = MeshCoreTransport(_mc_config()) + t._mc = MagicMock() # mc set but _connected remains False + assert t.send_advert() is False + + def test_updates_last_advert_sent_on_success(self): + """send_advert() sets _last_advert_sent to current epoch on success.""" + import time + t, mc, _ = _transport_with_mock_mc() + try: + mc.commands.send_advert = AsyncMock(return_value=None) + before = time.time() + t.send_advert() + assert t._last_advert_sent is not None + assert t._last_advert_sent >= before + finally: + _cleanup(t) + + def test_exception_returns_false_and_does_not_raise(self): + """send_advert() returns False (never raises) when the lib command raises.""" + t, mc, _ = _transport_with_mock_mc() + try: + mc.commands.send_advert = AsyncMock(side_effect=Exception("timeout")) + result = t.send_advert() + assert result is False + finally: + _cleanup(t) + + def test_does_not_update_last_advert_sent_on_failure(self): + """_last_advert_sent stays None when the lib command raises.""" + t, mc, _ = _transport_with_mock_mc() + try: + mc.commands.send_advert = AsyncMock(side_effect=Exception("timeout")) + t.send_advert() + assert t._last_advert_sent is None + finally: + _cleanup(t) + + +# --------------------------------------------------------------------------- +# 10. advert-on-connect +# --------------------------------------------------------------------------- + +class TestAdvertOnConnect: + def test_advert_sent_after_connect(self): + """connect() calls send_advert() once after _enumerate_channels().""" + from unittest.mock import patch + cfg = _mc_config() + # Disable periodic advert so we only check the one-shot on-connect call. + cfg.meshcore_advert_interval_seconds = 0 + t = MeshCoreTransport(cfg) + advert_calls = [] + + original_send_advert = MeshCoreTransport.send_advert + + def _spy_send_advert(self_inner): + advert_calls.append(True) + return True + + with patch.object(MeshCoreTransport, "send_advert", _spy_send_advert): + t.connect() + + try: + assert len(advert_calls) == 1, ( + f"expected 1 send_advert call on connect, got {len(advert_calls)}" + ) + finally: + t.disconnect() + + +# --------------------------------------------------------------------------- +# 11. Periodic advert scheduler +# --------------------------------------------------------------------------- + +class TestPeriodicAdvertScheduler: + def test_task_armed_when_interval_nonzero(self): + """connect() with meshcore_advert_interval_seconds > 0 arms _advert_task.""" + import time + cfg = _mc_config(meshcore_advert_interval_seconds=3600) + t = MeshCoreTransport(cfg) + try: + t.connect() + # Give the event loop a moment to execute the call_soon_threadsafe callback. + time.sleep(0.1) + assert t._advert_task is not None, "_advert_task should be set after connect" + finally: + t.disconnect() + + def test_task_not_armed_when_interval_zero(self): + """connect() with meshcore_advert_interval_seconds=0 leaves _advert_task None.""" + import time + cfg = _mc_config(meshcore_advert_interval_seconds=0) + t = MeshCoreTransport(cfg) + try: + t.connect() + time.sleep(0.1) + assert t._advert_task is None, "_advert_task should not be set when interval=0" + finally: + t.disconnect() + + def test_task_cleared_after_disconnect(self): + """disconnect() cancels and clears _advert_task.""" + import time + cfg = _mc_config(meshcore_advert_interval_seconds=3600) + t = MeshCoreTransport(cfg) + t.connect() + time.sleep(0.1) + assert t._advert_task is not None + t.disconnect() + assert t._advert_task is None, "_advert_task should be None after disconnect"