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>
This commit is contained in:
Matt Johnson 2026-07-03 21:46:55 +00:00
commit ef16bbcfcd
8 changed files with 605 additions and 41 deletions

View file

@ -492,6 +492,35 @@ 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
}
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 sendTestMessage(body: {
transport: 'meshtastic' | 'meshcore'
channel: string | number

View file

@ -1,34 +1,140 @@
import { useEffect } from 'react'
import { useState, useEffect } from 'react'
import { Bot } from 'lucide-react'
import {
fetchMeshcoreSelf,
getMeshcoreChannels,
type MeshcoreSelf,
type MeshcoreChannels,
} from '../lib/api'
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)
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
}
}, [])
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>
</dl>
</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>
{/* 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

@ -39,6 +39,33 @@ 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}
class TestSendRequest(BaseModel):
transport: str
channel: Union[str, int]

View file

@ -92,6 +92,16 @@ 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}
# ------------------------------------------------------------------
# Routing decision helpers (factored out for unit-test access)
# ------------------------------------------------------------------

View file

@ -170,6 +170,46 @@ 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()),
}
def set_context_config(self, cfg) -> None:
"""Set (or clear) the MeshCore passive-context filter config.

View file

@ -157,3 +157,108 @@ 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}

View file

@ -508,3 +508,124 @@ 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}