MeshCore Contacts roster + Companion status (read-only) (#17)

* feat(dashboard): MeshCore Contacts roster + Companion status (read-only)

Expose the live companion's contact roster (get_contacts) and self/channel
status via /api/meshcore/contacts + /api/meshcore/self. Fill the Contacts
(roster table) and Companion (status + channels) pages. Telemetry auto-poll
comes next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 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.

---------

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-03 16:14:10 -06:00 committed by GitHub
commit 284fb5cbf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1142 additions and 42 deletions

View file

@ -492,6 +492,48 @@ export async function getMeshcoreChannels(): Promise<MeshcoreChannels> {
return fetchJson<MeshcoreChannels>('/api/meshcore/channels')
}
export interface MeshcoreContact {
name: string | null
pubkey: string
type: string | null
last_advert: number | null
lat: number | null
lon: number | null
out_path_len: number | null
}
export interface MeshcoreContacts {
active: boolean
contacts: MeshcoreContact[]
}
export interface MeshcoreSelf {
name?: string | null
pubkey?: string | null
connected: boolean
host?: string
port?: number
channel_count?: number
last_advert_sent?: number | null // epoch seconds; null/absent = never advertised
}
export async function fetchMeshcoreContacts(): Promise<MeshcoreContacts> {
return fetchJson<MeshcoreContacts>('/api/meshcore/contacts')
}
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,34 +1,292 @@
import { 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'
}, [])
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
setError(null)
try {
const [selfData, channelData] = await Promise.all([
fetchMeshcoreSelf(),
getMeshcoreChannels(),
])
if (cancelled) return
setSelf(selfData)
setChannels(channelData)
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : 'Failed to load companion status')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
// 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 : []
return (
<div className="max-w-3xl mx-auto">
<div className="bg-bg-card border border-border p-8">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
<Bot size={24} className="text-accent" />
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-slate-100">Companion &amp; Channels</h2>
<span className="px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
Coming soon
</span>
</div>
<p className="text-sm text-slate-400 leading-relaxed max-w-prose">
This page will show live status for the AIDA MeshCore companion &mdash; its connection
health and the list of channels it is currently joined to. Once the companion status
API is available, you'll be able to monitor the companion here and see which channels
are reachable for broadcast delivery.
</p>
</div>
<div className="max-w-3xl mx-auto space-y-4">
{/* Header */}
<div className="flex items-center gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
<Bot size={24} className="text-accent" />
</div>
<div>
<h2 className="text-xl font-semibold text-slate-100">Companion &amp; Channels</h2>
<p className="text-sm text-[#777]">
Live status for the AIDA MeshCore companion and its joined channels.
</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="text-slate-400">Loading...</div>
</div>
) : error ? (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">
{error}
</div>
) : (
<>
{/* Status card */}
<div className="bg-bg-card border border-border p-6">
{connected ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-green-500" />
<span className="text-sm font-medium text-green-400">Connected</span>
</div>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4 text-sm">
<div>
<dt className="text-[#777] mb-1">Node name</dt>
<dd className="text-slate-100">{self?.name ?? 'unnamed'}</dd>
</div>
<div>
<dt className="text-[#777] mb-1">Host</dt>
<dd className="text-slate-100 font-mono">
{self?.host ?? '—'}
{self?.port != null ? `:${self.port}` : ''}
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-[#777] mb-1">Public key</dt>
<dd className="text-slate-100 font-mono text-xs break-all">
{self?.pubkey ?? '—'}
</dd>
</div>
<div>
<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">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-slate-600" />
<span className="text-sm font-medium text-slate-400">Not connected</span>
</div>
<p className="text-sm text-[#777] leading-relaxed max-w-prose">
The MeshCore companion is offline or inactive. No node identity or channel
membership is available while the companion is disconnected.
</p>
</div>
)}
</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">
<h3 className="text-sm font-medium text-slate-200">Channels</h3>
</div>
{channelNames.length > 0 ? (
<ul className="divide-y divide-border">
{channelNames.map((name) => (
<li key={name} className="px-4 py-2.5 text-sm text-slate-200 font-mono">
{name}
</li>
))}
</ul>
) : (
<div className="px-4 py-3 text-sm text-[#777]">No channels</div>
)}
</div>
</>
)}
</div>
)
}

View file

@ -1,34 +1,160 @@
import { useEffect } from 'react'
import { useState, useEffect } from 'react'
import { Users } from 'lucide-react'
import {
fetchMeshcoreContacts,
type MeshcoreContacts,
type MeshcoreContact,
} from '../lib/api'
function relativeTime(epochSeconds: number | null): string {
if (epochSeconds == null) return '—'
const diff = Math.floor(Date.now() / 1000) - epochSeconds
if (diff < 0) return 'just now'
if (diff < 60) return `${diff}s ago`
const mins = Math.floor(diff / 60)
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
chat: { label: 'Chat', className: 'bg-sky-500/15 text-sky-400' },
repeater: { label: 'Repeater', className: 'bg-amber-500/15 text-amber-400' },
room: { label: 'Room', className: 'bg-violet-500/15 text-violet-400' },
sensor: { label: 'Sensor', className: 'bg-emerald-500/15 text-emerald-400' },
}
function TypeBadge({ type }: { type: string | null }) {
const meta = (type && TYPE_BADGES[type]) || {
label: type ?? 'unknown',
className: 'bg-slate-600/30 text-slate-400',
}
return (
<span className={`px-2 py-0.5 text-[10px] uppercase tracking-wide rounded ${meta.className}`}>
{meta.label}
</span>
)
}
function contactName(c: MeshcoreContact): string {
if (c.name) return c.name
if (c.pubkey) return `${c.pubkey.slice(0, 12)}`
return 'unnamed'
}
function shortPubkey(pubkey: string): string {
return pubkey.length > 12 ? `${pubkey.slice(0, 12)}` : pubkey
}
function position(c: MeshcoreContact): string {
if (c.lat != null && c.lon != null) {
return `${c.lat.toFixed(4)}, ${c.lon.toFixed(4)}`
}
return '—'
}
export default function MeshCoreContacts() {
const [data, setData] = useState<MeshcoreContacts | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
document.title = 'MeshCore Contacts - MeshAI'
}, [])
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
setError(null)
try {
const result = await fetchMeshcoreContacts()
if (!cancelled) setData(result)
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load contacts')
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
return (
<div className="max-w-3xl mx-auto">
<div className="bg-bg-card border border-border p-8">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
<Users size={24} className="text-accent" />
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-slate-100">MeshCore Contacts</h2>
<span className="px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
Coming soon
</span>
</div>
<p className="text-sm text-slate-400 leading-relaxed max-w-prose">
This page will show the MeshCore companion's contact roster &mdash; the names, public
keys, last-heard timestamps, and positions of the nodes your companion knows about.
It becomes available once the companion data API is wired up, at which point contacts
can be browsed here and referenced directly when configuring MeshCore DM delivery.
</p>
</div>
<div className="max-w-4xl mx-auto space-y-4">
{/* Header */}
<div className="flex items-center gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
<Users size={24} className="text-accent" />
</div>
<div>
<h2 className="text-xl font-semibold text-slate-100">MeshCore Contacts</h2>
<p className="text-sm text-[#777]">
The companion's known contact roster &mdash; names, types, and last-heard times.
</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="text-slate-400">Loading...</div>
</div>
) : error ? (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">
{error}
</div>
) : data && data.active === false ? (
<div className="bg-bg-card border border-border p-6">
<p className="text-sm text-[#777] leading-relaxed max-w-prose">
The MeshCore companion is not connected. The contact roster is unavailable until the
companion comes online.
</p>
</div>
) : data && data.contacts.length === 0 ? (
<div className="bg-bg-card border border-border p-6">
<p className="text-sm text-[#777] leading-relaxed max-w-prose">
No contacts yet. The companion is connected but has not discovered any nodes so far.
</p>
</div>
) : (
<div className="bg-bg-card border border-border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-[#777]">
<th className="px-4 py-2.5 font-medium">Name</th>
<th className="px-4 py-2.5 font-medium">Type</th>
<th className="px-4 py-2.5 font-medium">Last heard</th>
<th className="px-4 py-2.5 font-medium">Position</th>
<th className="px-4 py-2.5 font-medium">Pubkey</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{(data?.contacts ?? []).map((c) => (
<tr key={c.pubkey} className="hover:bg-bg-hover">
<td className="px-4 py-2.5 text-slate-100">{contactName(c)}</td>
<td className="px-4 py-2.5">
<TypeBadge type={c.type} />
</td>
<td className="px-4 py-2.5 text-slate-300">{relativeTime(c.last_advert)}</td>
<td className="px-4 py-2.5 text-slate-300 font-mono text-xs">{position(c)}</td>
<td className="px-4 py-2.5 text-slate-400 font-mono text-xs">
{shortPubkey(c.pubkey)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-xs text-[#777]">
Telemetry auto-poll is coming in the next pass.
</p>
</div>
)
}

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

@ -39,6 +39,54 @@ async def meshcore_channels(request: Request):
return {"active": False, "channels": []}
@router.get("/meshcore/contacts")
async def meshcore_contacts(request: Request):
"""Roster of known MeshCore contacts if a meshcore transport is connected."""
connector = getattr(request.app.state, "connector", None)
mc = _find_child(connector, "meshcore")
if mc is not None and getattr(mc, "connected", False):
try:
contacts = list(mc.get_contacts())
except Exception:
contacts = []
return {"active": True, "contacts": contacts}
return {"active": False, "contacts": []}
@router.get("/meshcore/self")
async def meshcore_self(request: Request):
"""Companion self/connection status if a meshcore transport is connected."""
connector = getattr(request.app.state, "connector", None)
mc = _find_child(connector, "meshcore")
if mc is not None and getattr(mc, "connected", False):
try:
return mc.self_info()
except Exception:
return {"connected": False}
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

@ -92,6 +92,21 @@ class CompositeTransport(MeshTransport):
child = self.meshcore_child()
return child.known_channels() if child is not None else []
def get_contacts(self) -> List[dict]:
"""Passthrough to the MeshCore child's contact roster; [] if no meshcore child."""
child = self.meshcore_child()
return child.get_contacts() if child is not None else []
def self_info(self) -> dict:
"""Passthrough to the MeshCore child's self/connection status; {connected: False} if no meshcore child."""
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
@ -170,6 +175,47 @@ class MeshCoreTransport(MeshTransport):
"""Enumerated MeshCore channel names (from _chan_name_to_idx, populated at connect)."""
return list(self._chan_name_to_idx.keys())
def get_contacts(self) -> list[dict]:
"""Roster of known MeshCore contacts. [] if not connected."""
if self._mc is None or not self._connected:
return []
try:
ensure = getattr(self._mc, "ensure_contacts", None)
if ensure is not None:
self._run_coro(ensure())
except Exception:
pass
contacts = getattr(self._mc, "contacts", None) or {}
roster: list[dict] = []
for pubkey_hex, c in contacts.items():
if not isinstance(c, dict):
continue
roster.append({
"name": c.get("adv_name"),
"pubkey": c.get("public_key") or pubkey_hex,
"type": c.get("type"),
"last_advert": c.get("last_advert"),
"lat": c.get("adv_lat"),
"lon": c.get("adv_lon"),
"out_path_len": c.get("out_path_len"),
})
return roster
def self_info(self) -> dict:
"""Companion self/connection status. {connected: False} if not connected."""
if self._mc is None or not self._connected:
return {"connected": False}
info = self._self_info or {}
return {
"name": info.get("name"),
"pubkey": info.get("public_key"),
"connected": True,
"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:
"""Set (or clear) the MeshCore passive-context filter config.
@ -177,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)
# ------------------------------------------------------------------
@ -269,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"),
@ -277,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

@ -157,3 +157,196 @@ def test_meshcore_channels_no_meshcore():
r = client.get("/api/meshcore/channels")
assert r.status_code == 200
assert r.json() == {"active": False, "channels": []}
# ============================================================================
# GET /api/meshcore/contacts
# ============================================================================
_SAMPLE_ROSTER = [
{
"name": "Repeater One",
"pubkey": "aa11deadbeef",
"type": "repeater",
"last_advert": 1000,
"lat": 43.6,
"lon": -116.2,
"out_path_len": 2,
},
{
"name": "Sensor Two",
"pubkey": "bb22cafef00d",
"type": "sensor",
"last_advert": 2000,
"lat": None,
"lon": None,
"out_path_len": -1,
},
]
def test_meshcore_contacts_active():
mc = _child("meshcore", connected=True)
mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/contacts")
assert r.status_code == 200
assert r.json() == {"active": True, "contacts": _SAMPLE_ROSTER}
def test_meshcore_contacts_no_meshcore():
mt = _child("meshtastic", connected=True)
connector = _composite([mt])
client = _client(connector)
r = client.get("/api/meshcore/contacts")
assert r.status_code == 200
assert r.json() == {"active": False, "contacts": []}
def test_meshcore_contacts_disconnected():
mc = _child("meshcore", connected=False)
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/contacts")
assert r.status_code == 200
assert r.json() == {"active": False, "contacts": []}
# ============================================================================
# GET /api/meshcore/self
# ============================================================================
def test_meshcore_self_active():
mc = _child("meshcore", connected=True)
mc.self_info.return_value = {
"name": "AIDA",
"pubkey": "deadbeef1234",
"connected": True,
"host": "100.64.0.9",
"port": 5050,
"channel_count": 2,
}
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/self")
assert r.status_code == 200
body = r.json()
assert body["connected"] is True
assert body["pubkey"] == "deadbeef1234"
assert body["name"] == "AIDA"
assert body["channel_count"] == 2
def test_meshcore_self_no_meshcore():
mt = _child("meshtastic", connected=True)
connector = _composite([mt])
client = _client(connector)
r = client.get("/api/meshcore/self")
assert r.status_code == 200
assert r.json() == {"connected": False}
def test_meshcore_self_disconnected():
mc = _child("meshcore", connected=False)
connector = _composite([mc])
client = _client(connector)
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
@ -508,3 +513,284 @@ class TestMyNodeId:
t = MeshCoreTransport(_mc_config())
t._self_info = {}
assert t.my_node_id is None
# ---------------------------------------------------------------------------
# 7. get_contacts() — roster mapping
# ---------------------------------------------------------------------------
# Sample companion contact table: pubkey_hex -> raw contact dict, mirroring the
# meshcore lib's ``mc.contacts`` shape (repeater + sensor, with/without pos).
_SAMPLE_CONTACTS = {
"aa11": {
"adv_name": "Repeater One",
"public_key": "aa11deadbeef",
"type": "repeater",
"last_advert": 1000,
"adv_lat": 43.6,
"adv_lon": -116.2,
"out_path_len": 2,
},
"bb22": {
"adv_name": "Sensor Two",
"public_key": "bb22cafef00d",
"type": "sensor",
"last_advert": 2000,
"adv_lat": None,
"adv_lon": None,
"out_path_len": -1,
},
}
class TestGetContacts:
def test_maps_contacts_into_roster_shape(self):
"""mc.contacts dict is mapped into the roster shape (repeater + sensor)."""
t, mc, _ = _transport_with_mock_mc()
try:
mc.ensure_contacts = AsyncMock(return_value=None)
mc.contacts = dict(_SAMPLE_CONTACTS)
roster = t.get_contacts()
assert isinstance(roster, list)
assert len(roster) == 2
by_name = {r["name"]: r for r in roster}
rep = by_name["Repeater One"]
assert rep == {
"name": "Repeater One",
"pubkey": "aa11deadbeef",
"type": "repeater",
"last_advert": 1000,
"lat": 43.6,
"lon": -116.2,
"out_path_len": 2,
}
sensor = by_name["Sensor Two"]
assert sensor["type"] == "sensor"
assert sensor["pubkey"] == "bb22cafef00d"
assert sensor["lat"] is None
assert sensor["lon"] is None
assert sensor["out_path_len"] == -1
finally:
_cleanup(t)
def test_pubkey_falls_back_to_hex_key(self):
"""When a contact carries no public_key, the dict hex key is used."""
t, mc, _ = _transport_with_mock_mc()
try:
mc.ensure_contacts = AsyncMock(return_value=None)
mc.contacts = {"ff00": {"adv_name": "NoKey", "type": "chat"}}
roster = t.get_contacts()
assert len(roster) == 1
assert roster[0]["pubkey"] == "ff00"
assert roster[0]["name"] == "NoKey"
finally:
_cleanup(t)
def test_works_without_ensure_contacts(self):
"""A companion lacking ensure_contacts still yields the roster."""
t, mc, _ = _transport_with_mock_mc()
try:
mc.ensure_contacts = None
mc.contacts = dict(_SAMPLE_CONTACTS)
roster = t.get_contacts()
assert len(roster) == 2
finally:
_cleanup(t)
def test_returns_empty_when_not_connected(self):
"""A fresh, unconnected transport (_mc is None) returns []."""
t = MeshCoreTransport(_mc_config())
assert t.get_contacts() == []
# ---------------------------------------------------------------------------
# 8. self_info() — companion self/connection status
# ---------------------------------------------------------------------------
class TestSelfInfo:
def test_connected_returns_status_dict(self):
"""Connected: returns name/pubkey/connected/host/port/channel_count."""
t, mc, _ = _transport_with_mock_mc()
try:
# Build _chan_name_to_idx from the fixture's fake channel table so
# channel_count is non-zero (fixture wires get_channel but doesn't enumerate).
t._enumerate_channels()
t._self_info = {"public_key": "deadbeef1234", "name": "AIDA"}
info = t.self_info()
assert info["name"] == "AIDA"
assert info["pubkey"] == "deadbeef1234"
assert info["connected"] is True
assert info["host"] == "127.0.0.1"
assert info["port"] == 5050
# channel_count reflects the installed fake channel table.
assert info["channel_count"] == len(t.known_channels())
assert info["channel_count"] == 2
finally:
_cleanup(t)
def test_not_connected_returns_disconnected(self):
"""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"