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.
This commit is contained in:
Matt Johnson 2026-07-03 22:06:09 +00:00
commit 4138ebfdc3
8 changed files with 538 additions and 2 deletions

View file

@ -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<MeshcoreContacts> {
@ -521,6 +522,18 @@ export async function fetchMeshcoreSelf(): Promise<MeshcoreSelf> {
return fetchJson<MeshcoreSelf>('/api/meshcore/self')
}
export async function sendMeshcoreAdvert(): Promise<TestSendResult> {
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

View file

@ -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<MeshcoreSelf | null>(null)
const [channels, setChannels] = useState<MeshcoreChannels | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Send Advert state
const [advertSending, setAdvertSending] = useState(false)
const [advertResult, setAdvertResult] = useState<TestSendResult | null>(null)
// Auto-advert control state — interval in hours (0 = disabled)
// Loaded from connection config; editable in-page and PUTted back.
const [advertIntervalHours, setAdvertIntervalHours] = useState<number>(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<string, unknown>
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() {
<dt className="text-[#777] mb-1">Channels joined</dt>
<dd className="text-slate-100">{self?.channel_count ?? 0}</dd>
</div>
{self?.last_advert_sent != null && (
<div>
<dt className="text-[#777] mb-1">Last advertised</dt>
<dd className="text-slate-100">{relativeTime(self.last_advert_sent)}</dd>
</div>
)}
</dl>
{/* Send Advert */}
<div className="pt-2 border-t border-border space-y-2">
<div className="flex items-center gap-3">
<button
onClick={handleSendAdvert}
disabled={advertSending}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-accent/10 hover:bg-accent/20 text-accent border border-accent/30 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Radio size={14} />
{advertSending ? 'Sending…' : 'Send Advert'}
</button>
{advertResult != null && (
<span
className={`text-sm ${advertResult.sent ? 'text-green-400' : 'text-red-400'}`}
>
{advertResult.sent ? 'Advert sent' : advertResult.detail}
</span>
)}
</div>
<p className="text-xs text-[#555]">
Announce this node to the mesh so others can discover and DM it.
</p>
</div>
</div>
) : (
<div className="space-y-2">
@ -116,6 +229,45 @@ export default function MeshCoreCompanion() {
)}
</div>
{/* Advertising settings */}
<div className="bg-bg-card border border-border">
<div className="px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium text-slate-200">Advertising</h3>
</div>
<div className="px-4 py-4 space-y-4">
<div className="space-y-1">
<label className="text-xs font-medium text-[#777] uppercase tracking-wide">
Auto-advert interval
</label>
<div className="flex items-center gap-3">
<select
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(Number(e.target.value))}
className="bg-[#0a0e17] border border-[#1e2a3a] text-slate-200 text-sm rounded px-2 py-1.5 focus:outline-none focus:border-accent"
>
<option value={0}>Disabled</option>
<option value={1}>Every 1 hour</option>
<option value={3}>Every 3 hours (default)</option>
<option value={6}>Every 6 hours</option>
<option value={12}>Every 12 hours</option>
<option value={24}>Every 24 hours</option>
</select>
<button
onClick={handleSaveAdvertInterval}
disabled={advertIntervalSaving}
className="px-3 py-1.5 text-sm bg-accent/10 hover:bg-accent/20 text-accent border border-accent/30 rounded disabled:opacity-50 transition-colors"
>
{advertIntervalSaving ? 'Saving…' : advertIntervalSaved ? 'Saved' : 'Save'}
</button>
</div>
<p className="text-xs text-[#555]">
AIDA sends a flood advertisement at this interval so it stays discoverable.
Stored in <code className="text-accent/80">connection.meshcore_advert_interval_seconds</code>.
</p>
</div>
</div>
</div>
{/* Channel list */}
<div className="bg-bg-card border border-border">
<div className="px-4 py-3 border-b border-border">

View file

@ -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

View file

@ -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]

View file

@ -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)
# ------------------------------------------------------------------

View file

@ -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)

View file

@ -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

View file

@ -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"