feat(meshcore): report the true connection + add roster/channel management

self_info() reported host/port straight from config regardless of
conn_type, so a serial companion still advertised whatever stale
meshcore_host sat in the config — the API named a device meshai was not
talking to, which is enough to send an investigation to the wrong radio.
Connection details now come from one _connection_descriptor() shared with
connect(), so the log line and the API can't drift; only the live
conn_type's fields are populated and the rest are null.

meshai's device view is otherwise built once at connect and never re-read
— contacts via ensure_contacts(), channels via _enumerate_channels(). The
lib's contact handler only ever merges (meshcore.py::_update_contacts), so
a cached roster can never shrink, and a channel provisioned on the radio
stays invisible until the process restarts. There was no refetch path at
all. Adds an explicit resync that re-reads BOTH halves: a FULL
get_contacts(lastmod=0) reconciled with replace semantics (absent contacts
are dropped) plus a channel re-enumeration, each reporting what changed.

Also adds a preventive route-health check: every region_routes cell whose
MeshCore target cannot be resolved against the live roster/channel table
is surfaced, since such a send fails silently. Room targets are matched by
pubkey prefix, exactly as the dispatcher resolves them, so a picker-stored
prefix is not misreported as dangling. Same-name/different-pubkey roster
entries are flagged too — a name alone cannot identify a contact, which is
the trap behind a room rebuilt under a new keypair.

Backend:
- meshcore_roster.py: pure reconcile_contacts / check_route_health /
  find_name_collisions (no device I/O — unit-testable without a radio)
- transport: _connection_descriptor, resync, refresh_contacts,
  remove_contact, import_contact, export_roster, contacts_synced_at;
  auto_update_contacts enabled (configurable — it costs one incremental
  fetch per advert heard, which is real chatter on a dense mesh)
- API: POST contacts/refresh, DELETE contacts/{pubkey}, GET
  contacts/export, POST contacts/import, GET route-health

Frontend (existing Contacts & Companion page — no new page or nav entry):
- dangling-route + name-collision banners; resync/export/add-contact
  toolbar with last-synced and the added/removed counts; staleness badges;
  search, filters and sortable columns; per-contact delete behind a
  confirm; Companion tab shows the real transport + target.

A full pubkey is required to delete or add: the lib resolves by prefix,
and a prefix could silently hit the wrong node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-07-17 03:50:36 +00:00
commit aa18f642aa
14 changed files with 2970 additions and 502 deletions

View file

@ -699,15 +699,25 @@ export interface MeshcoreContact {
export interface MeshcoreContacts { export interface MeshcoreContacts {
active: boolean active: boolean
contacts: MeshcoreContact[] contacts: MeshcoreContact[]
last_synced_at?: number | null // epoch seconds the roster was pulled from the companion
} }
export interface MeshcoreSelf { export interface MeshcoreSelf {
name?: string | null name?: string | null
pubkey?: string | null pubkey?: string | null
connected: boolean connected: boolean
host?: string // Connection reporting: only the fields for the live `conn_type` are set;
port?: number // the rest are null. A serial companion has no host/port — showing a config
// leftover there would name a device meshai is not actually talking to.
conn_type?: 'tcp' | 'serial' | 'ble' | string
target?: string // human-readable: "serial:/dev/x@115200" | "host:port" | "ble:addr"
host?: string | null
port?: number | null
serial_port?: string | null
baud?: number | null
ble_address?: string | null
channel_count?: number channel_count?: number
last_advert_sent?: number | null // epoch seconds; null/absent = never advertised last_advert_sent?: number | null // epoch seconds; null/absent = never advertised
contacts_synced_at?: number | null
} }
export async function fetchMeshcoreContacts(): Promise<MeshcoreContacts> { export async function fetchMeshcoreContacts(): Promise<MeshcoreContacts> {
@ -717,6 +727,109 @@ export async function fetchMeshcoreSelf(): Promise<MeshcoreSelf> {
return fetchJson<MeshcoreSelf>('/api/meshcore/self') return fetchJson<MeshcoreSelf>('/api/meshcore/self')
} }
// Result of a full roster resync: what the reconcile actually changed.
export interface MeshcoreRefreshStats {
before: number
after: number
added: number
removed: number
updated: number
added_keys: string[]
removed_keys: string[]
}
// Channels are also a connect-time snapshot, so a resync re-reads them too.
export interface MeshcoreChannelStats {
before: number
after: number
added: string[]
removed: string[]
}
export interface MeshcoreRefreshResult {
active: boolean
stats: MeshcoreRefreshStats
channel_stats: MeshcoreChannelStats
contacts: MeshcoreContact[]
channels: string[]
last_synced_at: number | null
}
// Re-read the companion's device view: FULL contact refetch + reconcile (drops
// contacts the companion no longer has) AND channel re-enumeration. Throws with
// the backend's `detail` when MeshCore is not connected or the fetch fails.
export async function refreshMeshcoreContacts(): Promise<MeshcoreRefreshResult> {
const response = await fetch('/api/meshcore/contacts/refresh', { method: 'POST' })
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.detail || `API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
// Write contact records onto the companion (upsert; nothing is removed, and no
// mesh traffic is generated). Used both for a manual single add — e.g. after a
// room server is rebuilt with a new keypair — and for restoring an exported
// roster onto a replacement companion.
export interface MeshcoreImportResult {
active: boolean
imported: number
failed: number
errors: { pubkey: string | null; detail: string }[]
}
export async function importMeshcoreContacts(
contacts: Record<string, unknown>[],
): Promise<MeshcoreImportResult> {
const response = await fetch('/api/meshcore/contacts/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contacts }),
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.detail || `API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
// Remove a contact from the companion. Requires the FULL 64-hex pubkey.
export async function removeMeshcoreContact(pubkey: string): Promise<MeshcoreContacts> {
const response = await fetch(`/api/meshcore/contacts/${encodeURIComponent(pubkey)}`, {
method: 'DELETE',
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.detail || `API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
// A routing cell whose MeshCore target does not resolve on the companion.
export interface MeshcoreDanglingRoute {
family: string
region: string
target: string
kind: 'room' | 'channel' | string
reason: 'room_not_found' | 'channel_not_found' | 'not_a_room' | string
enabled: boolean
}
// Roster entries sharing a name but not a keypair — indistinguishable by name.
export interface MeshcoreNameCollision {
name: string
count: number
contacts: { pubkey: string; type: number | null }[]
}
export interface MeshcoreRouteHealth {
active: boolean
dangling: MeshcoreDanglingRoute[]
dangling_enabled: number
collisions: MeshcoreNameCollision[]
checked: number
mc_enabled: boolean
}
export async function fetchMeshcoreRouteHealth(): Promise<MeshcoreRouteHealth> {
return fetchJson<MeshcoreRouteHealth>('/api/meshcore/route-health')
}
export async function sendMeshcoreAdvert(): Promise<TestSendResult> { export async function sendMeshcoreAdvert(): Promise<TestSendResult> {
const response = await fetch('/api/meshcore/advert', { const response = await fetch('/api/meshcore/advert', {
method: 'POST', method: 'POST',

View file

@ -10,6 +10,32 @@ import {
type TestSendResult, type TestSendResult,
} from '../lib/api' } from '../lib/api'
// How meshai is attached to the companion. Distinct colours so the transport is
// readable at a glance — an operator must be able to tell WHICH device this is
// before acting on it.
const CONN_TYPE_BADGE: Record<string, string> = {
serial: 'bg-emerald-500/15 text-emerald-400',
tcp: 'bg-sky-500/15 text-sky-400',
ble: 'bg-violet-500/15 text-violet-400',
}
/** The live connection target, from whichever fields belong to this conn_type.
*
* Falls back to the per-transport fields if `target` is absent (older backend).
* Never falls back to host/port for a non-TCP link: that is exactly the
* mistake this display exists to prevent.
*/
function connectionTarget(self: MeshcoreSelf | null): string {
if (!self) return '—'
if (self.target) return self.target
if (self.conn_type === 'serial') {
return self.serial_port ? `${self.serial_port}@${self.baud ?? 115200}` : 'serial'
}
if (self.conn_type === 'ble') return self.ble_address || 'ble'
if (self.host) return `${self.host}${self.port != null ? `:${self.port}` : ''}`
return '—'
}
/** Format epoch seconds as a human-readable relative time string. */ /** Format epoch seconds as a human-readable relative time string. */
function relativeTime(epochSec: number): string { function relativeTime(epochSec: number): string {
const diffSec = Math.floor(Date.now() / 1000 - epochSec) const diffSec = Math.floor(Date.now() / 1000 - epochSec)
@ -178,10 +204,18 @@ export default function MeshCoreCompanion() {
<dd className="text-slate-100">{self?.name ?? 'unnamed'}</dd> <dd className="text-slate-100">{self?.name ?? 'unnamed'}</dd>
</div> </div>
<div> <div>
<dt className="text-[#777] mb-1">Host</dt> <dt className="text-[#777] mb-1">Connection</dt>
<dd className="text-slate-100 font-mono"> <dd className="flex items-center gap-2">
{self?.host ?? '—'} <span
{self?.port != null ? `:${self.port}` : ''} className={`px-1.5 py-0.5 text-[10px] uppercase tracking-wide rounded ${
CONN_TYPE_BADGE[self?.conn_type ?? ''] ?? 'bg-slate-600/30 text-slate-400'
}`}
>
{self?.conn_type ?? 'unknown'}
</span>
<span className="text-slate-100 font-mono text-xs break-all">
{connectionTarget(self)}
</span>
</dd> </dd>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">

View file

@ -1,13 +1,20 @@
import { Fragment, useCallback, useEffect, useState } from 'react' import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { Users } from 'lucide-react' import { AlertTriangle, Copy, Download, Plus, RefreshCw, Trash2, Users } from 'lucide-react'
import { import {
fetchMeshcoreContacts, fetchMeshcoreContacts,
fetchMeshcoreRouteHealth,
fetchMeshcoreTelemetry, fetchMeshcoreTelemetry,
fetchConnectionConfig, fetchConnectionConfig,
importMeshcoreContacts,
pollMeshcoreContact, pollMeshcoreContact,
refreshMeshcoreContacts,
removeMeshcoreContact,
updateConfig, updateConfig,
type MeshcoreChannelStats,
type MeshcoreContacts, type MeshcoreContacts,
type MeshcoreContact, type MeshcoreContact,
type MeshcoreRefreshStats,
type MeshcoreRouteHealth,
type MeshcoreTelemetry, type MeshcoreTelemetry,
type MeshcoreTelemetryEntry, type MeshcoreTelemetryEntry,
type MeshcoreTelemetryData, type MeshcoreTelemetryData,
@ -18,10 +25,32 @@ import {
const TELEMETRY_POLL_MS = 15000 const TELEMETRY_POLL_MS = 15000
const MIN_INTERVAL_MINUTES = 5 const MIN_INTERVAL_MINUTES = 5
// Relative time for epoch-seconds fields (last_advert). // A contact not heard from in this long is flagged stale. Adverts are typically
// hours apart, so days — not hours — is the honest threshold for "gone quiet".
const STALE_AFTER_DAYS = 14
const STALE_AFTER_SECONDS = STALE_AFTER_DAYS * 86400
type SortKey = 'name' | 'type' | 'last_advert'
type TypeFilter = 'all' | 'rooms' | 'stale'
function isStale(c: MeshcoreContact): boolean {
if (c.last_advert == null || c.last_advert <= 0) return false
return Math.floor(Date.now() / 1000) - c.last_advert > STALE_AFTER_SECONDS
}
// Why a routing cell will not resolve, in operator language.
const DANGLING_REASON: Record<string, string> = {
room_not_found: 'no room server with this key is on the companion',
channel_not_found: 'this channel is not on the companion',
not_a_room: 'this key belongs to a contact that is not a room server',
}
// Relative time for epoch-seconds fields (last_advert, last_synced_at).
// Floors the DIFFERENCE, not just the clock: last_synced_at is a float, so
// flooring only Date.now() would leave a fractional "28.6851...s ago".
function relativeTime(epochSeconds: number | null): string { function relativeTime(epochSeconds: number | null): string {
if (epochSeconds == null) return '—' if (epochSeconds == null) return '—'
const diff = Math.floor(Date.now() / 1000) - epochSeconds const diff = Math.floor(Date.now() / 1000 - epochSeconds)
if (diff < 0) return 'just now' if (diff < 0) return 'just now'
if (diff < 60) return `${diff}s ago` if (diff < 60) return `${diff}s ago`
const mins = Math.floor(diff / 60) const mins = Math.floor(diff / 60)
@ -145,6 +174,31 @@ export default function MeshCoreContacts() {
const [connectionConfig, setConnectionConfig] = useState<ConnectionConfig | null>(null) const [connectionConfig, setConnectionConfig] = useState<ConnectionConfig | null>(null)
const [telemetry, setTelemetry] = useState<MeshcoreTelemetry | null>(null) const [telemetry, setTelemetry] = useState<MeshcoreTelemetry | null>(null)
// Roster management: resync / export / delete / route health.
const [routeHealth, setRouteHealth] = useState<MeshcoreRouteHealth | null>(null)
const [resyncing, setResyncing] = useState(false)
const [resyncStats, setResyncStats] = useState<MeshcoreRefreshStats | null>(null)
const [channelStats, setChannelStats] = useState<MeshcoreChannelStats | null>(null)
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null)
// Manual add — the companion learns most contacts by advert, but a node that
// has been rekeyed (or is not yet heard) has to be entered by key.
const [showAdd, setShowAdd] = useState(false)
const [addName, setAddName] = useState('')
const [addPubkey, setAddPubkey] = useState('')
const [addType, setAddType] = useState(1)
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [expandedKey, setExpandedKey] = useState<string | null>(null)
// Table controls.
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all')
const [sortKey, setSortKey] = useState<SortKey>('name')
const [sortAsc, setSortAsc] = useState(true)
// Per-row transient UI state. // Per-row transient UI state.
const [savingId, setSavingId] = useState<string | null>(null) const [savingId, setSavingId] = useState<string | null>(null)
const [savedId, setSavedId] = useState<string | null>(null) const [savedId, setSavedId] = useState<string | null>(null)
@ -169,7 +223,10 @@ export default function MeshCoreContacts() {
setError(null) setError(null)
try { try {
const result = await fetchMeshcoreContacts() const result = await fetchMeshcoreContacts()
if (!cancelled) setData(result) if (!cancelled) {
setData(result)
setLastSyncedAt(result.last_synced_at ?? null)
}
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load contacts') setError(err instanceof Error ? err.message : 'Failed to load contacts')
@ -183,6 +240,21 @@ export default function MeshCoreContacts() {
} }
}, []) }, [])
// Route health (once): which routing cells point at something that no longer
// exists. Non-fatal — the roster is still useful if this check fails.
const loadRouteHealth = useCallback(async () => {
try {
const health = await fetchMeshcoreRouteHealth()
setRouteHealth(health)
} catch {
// non-fatal — banner simply stays hidden
}
}, [])
useEffect(() => {
loadRouteHealth()
}, [loadRouteHealth])
// Connection config (once) — kept whole so PUTs send it back intact. // Connection config (once) — kept whole so PUTs send it back intact.
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -292,6 +364,150 @@ export default function MeshCoreContacts() {
} }
}, []) }, [])
// Full resync: refetch the whole roster from the node and reconcile, so
// entries the companion no longer has are dropped rather than lingering.
const handleResync = useCallback(async () => {
setResyncing(true)
setResyncStats(null)
setChannelStats(null)
setSaveError(null)
try {
const result = await refreshMeshcoreContacts()
setData({ active: result.active, contacts: result.contacts })
setLastSyncedAt(result.last_synced_at)
setResyncStats(result.stats)
setChannelStats(result.channel_stats)
// Roster and channels just changed — re-check the routing cells against them.
loadRouteHealth()
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Resync failed')
} finally {
setResyncing(false)
}
}, [loadRouteHealth])
// Add a contact by key. Minimal record: the companion fills in the rest when
// the node next adverts; out_path_len -1 means "flood until a path is known".
const handleAdd = useCallback(async () => {
const pubkey = addPubkey.trim().toLowerCase()
const name = addName.trim()
if (!/^[0-9a-f]{64}$/.test(pubkey)) {
setAddError('Pubkey must be exactly 64 hex characters (the full key, not a prefix)')
return
}
if (!name) {
setAddError('A name is required')
return
}
setAdding(true)
setAddError(null)
try {
const result = await importMeshcoreContacts([
{ pubkey, name, type: addType, flags: 0, out_path_len: -1, out_path: '', last_advert: 0 },
])
if (result.failed > 0) {
setAddError(result.errors[0]?.detail || 'Add failed')
return
}
setShowAdd(false)
setAddName('')
setAddPubkey('')
// Re-read so the new contact appears with whatever the companion stored.
const refreshed = await fetchMeshcoreContacts()
setData(refreshed)
setLastSyncedAt(refreshed.last_synced_at ?? null)
loadRouteHealth()
} catch (err) {
setAddError(err instanceof Error ? err.message : 'Add failed')
} finally {
setAdding(false)
}
}, [addPubkey, addName, addType, loadRouteHealth])
// Export streams from the API (not from component state) so the file is the
// full importable record set, not the display projection shown in the table.
const handleExport = useCallback(() => {
window.location.href = '/api/meshcore/contacts/export'
}, [])
const handleDelete = useCallback(async (c: MeshcoreContact) => {
setDeletingId(c.pubkey)
setSaveError(null)
try {
const result = await removeMeshcoreContact(c.pubkey)
setData((prev) => ({ active: true, contacts: result.contacts, last_synced_at: prev?.last_synced_at ?? null }))
setConfirmDelete(null)
loadRouteHealth()
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Delete failed')
} finally {
setDeletingId((d) => (d === c.pubkey ? null : d))
}
}, [loadRouteHealth])
const handleCopyPubkey = useCallback(async (pubkey: string) => {
try {
await navigator.clipboard.writeText(pubkey)
} catch {
// clipboard unavailable (non-secure context) — no-op
}
}, [])
const toggleSort = useCallback((key: SortKey) => {
setSortKey((prev) => {
if (prev === key) {
setSortAsc((asc) => !asc)
return prev
}
setSortAsc(true)
return key
})
}, [])
// Names collide across keypairs, so a name alone cannot identify a contact.
// Flag the ambiguous ones inline rather than leaving two identical rows.
const collidingNames = useMemo(() => {
const names = new Set<string>()
for (const collision of routeHealth?.collisions ?? []) names.add(collision.name)
return names
}, [routeHealth])
const visibleContacts = useMemo(() => {
let list = data?.contacts ?? []
const needle = search.trim().toLowerCase()
if (needle) {
list = list.filter(
(c) =>
(c.name ?? '').toLowerCase().includes(needle) ||
c.pubkey.toLowerCase().includes(needle)
)
}
if (typeFilter === 'rooms') list = list.filter((c) => c.type === 3)
else if (typeFilter === 'stale') list = list.filter(isStale)
const sorted = [...list].sort((a, b) => {
let cmp = 0
if (sortKey === 'name') {
cmp = (a.name ?? '').localeCompare(b.name ?? '')
} else if (sortKey === 'type') {
cmp = (a.type ?? 0) - (b.type ?? 0)
} else {
cmp = (a.last_advert ?? 0) - (b.last_advert ?? 0)
}
return sortAsc ? cmp : -cmp
})
return sorted
}, [data, search, typeFilter, sortKey, sortAsc])
const staleCount = useMemo(
() => (data?.contacts ?? []).filter(isStale).length,
[data]
)
const roomCount = useMemo(
() => (data?.contacts ?? []).filter((c) => c.type === 3).length,
[data]
)
const handleSaveInterval = useCallback(async () => { const handleSaveInterval = useCallback(async () => {
if (!connectionConfig) return if (!connectionConfig) return
const minutes = Math.max(MIN_INTERVAL_MINUTES, Math.round(intervalMinutes) || MIN_INTERVAL_MINUTES) const minutes = Math.max(MIN_INTERVAL_MINUTES, Math.round(intervalMinutes) || MIN_INTERVAL_MINUTES)
@ -316,9 +532,11 @@ export default function MeshCoreContacts() {
}, [connectionConfig, intervalMinutes]) }, [connectionConfig, intervalMinutes])
const rosterActive = data?.active !== false const rosterActive = data?.active !== false
const dangling = routeHealth?.dangling ?? []
const collisions = routeHealth?.collisions ?? []
return ( return (
<div className="max-w-4xl mx-auto space-y-4"> <div className="max-w-5xl mx-auto space-y-4">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <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"> <div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
@ -333,6 +551,192 @@ export default function MeshCoreContacts() {
</div> </div>
</div> </div>
{/* Dangling-route warning a cell pointing into the void fails SILENTLY
at send time, so this is the only place it becomes visible. */}
{dangling.length > 0 && (
<div className="border border-red-500/40 bg-red-500/10 p-4 space-y-2">
<div className="flex items-start gap-3">
<AlertTriangle size={18} className="text-red-400 flex-shrink-0 mt-0.5" />
<div className="space-y-2 min-w-0">
<h3 className="text-sm font-semibold text-red-300">
{dangling.length} routing {dangling.length === 1 ? 'cell points' : 'cells point'} at a
destination that no longer exists
</h3>
<p className="text-xs text-red-300/70 max-w-prose">
These cells cannot be delivered a send to a missing room or channel fails
silently. Fix the target on the Routing page, or resync if the roster is stale.
</p>
<ul className="space-y-1">
{dangling.map((d) => (
<li
key={`${d.family}-${d.region}-${d.target}`}
className="text-xs text-slate-200 flex flex-wrap items-center gap-x-2 gap-y-1"
>
<span className="px-1.5 py-0.5 rounded bg-red-500/20 text-red-300 uppercase tracking-wide text-[10px]">
{d.family}
</span>
<span className="text-slate-300">{d.region}</span>
<span className="text-[#777]"></span>
<span className="font-mono text-[11px] text-red-300 break-all">{d.target}</span>
<span className="text-[#777]">
{DANGLING_REASON[d.reason] ?? d.reason}
</span>
{!d.enabled && (
<span className="px-1.5 py-0.5 rounded bg-slate-600/30 text-slate-400 text-[10px] uppercase tracking-wide">
disabled
</span>
)}
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Name collisions two keypairs advertising the same name are
indistinguishable in any name-based picker. */}
{collisions.length > 0 && (
<div className="border border-amber-500/40 bg-amber-500/10 p-4">
<div className="flex items-start gap-3">
<AlertTriangle size={18} className="text-amber-400 flex-shrink-0 mt-0.5" />
<div className="space-y-2 min-w-0">
<h3 className="text-sm font-semibold text-amber-300">
{collisions.length} duplicated {collisions.length === 1 ? 'name' : 'names'} on the roster
</h3>
<p className="text-xs text-amber-300/70 max-w-prose">
These names each map to more than one public key. A name alone cannot identify
them always confirm the key before routing to or deleting one.
</p>
<ul className="space-y-1">
{collisions.map((c) => (
<li key={c.name} className="text-xs text-slate-200">
<span className="text-slate-100">{c.name}</span>{' '}
<span className="text-[#777]">×{c.count}</span>
<span className="ml-2 font-mono text-[11px] text-amber-300/80">
{c.contacts.map((x) => shortPubkey(x.pubkey)).join(' · ')}
</span>
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Roster toolbar: sync state + resync/export */}
{rosterActive && (
<div className="bg-bg-card border border-border p-4 space-y-3">
<div className="flex flex-wrap items-center gap-3">
<button
onClick={handleResync}
disabled={resyncing}
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50"
title="Refetch the full roster from the companion and drop entries it no longer has"
>
<RefreshCw size={14} className={resyncing ? 'animate-spin' : undefined} />
{resyncing ? 'Resyncing…' : 'Resync from node'}
</button>
<button
onClick={handleExport}
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded bg-[#0a0e17] border border-[#1e2a3a] text-slate-200 hover:border-accent/40"
title="Download the roster as JSON"
>
<Download size={14} />
Export JSON
</button>
<button
onClick={() => { setShowAdd((s) => !s); setAddError(null) }}
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded bg-[#0a0e17] border border-[#1e2a3a] text-slate-200 hover:border-accent/40"
title="Add a contact by public key"
>
<Plus size={14} />
Add contact
</button>
<span className="text-xs text-[#777]">
Last synced {lastSyncedAt != null ? relativeTime(lastSyncedAt) : 'unknown'}
</span>
{resyncStats && (
<span className="text-xs text-slate-300">
<span className="text-emerald-400">+{resyncStats.added} added</span>
{' · '}
<span className="text-red-400">{resyncStats.removed} removed</span>
{' · '}
<span className="text-[#777]">{resyncStats.updated} updated</span>
{' · '}
<span className="text-[#777]">{resyncStats.after} total</span>
{channelStats && (
<span className="text-[#777]">
{' · '}channels {channelStats.after}
{channelStats.added.length > 0 && (
<span className="text-emerald-400"> +{channelStats.added.length}</span>
)}
{channelStats.removed.length > 0 && (
<span className="text-red-400"> {channelStats.removed.length}</span>
)}
</span>
)}
</span>
)}
</div>
{/* Add a contact by key the companion learns most nodes by advert,
but a rekeyed or out-of-range node has to be entered manually. */}
{showAdd && (
<div className="border border-[#1e2a3a] bg-[#0a0e17] p-3 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<input
value={addName}
onChange={(e) => setAddName(e.target.value)}
placeholder="Name"
className="w-40 px-2 py-1 text-sm bg-bg-card border border-[#1e2a3a] rounded text-slate-100 placeholder:text-[#555]"
/>
<input
value={addPubkey}
onChange={(e) => setAddPubkey(e.target.value)}
placeholder="Full 64-character hex public key"
className="flex-1 min-w-[280px] px-2 py-1 text-sm font-mono bg-bg-card border border-[#1e2a3a] rounded text-slate-100 placeholder:text-[#555]"
/>
<select
value={addType}
onChange={(e) => setAddType(Number(e.target.value))}
className="px-2 py-1 text-sm bg-bg-card border border-[#1e2a3a] rounded text-slate-200"
>
<option value={1}>Chat</option>
<option value={2}>Repeater</option>
<option value={3}>Room</option>
<option value={4}>Sensor</option>
</select>
<button
onClick={handleAdd}
disabled={adding}
className="px-3 py-1 text-sm rounded bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50"
>
{adding ? 'Adding…' : 'Add'}
</button>
<button
onClick={() => setShowAdd(false)}
className="px-2 py-1 text-sm text-[#777] hover:text-slate-200"
>
Cancel
</button>
</div>
{addError && <p className="text-xs text-red-400">{addError}</p>}
<p className="text-xs text-[#777] max-w-prose">
Writes the contact straight to the companion &mdash; nothing is transmitted. Use this
when a node has been rebuilt with a new keypair, or is not yet in range to advert.
</p>
</div>
)}
<p className="text-xs text-[#777] max-w-prose">
The roster and channel list are a snapshot cached from the companion at connect. Resync
re-reads both from the node and reconciles them &mdash; the only action that removes
entries the companion has dropped, or picks up a channel added on the radio.
</p>
</div>
)}
{/* Auto-poll interval control */} {/* Auto-poll interval control */}
{rosterActive && connectionConfig && ( {rosterActive && connectionConfig && (
<div className="bg-bg-card border border-border p-4 space-y-2"> <div className="bg-bg-card border border-border p-4 space-y-2">
@ -389,13 +793,56 @@ export default function MeshCoreContacts() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="bg-bg-card border border-border overflow-x-auto"> <div className="bg-bg-card border border-border">
{/* Filter / search toolbar */}
<div className="flex flex-wrap items-center gap-3 px-4 py-3 border-b border-border">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name or pubkey…"
className="flex-1 min-w-[180px] px-2 py-1 text-sm bg-[#0a0e17] border border-[#1e2a3a] rounded text-slate-100 placeholder:text-[#555]"
/>
<div className="flex gap-1">
{([
{ key: 'all', label: `All ${data?.contacts.length ?? 0}` },
{ key: 'rooms', label: `Rooms ${roomCount}` },
{ key: 'stale', label: `Stale ${staleCount}` },
] as const).map(({ key, label }) => (
<button
key={key}
onClick={() => setTypeFilter(key)}
className={`px-2.5 py-1 text-xs rounded border transition-colors ${
typeFilter === key
? 'border-accent/40 bg-accent/15 text-accent'
: 'border-[#1e2a3a] bg-[#0a0e17] text-[#777] hover:text-slate-200'
}`}
>
{label}
</button>
))}
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-[#777]"> <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">
<th className="px-4 py-2.5 font-medium">Type</th> <button onClick={() => toggleSort('name')} className="hover:text-slate-200 uppercase">
<th className="px-4 py-2.5 font-medium">Last heard</th> Name{sortKey === 'name' ? (sortAsc ? ' ▲' : ' ▼') : ''}
</button>
</th>
<th className="px-4 py-2.5 font-medium">
<button onClick={() => toggleSort('type')} className="hover:text-slate-200 uppercase">
Type{sortKey === 'type' ? (sortAsc ? ' ▲' : ' ▼') : ''}
</button>
</th>
<th className="px-4 py-2.5 font-medium">
<button onClick={() => toggleSort('last_advert')} className="hover:text-slate-200 uppercase">
Last heard{sortKey === 'last_advert' ? (sortAsc ? ' ▲' : ' ▼') : ''}
</button>
</th>
<th className="px-4 py-2.5 font-medium">Position</th> <th className="px-4 py-2.5 font-medium">Position</th>
<th className="px-4 py-2.5 font-medium">Pubkey</th> <th className="px-4 py-2.5 font-medium">Pubkey</th>
<th className="px-4 py-2.5 font-medium">Auto-poll</th> <th className="px-4 py-2.5 font-medium">Auto-poll</th>
@ -403,7 +850,7 @@ export default function MeshCoreContacts() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{(data?.contacts ?? []).map((c) => { {visibleContacts.map((c) => {
const id = contactId(c) const id = contactId(c)
const entry = entryFor(c) const entry = entryFor(c)
const selected = isSelected(c) const selected = isSelected(c)
@ -437,14 +884,53 @@ export default function MeshCoreContacts() {
return ( return (
<Fragment key={c.pubkey}> <Fragment key={c.pubkey}>
<tr className="hover:bg-bg-hover"> <tr 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 text-slate-100">
<div className="flex items-center gap-2">
<span>{contactName(c)}</span>
{c.name != null && collidingNames.has(c.name) && (
<span
className="px-1.5 py-0.5 text-[10px] uppercase tracking-wide rounded bg-amber-500/15 text-amber-400"
title="Another contact advertises this same name with a different key — check the pubkey"
>
dup name
</span>
)}
</div>
</td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<TypeBadge type={c.type} /> <TypeBadge type={c.type} />
</td> </td>
<td className="px-4 py-2.5 text-slate-300">{relativeTime(c.last_advert)}</td> <td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="text-slate-300">{relativeTime(c.last_advert)}</span>
{isStale(c) && (
<span
className="px-1.5 py-0.5 text-[10px] uppercase tracking-wide rounded bg-orange-500/15 text-orange-400"
title={`Not heard from in over ${STALE_AFTER_DAYS} days`}
>
stale
</span>
)}
</div>
</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-300 font-mono text-xs">{position(c)}</td>
<td className="px-4 py-2.5 text-slate-400 font-mono text-xs"> <td className="px-4 py-2.5 text-slate-400 font-mono text-xs">
{shortPubkey(c.pubkey)} <div className="flex items-center gap-1.5">
<button
onClick={() => setExpandedKey((k) => (k === c.pubkey ? null : c.pubkey))}
className="hover:text-accent"
title={c.pubkey}
>
{expandedKey === c.pubkey ? c.pubkey : shortPubkey(c.pubkey)}
</button>
<button
onClick={() => handleCopyPubkey(c.pubkey)}
className="text-[#555] hover:text-accent flex-shrink-0"
title="Copy full pubkey"
>
<Copy size={11} />
</button>
</div>
</td> </td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<label <label
@ -472,15 +958,55 @@ export default function MeshCoreContacts() {
</label> </label>
</td> </td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<button <div className="flex items-center justify-end gap-1.5">
onClick={() => handlePollNow(c)} <button
disabled={pollingId === id} onClick={() => handlePollNow(c)}
className="px-2 py-1 text-xs rounded bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50" disabled={pollingId === id}
> className="px-2 py-1 text-xs rounded bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50"
{pollingId === id ? 'Polling…' : 'Poll now'} >
</button> {pollingId === id ? 'Polling…' : 'Poll now'}
</button>
{/* Two-step delete: removal from the companion is
permanent the node must be rediscovered. */}
{confirmDelete === c.pubkey ? (
<>
<button
onClick={() => handleDelete(c)}
disabled={deletingId === c.pubkey}
className="px-2 py-1 text-xs rounded bg-red-500/20 text-red-300 hover:bg-red-500/30 disabled:opacity-50"
>
{deletingId === c.pubkey ? 'Deleting…' : 'Confirm'}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="px-2 py-1 text-xs rounded text-[#777] hover:text-slate-200"
>
Cancel
</button>
</>
) : (
<button
onClick={() => setConfirmDelete(c.pubkey)}
className="p-1 rounded text-[#555] hover:text-red-400 hover:bg-red-500/10"
title="Remove this contact from the companion"
>
<Trash2 size={13} />
</button>
)}
</div>
</td> </td>
</tr> </tr>
{confirmDelete === c.pubkey && (
<tr className="bg-red-500/5">
<td colSpan={7} className="px-4 py-2 border-t border-red-500/20">
<span className="text-xs text-red-300">
Remove <span className="text-slate-100">{contactName(c)}</span>{' '}
<span className="font-mono text-[11px]">{shortPubkey(c.pubkey)}</span>{' '}
from the companion? It will only return if the node advertises again.
</span>
</td>
</tr>
)}
{hasReadout && ( {hasReadout && (
<tr className="bg-[#0a0e17]/40"> <tr className="bg-[#0a0e17]/40">
<td colSpan={7} className="px-4 py-2 border-t border-border/50"> <td colSpan={7} className="px-4 py-2 border-t border-border/50">
@ -500,6 +1026,12 @@ export default function MeshCoreContacts() {
})} })}
</tbody> </tbody>
</table> </table>
{visibleContacts.length === 0 && (
<div className="px-4 py-6 text-sm text-[#777]">
No contacts match this filter.
</div>
)}
</div>
</div> </div>
)} )}
</div> </div>

View file

@ -59,6 +59,10 @@ class ConnectionConfig:
meshcore_baud: int = 115200 meshcore_baud: int = 115200
meshcore_ble_address: str = "" # optional; for ble meshcore_ble_address: str = "" # optional; for ble
meshcore_auto_add_contacts: bool = True # firmware auto-adds every node it hears an advert from (so AIDA can DM anyone) meshcore_auto_add_contacts: bool = True # firmware auto-adds every node it hears an advert from (so AIDA can DM anyone)
# Refresh the cached roster whenever an advert/path-update is heard. Costs one
# incremental contact fetch to the companion per advert (local chatter, never a
# mesh send); false = lib default (connect-time snapshot + explicit resync only).
meshcore_auto_update_contacts: bool = True
meshcore_ack_wait_seconds: float = 6.0 # wait for delivery ACK before falling back to path discovery meshcore_ack_wait_seconds: float = 6.0 # wait for delivery ACK before falling back to path discovery
meshcore_discovery_wait_seconds: float = 8.0 # path-discovery timeout on the no-ACK fallback (was hardcoded 25s) meshcore_discovery_wait_seconds: float = 8.0 # path-discovery timeout on the no-ACK fallback (was hardcoded 25s)

View file

@ -1,16 +1,23 @@
"""Dashboard 'send test message' API routes (meshtastic + meshcore).""" """Dashboard 'send test message' API routes (meshtastic + meshcore)."""
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from typing import Optional, Union from typing import Optional, Union
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from meshai import secrets_store from meshai import secrets_store
from meshai.meshcore_roster import check_route_health, find_name_collisions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["mesh-send"]) router = APIRouter(tags=["mesh-send"])
# Roster export envelope: identifies the file on the way back in so an import
# can reject something that was never a meshai roster.
_ROSTER_EXPORT_FORMAT = "meshai.meshcore.roster"
_ROSTER_EXPORT_VERSION = 1
def _find_child(connector, name: str): def _find_child(connector, name: str):
"""Find a child transport by transport_name — handles bare transport or CompositeTransport.""" """Find a child transport by transport_name — handles bare transport or CompositeTransport."""
@ -163,7 +170,12 @@ async def meshcore_rooms(request: Request):
@router.get("/meshcore/contacts") @router.get("/meshcore/contacts")
async def meshcore_contacts(request: Request): async def meshcore_contacts(request: Request):
"""Roster of known MeshCore contacts if a meshcore transport is connected.""" """Roster of known MeshCore contacts if a meshcore transport is connected.
``last_synced_at`` (epoch seconds, or null) is when the roster was last
pulled from the companion, so the UI can present it as a snapshot with a
known age rather than implying it is live.
"""
connector = getattr(request.app.state, "connector", None) connector = getattr(request.app.state, "connector", None)
mc = _find_child(connector, "meshcore") mc = _find_child(connector, "meshcore")
if mc is not None and getattr(mc, "connected", False): if mc is not None and getattr(mc, "connected", False):
@ -171,8 +183,253 @@ async def meshcore_contacts(request: Request):
contacts = list(mc.get_contacts()) contacts = list(mc.get_contacts())
except Exception: except Exception:
contacts = [] contacts = []
return {"active": True, "contacts": contacts} try:
return {"active": False, "contacts": []} last_synced_at = mc.contacts_synced_at()
except Exception:
last_synced_at = None
return {"active": True, "contacts": contacts, "last_synced_at": last_synced_at}
return {"active": False, "contacts": [], "last_synced_at": None}
@router.post("/meshcore/contacts/refresh")
async def meshcore_refresh_contacts(request: Request):
"""Re-read the companion's device view — contacts AND channels.
meshai builds its picture of the device at connect and never re-reads it,
so a contact removed (or a channel provisioned) on the radio afterwards is
invisible until a restart. This is the resync path.
Unlike the passive roster read, the contact reconcile DROPS entries the
companion no longer has (the lib's own fetch only ever merges). Returns the
reconcile stats, the channel delta, and the refreshed roster + channel list.
"""
connector = getattr(request.app.state, "connector", None)
mc = _find_child(connector, "meshcore")
if mc is None or not getattr(mc, "connected", False):
raise HTTPException(status_code=409, detail="MeshCore not connected")
try:
result = mc.resync()
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc))
except Exception as exc:
logger.error("dashboard: meshcore resync error: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
stats = result.get("contacts", {})
channel_stats = result.get("channels", {})
try:
contacts = list(mc.get_contacts())
except Exception:
contacts = []
try:
channels = list(mc.known_channels())
except Exception:
channels = []
try:
last_synced_at = mc.contacts_synced_at()
except Exception:
last_synced_at = None
logger.info(
"dashboard: meshcore resync — contacts +%d/-%d (now %d), channels +%d/-%d",
stats.get("added", 0), stats.get("removed", 0), stats.get("after", 0),
len(channel_stats.get("added", [])), len(channel_stats.get("removed", [])),
)
return {
"active": True,
"stats": stats,
"channel_stats": channel_stats,
"contacts": contacts,
"channels": channels,
"last_synced_at": last_synced_at,
}
@router.get("/meshcore/contacts/export")
async def meshcore_export_contacts(request: Request):
"""Download the roster as JSON.
Records carry the full lib field set (not the display projection), so an
export can be re-imported onto a replacement companion.
"""
connector = getattr(request.app.state, "connector", None)
mc = _find_child(connector, "meshcore")
if mc is None or not getattr(mc, "connected", False):
raise HTTPException(status_code=409, detail="MeshCore not connected")
try:
records = mc.export_roster()
except Exception as exc:
logger.error("dashboard: meshcore export_roster error: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
try:
info = mc.self_info()
except Exception:
info = {}
try:
last_synced_at = mc.contacts_synced_at()
except Exception:
last_synced_at = None
payload = {
"format": _ROSTER_EXPORT_FORMAT,
"version": _ROSTER_EXPORT_VERSION,
"exported_at": datetime.now(timezone.utc).isoformat(),
"last_synced_at": last_synced_at,
# Which device this roster came off — a roster is only meaningful
# paired with the companion it was read from.
"device": {
"name": info.get("name"),
"pubkey": info.get("pubkey"),
"conn_type": info.get("conn_type"),
"target": info.get("target"),
},
"count": len(records),
"contacts": records,
}
filename = f"meshcore-roster-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.json"
return JSONResponse(
content=payload,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
class ImportContactsRequest(BaseModel):
contacts: list[dict]
@router.post("/meshcore/contacts/import")
async def meshcore_import_contacts(request: Request, body: ImportContactsRequest):
"""Write exported roster records onto the companion.
Intended for migrating a roster to a replacement companion rather than
waiting to rediscover every node by advert. Additive and idempotent: each
record is an upsert, nothing is removed, and no mesh traffic is generated.
Per-record failures are collected rather than aborting the batch, so one bad
record cannot strand a partial import with no report of what landed.
"""
connector = getattr(request.app.state, "connector", None)
mc = _find_child(connector, "meshcore")
if mc is None or not getattr(mc, "connected", False):
raise HTTPException(status_code=409, detail="MeshCore not connected")
records = body.contacts or []
if not records:
raise HTTPException(status_code=400, detail="No contacts supplied")
imported = 0
errors: list[dict] = []
for record in records:
try:
mc.import_contact(record)
imported += 1
except (ValueError, RuntimeError) as exc:
errors.append({"pubkey": record.get("pubkey"), "detail": str(exc)})
except Exception as exc:
logger.error("dashboard: meshcore import_contact error: %s", exc)
errors.append({"pubkey": record.get("pubkey"), "detail": str(exc)})
logger.info(
"dashboard: meshcore roster import — %d/%d written, %d failed",
imported, len(records), len(errors),
)
return {"active": True, "imported": imported, "failed": len(errors), "errors": errors}
@router.delete("/meshcore/contacts/{pubkey}")
async def meshcore_remove_contact(request: Request, pubkey: str):
"""Remove a contact from the companion by full pubkey.
Requires the FULL 64-hex key a prefix could match the wrong node, and a
wrongly-deleted contact is unrecoverable without rediscovery.
"""
connector = getattr(request.app.state, "connector", None)
mc = _find_child(connector, "meshcore")
if mc is None or not getattr(mc, "connected", False):
raise HTTPException(status_code=409, detail="MeshCore not connected")
try:
mc.remove_contact(pubkey)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc))
except Exception as exc:
logger.error("dashboard: meshcore remove_contact error: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
try:
contacts = list(mc.get_contacts())
except Exception:
contacts = []
logger.info("dashboard: meshcore contact %s removed", pubkey)
return {"active": True, "contacts": contacts}
@router.get("/meshcore/route-health")
async def meshcore_route_health(request: Request):
"""Flag region-routing cells whose MeshCore target no longer exists.
Preventive: a cell pointing at a room pubkey or channel the companion does
not have will fail silently at send time the alert is simply never
delivered, with nothing surfaced to the operator. Resolving every cell
up-front turns that silence into a visible warning. Read-only; sends nothing.
Also reports same-name/different-pubkey roster collisions, which are what
make a name-based picker ambiguous in the first place.
Returns {active, dangling, dangling_enabled, collisions, checked, mc_enabled}.
``active: false`` (with empty results) when MeshCore is not connected an
unreachable companion is not evidence that a route is broken.
"""
connector = getattr(request.app.state, "connector", None)
mc = _find_child(connector, "meshcore")
if mc is None or not getattr(mc, "connected", False):
return {
"active": False,
"dangling": [],
"dangling_enabled": 0,
"collisions": [],
"checked": 0,
"mc_enabled": False,
}
config = getattr(request.app.state, "config", None)
rr = getattr(getattr(config, "notifications", None), "region_routes", None)
cells = getattr(rr, "cells", None) or {}
mc_enabled = bool(getattr(rr, "mc_enabled", False))
try:
contacts = list(mc.get_contacts())
except Exception:
contacts = []
try:
channels = list(mc.known_channels())
except Exception:
channels = []
dangling = check_route_health(cells, channels, contacts)
collisions = find_name_collisions(contacts)
checked = sum(
1
for regions in cells.values()
if isinstance(regions, dict)
for cell in regions.values()
if isinstance(cell, dict) and cell.get("mc")
)
return {
"active": True,
"dangling": dangling,
"dangling_enabled": sum(1 for d in dangling if d.get("enabled")),
"collisions": collisions,
"checked": checked,
"mc_enabled": mc_enabled,
}
@router.get("/meshcore/self") @router.get("/meshcore/self")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-BuD2nQeg.js"></script> <script type="module" crossorigin src="/assets/index-dwX3LKqm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oCy31pjB.css"> <link rel="stylesheet" crossorigin href="/assets/index-CTVGSJxQ.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -0,0 +1,234 @@
"""Pure roster helpers for the MeshCore companion: reconcile + route health.
Deliberately free of device I/O and of the ``meshcore`` lib itself, so the two
pieces of logic worth getting right what a roster IS after a resync, and
whether the routing matrix still points at destinations that exist can be
unit-tested without a radio.
Two independent concerns live here:
``reconcile_contacts``
Replace-semantics for a FULL contact refetch. The lib only ever merges
(see below), so this is what makes an operator-triggered resync able to
*drop* entries rather than only ever grow the roster.
``check_route_health`` / ``find_name_collisions``
Read-only checks over the companion roster + the region-routing matrix.
Preventive: they answer "would this cell resolve if it fired right now?"
without sending anything.
"""
from typing import Any, Optional
# MeshCore contact.type as reported by the firmware CONTACT_TYPENAMES table
# [NONE, CLI, REP, ROOM, SENS]. Mirrors MeshCoreTransport.ROOM_CONTACT_TYPE.
ROOM_CONTACT_TYPE = 3
def reconcile_contacts(
cached: dict[str, dict], fresh: dict[str, dict]
) -> tuple[dict[str, dict], dict[str, Any]]:
"""Reconcile a cached roster against a FULL refetch, with replace semantics.
The meshcore lib's contact handler (``meshcore/meshcore.py::_update_contacts``)
only ever ``.update()``s existing entries or adds new ones it has no
removal path. So merging a fetch into the cache can never shrink it: an
entry removed on the companion would persist in the cache until reconnect.
Reconciling against an authoritative full fetch restores replace semantics:
anything absent from *fresh* is dropped.
Per-contact FIELDS are merged (fresh wins) rather than replaced wholesale,
which keeps the lib's field-merge behavior for contacts that still exist —
a fresh record missing an optional field must not blank the cached one.
IMPORTANT the caller must only pass a *fresh* that came from a SUCCESSFUL
full fetch (``get_contacts(lastmod=0)`` returning a CONTACTS event). This
function trusts *fresh* as authoritative: an empty *fresh* legitimately
means "the companion has no contacts" and will empty the roster. Passing a
partial/failed fetch here would silently delete real contacts.
Args:
cached: pubkey -> contact dict (the lib's current mirror).
fresh: pubkey -> contact dict (authoritative FULL fetch).
Returns:
(reconciled, stats). ``stats`` carries before/after/added/removed/updated
counts plus ``added_keys``/``removed_keys`` (sorted pubkey lists) so the
caller can report exactly what a resync changed.
"""
reconciled: dict[str, dict] = {}
added: list[str] = []
updated: list[str] = []
for pubkey, contact in fresh.items():
previous = cached.get(pubkey)
if previous is None:
reconciled[pubkey] = dict(contact)
added.append(pubkey)
continue
merged = {**previous, **contact}
reconciled[pubkey] = merged
if merged != previous:
updated.append(pubkey)
removed = [pubkey for pubkey in cached if pubkey not in fresh]
stats: dict[str, Any] = {
"before": len(cached),
"after": len(reconciled),
"added": len(added),
"removed": len(removed),
"updated": len(updated),
"added_keys": sorted(added),
"removed_keys": sorted(removed),
}
return reconciled, stats
def _find_contact_by_key_prefix(
contacts: list[dict], prefix: str
) -> Optional[dict]:
"""Resolve *prefix* to a contact the way the send path does.
Mirrors ``meshcore.MeshCore.get_contact_by_key_prefix``: case-insensitive
``startswith`` on the contact's pubkey. Room routing cells are resolved
through that same call (``MeshCoreTransport._resolve_contact``), so the
health check MUST use identical matching or it would report a cell as
dangling that the dispatcher can in fact resolve (a 12-hex prefix from the
room picker is a legitimate cell value, not just a full key).
"""
if not prefix:
return None
needle = prefix.lower()
for contact in contacts:
pubkey = (contact.get("pubkey") or "").lower()
if pubkey.startswith(needle):
return contact
return None
def check_route_health(
cells: dict,
channel_names: list[str],
contacts: list[dict],
) -> list[dict]:
"""Flag region-routing cells whose MeshCore target does not exist.
Preventive check it resolves each cell's ``mc`` target against the live
companion roster/channel table and reports the ones that would not resolve.
Nothing is sent.
A cell's ``mc`` value is either ``room:<pubkey>`` (addressed room send) or a
bare channel NAME; the parse is delegated to the single canonical
implementation in ``meshai.notifications.channels`` so this can never drift
from what the dispatcher actually does.
Cells with no ``mc`` target are skipped (nothing to resolve). Disabled cells
ARE still checked and returned with ``enabled: False`` a broken cell is
worth surfacing before someone re-enables it so callers should weight the
enabled ones when deciding how loudly to complain.
Args:
cells: ``region_routes.cells`` family -> region -> cell dict.
channel_names: channel NAMES on the companion (``known_channels()``).
contacts: roster dicts with at least ``pubkey``/``name``/``type``.
Returns:
A list of offending cells, each
``{family, region, target, kind, reason, enabled}``. Empty list = healthy.
"""
# Lazy import: keeps this module dependency-free at import time and avoids
# pulling the notifications stack (httpx/smtplib) into transport callers.
from meshai.notifications.channels import parse_meshcore_room # noqa: PLC0415
known = {name for name in channel_names}
problems: list[dict] = []
for family, regions in (cells or {}).items():
if not isinstance(regions, dict):
continue
for region, cell in regions.items():
if not isinstance(cell, dict):
continue
target = cell.get("mc")
if not target or not isinstance(target, str):
continue
enabled = bool(cell.get("enabled", True))
room_pubkey = parse_meshcore_room(target)
if room_pubkey is not None:
contact = _find_contact_by_key_prefix(contacts, room_pubkey)
if contact is None:
problems.append({
"family": family,
"region": region,
"target": target,
"kind": "room",
"reason": "room_not_found",
"enabled": enabled,
})
elif contact.get("type") != ROOM_CONTACT_TYPE:
# Resolves to a real contact that is not a room server —
# an addressed send would go to the wrong kind of node.
problems.append({
"family": family,
"region": region,
"target": target,
"kind": "room",
"reason": "not_a_room",
"enabled": enabled,
})
continue
if target not in known:
problems.append({
"family": family,
"region": region,
"target": target,
"kind": "channel",
"reason": "channel_not_found",
"enabled": enabled,
})
return problems
def find_name_collisions(contacts: list[dict]) -> list[dict]:
"""Group roster entries that share a name but have different pubkeys.
A name is not a stable identifier on MeshCore two operators can advertise
the same ``adv_name`` from different keypairs. Anything that picks a target
by name alone (a room picker, an operator reading a table) cannot tell them
apart, so surfacing the collision is what makes the ambiguity visible.
Only same-name/different-pubkey groups are returned; duplicates of the same
pubkey are not a collision.
Returns:
``[{name, count, contacts: [{pubkey, type}, ...]}]``, sorted by name.
Empty list = no collisions.
"""
by_name: dict[str, dict[str, dict]] = {}
for contact in contacts:
name = contact.get("name")
if not name:
continue
pubkey = contact.get("pubkey") or ""
if not pubkey:
continue
by_name.setdefault(name, {})[pubkey] = contact
collisions: list[dict] = []
for name, keyed in by_name.items():
if len(keyed) < 2:
continue
collisions.append({
"name": name,
"count": len(keyed),
"contacts": [
{"pubkey": pubkey, "type": c.get("type")}
for pubkey, c in sorted(keyed.items())
],
})
return sorted(collisions, key=lambda c: c["name"])

View file

@ -21,6 +21,7 @@ from typing import Callable, Optional
from .base import MeshTransport from .base import MeshTransport
from .send_queue import RadioSendQueue from .send_queue import RadioSendQueue
from ..connector import MeshMessage from ..connector import MeshMessage
from ..meshcore_roster import reconcile_contacts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -123,6 +124,10 @@ class MeshCoreTransport(MeshTransport):
self._chan_details: list[dict] = [] self._chan_details: list[dict] = []
# Self-advertisement tracking. # Self-advertisement tracking.
self._last_advert_sent: Optional[float] = None # epoch seconds or None self._last_advert_sent: Optional[float] = None # epoch seconds or None
# When the contact roster was last synced from the companion (epoch
# seconds): set at connect and on every refresh_contacts(). Lets the
# dashboard show roster freshness rather than implying "live".
self._contacts_synced_at: Optional[float] = None
# asyncio.Task handle for the periodic advert loop; None when inactive. # asyncio.Task handle for the periodic advert loop; None when inactive.
self._advert_task = None self._advert_task = None
# asyncio.Task handle for the telemetry auto-poll loop; None when inactive. # asyncio.Task handle for the telemetry auto-poll loop; None when inactive.
@ -146,6 +151,48 @@ class MeshCoreTransport(MeshTransport):
# Internal helpers # Internal helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _connection_descriptor(self) -> dict:
"""Describe the CONFIGURED connection: how we attach to the companion.
Single source of truth for both the connect() log line and the
``self_info()`` API payload, so the two can never disagree about which
device meshai is attached to. Exactly one transport's fields are
populated per ``conn_type``; the rest are None. ``target`` is the
human-readable form ("serial:/dev/x@115200", "host:port", "ble:addr").
Reads config only it describes the configured attachment, not a live
handle, so it is safe to call before connect() and while disconnected.
"""
conn_type = (getattr(self.config, "meshcore_conn_type", "tcp") or "tcp").strip().lower()
base = {
"conn_type": conn_type,
"host": None,
"port": None,
"serial_port": None,
"baud": None,
"ble_address": None,
}
if conn_type == "serial":
serial_port = getattr(self.config, "meshcore_serial_port", "") or ""
baud = getattr(self.config, "meshcore_baud", 115200)
base.update({
"serial_port": serial_port or None,
"baud": baud,
"target": f"serial:{serial_port}@{baud}",
})
return base
if conn_type == "ble":
ble_address = getattr(self.config, "meshcore_ble_address", "") or ""
base.update({
"ble_address": ble_address or None,
"target": f"ble:{ble_address or 'auto'}",
})
return base
host = getattr(self.config, "meshcore_host", "") or ""
port = getattr(self.config, "meshcore_port", 5050)
base.update({"host": host or None, "port": port, "target": f"{host}:{port}"})
return base
def _run_coro(self, coro, timeout: float = _COMMAND_TIMEOUT): def _run_coro(self, coro, timeout: float = _COMMAND_TIMEOUT):
"""Submit *coro* to the dedicated event loop and block until done. """Submit *coro* to the dedicated event loop and block until done.
@ -991,6 +1038,238 @@ class MeshCoreTransport(MeshTransport):
}) })
return roster return roster
def contacts_synced_at(self) -> Optional[float]:
"""Epoch seconds when the roster was last synced from the companion."""
return self._contacts_synced_at
async def _refresh_contacts_async(self) -> dict:
"""Force a FULL contact refetch and reconcile the lib's cache. Runs on the MC loop.
``ensure_contacts()`` is a no-op once the cache is populated, and even
when it does fetch it passes ``lastmod`` (incremental). Neither path can
ever shrink the cache, because the lib's CONTACTS handler only merges.
This forces ``get_contacts(lastmod=0)`` the authoritative FULL set
and then REPLACES the cache with it, so entries the companion no longer
has are dropped rather than surviving forever.
Only reconciles on a successful CONTACTS event: an ERROR/timeout leaves
the cache untouched, because treating a failed fetch as authoritative
would delete the entire roster.
Returns the reconcile stats dict (before/after/added/removed/updated).
"""
from meshcore import EventType # noqa: PLC0415 (lazy import intentional)
mc = self._mc
if mc is None:
raise RuntimeError("MeshCore not connected")
result = await mc.commands.get_contacts(lastmod=0)
if result is None:
raise RuntimeError("contact refresh failed: no response from companion")
if getattr(result, "type", None) == EventType.ERROR:
reason = (getattr(result, "payload", None) or {}).get("reason", "unknown")
raise RuntimeError(f"contact refresh failed: {reason}")
payload = getattr(result, "payload", None) or {}
# Re-key by public_key to match the lib's own cache layout — the event
# payload may be keyed differently, but every value carries its key.
fresh: dict[str, dict] = {}
for contact in payload.values():
if not isinstance(contact, dict):
continue
pubkey = contact.get("public_key")
if pubkey:
fresh[pubkey] = contact
cached = dict(getattr(mc, "contacts", None) or {})
reconciled, stats = reconcile_contacts(cached, fresh)
# Replace the lib's cache in place. The lib exposes ``contacts`` as a
# read-only property over ``_contacts``, so the private attr is the only
# way to apply removals; assigning a fresh dict would also break the
# lib's own handlers that mutate it in place.
mc._contacts.clear()
mc._contacts.update(reconciled)
mc._contacts_dirty = False
self._contacts_synced_at = _time.time()
logger.info(
"MeshCore: roster resync — %d before, %d after (+%d added, -%d removed, %d updated)",
stats["before"], stats["after"], stats["added"], stats["removed"], stats["updated"],
)
return stats
def refresh_contacts(self) -> dict:
"""Force a FULL roster resync from the companion and reconcile the cache.
Blocking wrapper around ``_refresh_contacts_async``. Raises RuntimeError
when not connected or when the companion fetch fails. Returns the
reconcile stats dict.
"""
if self._mc is None or not self._connected:
raise RuntimeError("MeshCore not connected")
return self._run_coro(self._refresh_contacts_async(), timeout=30.0)
def resync(self) -> dict:
"""Re-read the companion's full device view: contacts AND channels.
meshai's picture of the device is otherwise built once at connect —
contacts by ``ensure_contacts()``, channels by ``_enumerate_channels()``
and never re-read. A channel provisioned on the radio afterwards is
invisible until the process restarts. This is the one operator action
that re-reads both.
Must be called OFF the MC loop (it bridges through ``_run_coro``); the
dashboard route thread is the intended caller.
Returns ``{"contacts": <reconcile stats>, "channels": {before, after,
added, removed}}``.
"""
if self._mc is None or not self._connected:
raise RuntimeError("MeshCore not connected")
contact_stats = self.refresh_contacts()
# Channels: re-walk the companion's slot table. _enumerate_channels()
# rebuilds BOTH _chan_name_to_idx and _chan_details (the async variant
# only does the former, which would leave the Channels view stale).
before = set(self.known_channels())
self._enumerate_channels()
after = set(self.known_channels())
channel_stats = {
"before": len(before),
"after": len(after),
"added": sorted(after - before),
"removed": sorted(before - after),
}
if channel_stats["added"] or channel_stats["removed"]:
logger.info(
"MeshCore: channel resync — +%s / -%s",
channel_stats["added"], channel_stats["removed"],
)
return {"contacts": contact_stats, "channels": channel_stats}
def remove_contact(self, pubkey: str) -> None:
"""Remove a contact from the companion by full pubkey.
Wraps the lib's ``commands.remove_contact`` (CMD 0x0f). The lib does not
touch its own cache on removal, so the entry is dropped from the mirror
here too otherwise the deleted contact would reappear in the roster
until the next full resync.
A FULL 64-hex pubkey is required: the lib validates with
``prefix_length=32``, and a prefix could otherwise resolve to the wrong
node the wrong contact silently deleted. Raises ValueError on a bad
key, RuntimeError when not connected or when the companion rejects it.
"""
if self._mc is None or not self._connected:
raise RuntimeError("MeshCore not connected")
key = (pubkey or "").strip().lower()
if len(key) != 64:
raise ValueError("A full 64-character hex pubkey is required to remove a contact")
try:
bytes.fromhex(key)
except ValueError:
raise ValueError(f"Invalid pubkey hex: {pubkey!r}")
from meshcore import EventType # noqa: PLC0415 (lazy import intentional)
result = self._run_coro(self._mc.commands.remove_contact(key), timeout=15.0)
if result is None:
raise RuntimeError("remove_contact failed: no response from companion")
if getattr(result, "type", None) == EventType.ERROR:
reason = (getattr(result, "payload", None) or {}).get("reason", "unknown")
raise RuntimeError(f"remove_contact failed: {reason}")
try:
self._mc._contacts.pop(key, None)
except Exception:
logger.debug("MeshCore: could not drop %s from cache mirror", key, exc_info=True)
logger.info("MeshCore: removed contact %s from companion", key)
def export_roster(self) -> list[dict]:
"""Roster as importable records: the raw lib fields, not the UI view.
``get_contacts()`` returns a display projection; a record has to carry
every field ``commands.update_contact`` writes back (type, flags, the
out_path triplet, adv_name, last_advert, adv_lat/adv_lon) or it cannot
be imported onto another companion. Returns [] when not connected.
"""
if self._mc is None or not self._connected:
return []
contacts = getattr(self._mc, "contacts", None) or {}
records: list[dict] = []
for pubkey_hex, contact in contacts.items():
if not isinstance(contact, dict):
continue
pubkey = contact.get("public_key") or pubkey_hex
try:
out_path_len = int(contact.get("out_path_len", -1))
except (TypeError, ValueError):
out_path_len = -1
records.append({
"name": contact.get("adv_name"),
"pubkey": pubkey,
"type": contact.get("type"),
"flags": contact.get("flags"),
"last_advert": contact.get("last_advert"),
"adv_lat": contact.get("adv_lat"),
"adv_lon": contact.get("adv_lon"),
"out_path": contact.get("out_path"),
"out_path_len": out_path_len,
"out_path_hash_mode": contact.get("out_path_hash_mode"),
"path_established": out_path_len >= 0,
})
return sorted(records, key=lambda r: (r["name"] or "").lower())
def import_contact(self, record: dict) -> None:
"""Write one exported roster record onto the companion.
Uses ``commands.add_contact`` (which delegates to ``update_contact``,
CMD 0x09) to rebuild a contact on a replacement companion without
waiting to rediscover it by advert. This is a LOCAL device write it
does not transmit to the mesh.
Additive/idempotent by nature: CMD 0x09 upserts, so re-importing an
existing contact overwrites that record rather than duplicating it.
Raises ValueError on a malformed record, RuntimeError on rejection.
"""
if self._mc is None or not self._connected:
raise RuntimeError("MeshCore not connected")
pubkey = (record.get("pubkey") or "").strip().lower()
if len(pubkey) != 64:
raise ValueError("Each contact needs a full 64-character hex pubkey")
try:
bytes.fromhex(pubkey)
except ValueError:
raise ValueError(f"Invalid pubkey hex: {pubkey!r}")
# Rebuild the lib-shaped contact dict update_contact() expects. The
# defaults keep a hand-written or older export importable: an unknown
# path simply means "flood until a path is discovered".
contact = {
"public_key": pubkey,
"type": int(record.get("type") or 0),
"flags": int(record.get("flags") or 0),
"out_path": record.get("out_path") or "",
"out_path_len": int(record.get("out_path_len", -1) if record.get("out_path_len") is not None else -1),
"out_path_hash_mode": int(record.get("out_path_hash_mode") or 0),
"adv_name": record.get("name") or "",
"last_advert": int(record.get("last_advert") or 0),
"adv_lat": float(record.get("adv_lat") or 0.0),
"adv_lon": float(record.get("adv_lon") or 0.0),
}
from meshcore import EventType # noqa: PLC0415 (lazy import intentional)
result = self._run_coro(self._mc.commands.add_contact(contact), timeout=15.0)
if result is None:
raise RuntimeError("import_contact failed: no response from companion")
if getattr(result, "type", None) == EventType.ERROR:
reason = (getattr(result, "payload", None) or {}).get("reason", "unknown")
raise RuntimeError(f"import_contact failed: {reason}")
# A MeshCore ROOM SERVER is a contact whose ``type`` is ROOM (3) in the # A MeshCore ROOM SERVER is a contact whose ``type`` is ROOM (3) in the
# firmware CONTACT_TYPENAMES table [NONE, CLI, REP, ROOM, SENS]. We route # firmware CONTACT_TYPENAMES table [NONE, CLI, REP, ROOM, SENS]. We route
# to a room via the DM primitive (send_msg to its pubkey), so a room is # to a room via the DM primitive (send_msg to its pubkey), so a room is
@ -1106,18 +1385,34 @@ class MeshCoreTransport(MeshTransport):
return await self._do_mc_dm_send_async(text, pubkey) return await self._do_mc_dm_send_async(text, pubkey)
def self_info(self) -> dict: def self_info(self) -> dict:
"""Companion self/connection status. {connected: False} if not connected.""" """Companion self/connection status. {connected: False} if not connected.
``name``/``pubkey`` identify the ACTUAL connected device; the connection
fields describe how we are attached to it. Only the fields belonging to
the live ``conn_type`` are populated the others are None. Reporting
``host``/``port`` unconditionally (as this did previously) meant a serial
connection still advertised whatever stale ``meshcore_host`` sat in the
config, i.e. the API named a device meshai was not talking to. Anyone
trusting that pointer investigates the wrong physical radio.
"""
if self._mc is None or not self._connected: if self._mc is None or not self._connected:
return {"connected": False} return {"connected": False}
info = self._self_info or {} info = self._self_info or {}
descriptor = self._connection_descriptor()
return { return {
"name": info.get("name"), "name": info.get("name"),
"pubkey": info.get("public_key"), "pubkey": info.get("public_key"),
"connected": True, "connected": True,
"host": getattr(self.config, "meshcore_host", "100.64.0.9"), "conn_type": descriptor["conn_type"],
"port": getattr(self.config, "meshcore_port", 5050), "target": descriptor["target"],
"host": descriptor["host"],
"port": descriptor["port"],
"serial_port": descriptor["serial_port"],
"baud": descriptor["baud"],
"ble_address": descriptor["ble_address"],
"channel_count": len(self.known_channels()), "channel_count": len(self.known_channels()),
"last_advert_sent": self._last_advert_sent, "last_advert_sent": self._last_advert_sent,
"contacts_synced_at": self._contacts_synced_at,
} }
def set_context_config(self, cfg) -> None: def set_context_config(self, cfg) -> None:
@ -1532,8 +1827,28 @@ class MeshCoreTransport(MeshTransport):
self._mc.subscribe(EventType.NEW_CONTACT, self._on_new_contact) self._mc.subscribe(EventType.NEW_CONTACT, self._on_new_contact)
try: try:
await self._mc.ensure_contacts() await self._mc.ensure_contacts()
self._contacts_synced_at = _time.time()
except Exception: except Exception:
logger.debug("MeshCore: ensure_contacts failed (non-fatal)", exc_info=True) logger.debug("MeshCore: ensure_contacts failed (non-fatal)", exc_info=True)
# Let the lib refresh its roster when it hears an ADVERTISEMENT /
# PATH_UPDATE. The lib defaults this OFF, which leaves the roster a
# connect-time snapshot that only grows (its CONTACTS handler merges and
# never removes) until something forces a refetch.
#
# TRADE-OFF: each advert heard costs one incremental get_contacts()
# round-trip to the companion — local serial/TCP chatter, never a mesh
# transmission. On a dense mesh (and especially with
# meshcore_auto_add_contacts, where the firmware adds every node it
# hears) that is a steady trickle of fetches. Set
# meshcore_auto_update_contacts=false to keep the lib default and rely
# on the explicit "Resync" action instead.
if getattr(self.config, "meshcore_auto_update_contacts", True):
try:
self._mc.auto_update_contacts = True
logger.info("MeshCore: auto-update-contacts ENABLED (adverts refresh the roster)")
except Exception as exc:
logger.warning("MeshCore: could not enable auto_update_contacts (non-fatal): %s", exc)
if getattr(self.config, "meshcore_auto_add_contacts", True): if getattr(self.config, "meshcore_auto_add_contacts", True):
try: try:
await self._mc.commands.set_autoadd_config(1) await self._mc.commands.set_autoadd_config(1)
@ -1571,13 +1886,9 @@ class MeshCoreTransport(MeshTransport):
auto_reconnect = getattr(self.config, "meshcore_auto_reconnect", True) auto_reconnect = getattr(self.config, "meshcore_auto_reconnect", True)
max_attempts = getattr(self.config, "meshcore_max_reconnect_attempts", 5) max_attempts = getattr(self.config, "meshcore_max_reconnect_attempts", 5)
# Build a human-readable target string for logging. # Human-readable target for logging — from the same descriptor that
if conn_type == "serial": # self_info() reports, so the log and the API never disagree.
target = f"serial:{serial_port}@{baud}" target = self._connection_descriptor()["target"]
elif conn_type == "ble":
target = f"ble:{ble_address or 'auto'}"
else:
target = f"{host}:{port}"
logger.info("MeshCoreTransport: connecting to %s", target) logger.info("MeshCoreTransport: connecting to %s", target)

View file

@ -6,6 +6,7 @@ Uses a bare FastAPI() + TestClient with a hand-seeded ``app.state.connector``
""" """
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -198,12 +199,17 @@ _SAMPLE_ROSTER = [
def test_meshcore_contacts_active(): def test_meshcore_contacts_active():
mc = _child("meshcore", connected=True) mc = _child("meshcore", connected=True)
mc.get_contacts.return_value = list(_SAMPLE_ROSTER) mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
mc.contacts_synced_at.return_value = 1700000000.0
connector = _composite([mc]) connector = _composite([mc])
client = _client(connector) client = _client(connector)
r = client.get("/api/meshcore/contacts") r = client.get("/api/meshcore/contacts")
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"active": True, "contacts": _SAMPLE_ROSTER} assert r.json() == {
"active": True,
"contacts": _SAMPLE_ROSTER,
"last_synced_at": 1700000000.0,
}
def test_meshcore_contacts_no_meshcore(): def test_meshcore_contacts_no_meshcore():
@ -213,7 +219,7 @@ def test_meshcore_contacts_no_meshcore():
r = client.get("/api/meshcore/contacts") r = client.get("/api/meshcore/contacts")
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"active": False, "contacts": []} assert r.json() == {"active": False, "contacts": [], "last_synced_at": None}
def test_meshcore_contacts_disconnected(): def test_meshcore_contacts_disconnected():
@ -223,7 +229,250 @@ def test_meshcore_contacts_disconnected():
r = client.get("/api/meshcore/contacts") r = client.get("/api/meshcore/contacts")
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"active": False, "contacts": []} assert r.json() == {"active": False, "contacts": [], "last_synced_at": None}
# ============================================================================
# POST /api/meshcore/contacts/refresh — full resync + reconcile
# ============================================================================
_REFRESH_STATS = {
"before": 3, "after": 3, "added": 1, "removed": 1, "updated": 0,
"added_keys": ["cc" * 32], "removed_keys": ["bb" * 32],
}
_CHANNEL_STATS = {"before": 4, "after": 5, "added": ["#new-chan"], "removed": []}
def test_meshcore_refresh_returns_contact_and_channel_stats():
"""The resync re-reads BOTH halves of the device view, and reports each."""
mc = _child("meshcore", connected=True, known=["#aida", "#new-chan"])
mc.resync.return_value = {"contacts": dict(_REFRESH_STATS), "channels": dict(_CHANNEL_STATS)}
mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
mc.contacts_synced_at.return_value = 1700000000.0
client = _client(_composite([mc]))
r = client.post("/api/meshcore/contacts/refresh")
assert r.status_code == 200
body = r.json()
assert body["stats"] == _REFRESH_STATS
assert body["channel_stats"] == _CHANNEL_STATS
assert body["contacts"] == _SAMPLE_ROSTER
assert body["channels"] == ["#aida", "#new-chan"]
assert body["last_synced_at"] == 1700000000.0
mc.resync.assert_called_once()
def test_meshcore_refresh_conflict_when_disconnected():
mc = _child("meshcore", connected=False)
client = _client(_composite([mc]))
r = client.post("/api/meshcore/contacts/refresh")
assert r.status_code == 409
mc.resync.assert_not_called()
def test_meshcore_refresh_surfaces_companion_failure():
"""A failed fetch must surface, not be reported as a successful resync."""
mc = _child("meshcore", connected=True)
mc.resync.side_effect = RuntimeError("contact refresh failed: timeout")
client = _client(_composite([mc]))
r = client.post("/api/meshcore/contacts/refresh")
assert r.status_code == 502
assert "timeout" in r.json()["detail"]
# ============================================================================
# DELETE /api/meshcore/contacts/{pubkey}
# ============================================================================
def test_meshcore_delete_contact_removes_and_returns_roster():
mc = _child("meshcore", connected=True)
mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
client = _client(_composite([mc]))
r = client.delete(f"/api/meshcore/contacts/{'aa' * 32}")
assert r.status_code == 200
assert r.json() == {"active": True, "contacts": _SAMPLE_ROSTER}
mc.remove_contact.assert_called_once_with("aa" * 32)
def test_meshcore_delete_contact_rejects_bad_key():
mc = _child("meshcore", connected=True)
mc.remove_contact.side_effect = ValueError("A full 64-character hex pubkey is required")
client = _client(_composite([mc]))
r = client.delete("/api/meshcore/contacts/aa11")
assert r.status_code == 400
def test_meshcore_delete_contact_conflict_when_disconnected():
mc = _child("meshcore", connected=False)
client = _client(_composite([mc]))
r = client.delete(f"/api/meshcore/contacts/{'aa' * 32}")
assert r.status_code == 409
mc.remove_contact.assert_not_called()
# ============================================================================
# GET /api/meshcore/contacts/export
# ============================================================================
def test_meshcore_export_returns_envelope_and_attachment():
mc = _child("meshcore", connected=True)
mc.export_roster.return_value = [{"name": "N", "pubkey": "aa" * 32, "type": 1}]
mc.self_info.return_value = {
"name": "AIDA", "pubkey": "a6" * 32,
"conn_type": "serial", "target": "serial:/dev/meshcore-rak@115200",
}
mc.contacts_synced_at.return_value = 1700000000.0
client = _client(_composite([mc]))
r = client.get("/api/meshcore/contacts/export")
assert r.status_code == 200
assert "attachment" in r.headers["content-disposition"]
body = r.json()
assert body["format"] == "meshai.meshcore.roster"
assert body["count"] == 1
# The roster is only meaningful paired with the device it came from.
assert body["device"]["conn_type"] == "serial"
assert body["device"]["target"] == "serial:/dev/meshcore-rak@115200"
def test_meshcore_export_conflict_when_disconnected():
mc = _child("meshcore", connected=False)
client = _client(_composite([mc]))
assert client.get("/api/meshcore/contacts/export").status_code == 409
# ============================================================================
# POST /api/meshcore/contacts/import
# ============================================================================
def test_meshcore_import_writes_each_record():
mc = _child("meshcore", connected=True)
client = _client(_composite([mc]))
r = client.post("/api/meshcore/contacts/import", json={
"contacts": [{"pubkey": "aa" * 32}, {"pubkey": "bb" * 32}],
})
assert r.status_code == 200
assert r.json() == {"active": True, "imported": 2, "failed": 0, "errors": []}
assert mc.import_contact.call_count == 2
def test_meshcore_import_collects_per_record_errors():
"""One bad record must not strand the batch with no report of what landed."""
mc = _child("meshcore", connected=True)
mc.import_contact.side_effect = [None, ValueError("bad pubkey")]
client = _client(_composite([mc]))
r = client.post("/api/meshcore/contacts/import", json={
"contacts": [{"pubkey": "aa" * 32}, {"pubkey": "nope"}],
})
body = r.json()
assert body["imported"] == 1
assert body["failed"] == 1
assert body["errors"][0]["pubkey"] == "nope"
def test_meshcore_import_rejects_empty_payload():
mc = _child("meshcore", connected=True)
client = _client(_composite([mc]))
assert client.post("/api/meshcore/contacts/import", json={"contacts": []}).status_code == 400
# ============================================================================
# GET /api/meshcore/route-health
# ============================================================================
def _config_with_cells(cells, mc_enabled=True):
return SimpleNamespace(
notifications=SimpleNamespace(
region_routes=SimpleNamespace(mt_enabled=True, mc_enabled=mc_enabled, cells=cells)
)
)
def _health_client(connector, config):
app = FastAPI()
app.include_router(router, prefix="/api")
app.state.connector = connector
app.state.config = config
return TestClient(app)
def test_route_health_flags_dangling_room_cell():
mc = _child("meshcore", connected=True, known=["#aida"])
mc.get_contacts.return_value = []
config = _config_with_cells({"fire": {"SC Idaho": {"mc": f"room:{'de' * 32}", "enabled": True}}})
client = _health_client(_composite([mc]), config)
body = client.get("/api/meshcore/route-health").json()
assert body["active"] is True
assert len(body["dangling"]) == 1
assert body["dangling"][0]["reason"] == "room_not_found"
assert body["dangling_enabled"] == 1
def test_route_health_clean_when_targets_resolve():
mc = _child("meshcore", connected=True, known=["#aida"])
mc.get_contacts.return_value = [
{"pubkey": "aa" * 32, "name": "Room", "type": 3},
]
config = _config_with_cells({
"weather": {
"SW Idaho": {"mc": "#aida", "enabled": True},
"SC Idaho": {"mc": f"room:{'aa' * 32}", "enabled": True},
}
})
client = _health_client(_composite([mc]), config)
body = client.get("/api/meshcore/route-health").json()
assert body["dangling"] == []
assert body["checked"] == 2
def test_route_health_reports_name_collisions():
mc = _child("meshcore", connected=True, known=[])
mc.get_contacts.return_value = [
{"pubkey": "aa" * 32, "name": "SC ID AIDA Alerts", "type": 3},
{"pubkey": "bb" * 32, "name": "SC ID AIDA Alerts", "type": 3},
]
client = _health_client(_composite([mc]), _config_with_cells({}))
body = client.get("/api/meshcore/route-health").json()
assert len(body["collisions"]) == 1
assert body["collisions"][0]["count"] == 2
def test_route_health_inactive_when_disconnected():
"""A disconnected companion is not evidence that a route is broken."""
mc = _child("meshcore", connected=False)
config = _config_with_cells({"fire": {"SC Idaho": {"mc": "room:dead", "enabled": True}}})
client = _health_client(_composite([mc]), config)
body = client.get("/api/meshcore/route-health").json()
assert body["active"] is False
assert body["dangling"] == []
# ============================================================================ # ============================================================================

View file

@ -0,0 +1,714 @@
"""Tests for MeshCore roster management: reconcile, route health, name collisions.
Covers:
- reconcile_contacts(): full-refetch replace semantics (the crux a merge
can never remove, so this is what lets a resync drop stale entries)
- check_route_health(): region-routing cells pointing at absent rooms/channels
- find_name_collisions(): same name, different pubkey
- MeshCoreTransport.self_info(): reports the ACTUAL connection, never a
config value belonging to a different conn_type
- MeshCoreTransport._refresh_contacts_async / remove_contact / import_contact
The companion is mocked throughout: no device I/O, no mesh traffic, nothing
removed from a real radio.
"""
import asyncio
import sys
import types
import pytest
from meshai.meshcore_roster import (
check_route_health,
find_name_collisions,
reconcile_contacts,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _contact(pubkey: str, name: str = "node", type_: int = 1, **extra) -> dict:
"""A lib-shaped contact dict (public_key is the lib's key field)."""
base = {
"public_key": pubkey,
"adv_name": name,
"type": type_,
"last_advert": 1000,
"out_path_len": -1,
"out_path": "",
"out_path_hash_mode": 0,
"flags": 0,
"adv_lat": 0.0,
"adv_lon": 0.0,
}
base.update(extra)
return base
def _roster(pubkey: str, name: str = "node", type_: int = 1) -> dict:
"""A roster-projection contact dict (as get_contacts() returns)."""
return {"pubkey": pubkey, "name": name, "type": type_, "last_advert": 1000}
KEY_A = "aa" * 32
KEY_B = "bb" * 32
KEY_C = "cc" * 32
# ---------------------------------------------------------------------------
# Part 1: reconcile_contacts — replace semantics
# ---------------------------------------------------------------------------
class TestReconcileContacts:
def test_absent_contact_is_dropped(self):
"""The crux: a contact missing from the FULL refetch is removed.
The lib's own fetch handler only merges, so without this a deleted
contact survives in the cache forever.
"""
cached = {KEY_A: _contact(KEY_A, "alpha"), KEY_B: _contact(KEY_B, "bravo")}
fresh = {KEY_A: _contact(KEY_A, "alpha")}
reconciled, stats = reconcile_contacts(cached, fresh)
assert KEY_B not in reconciled
assert set(reconciled) == {KEY_A}
assert stats["removed"] == 1
assert stats["removed_keys"] == [KEY_B]
assert stats["before"] == 2
assert stats["after"] == 1
def test_new_contact_is_added(self):
cached = {KEY_A: _contact(KEY_A, "alpha")}
fresh = {KEY_A: _contact(KEY_A, "alpha"), KEY_B: _contact(KEY_B, "bravo")}
reconciled, stats = reconcile_contacts(cached, fresh)
assert set(reconciled) == {KEY_A, KEY_B}
assert stats["added"] == 1
assert stats["added_keys"] == [KEY_B]
assert stats["removed"] == 0
def test_changed_contact_is_updated(self):
cached = {KEY_A: _contact(KEY_A, "alpha", last_advert=1000)}
fresh = {KEY_A: _contact(KEY_A, "alpha", last_advert=2000)}
reconciled, stats = reconcile_contacts(cached, fresh)
assert reconciled[KEY_A]["last_advert"] == 2000
assert stats["updated"] == 1
assert stats["added"] == 0
assert stats["removed"] == 0
def test_unchanged_contact_not_counted_as_updated(self):
cached = {KEY_A: _contact(KEY_A, "alpha")}
fresh = {KEY_A: _contact(KEY_A, "alpha")}
_, stats = reconcile_contacts(cached, fresh)
assert stats["updated"] == 0
assert stats["added"] == 0
assert stats["removed"] == 0
assert stats["after"] == 1
def test_fields_merge_rather_than_replace(self):
"""A fresh record missing an optional field must not blank the cached one."""
cached = {KEY_A: _contact(KEY_A, "alpha", nickname="local-only")}
fresh = {KEY_A: {"public_key": KEY_A, "adv_name": "alpha-renamed"}}
reconciled, _ = reconcile_contacts(cached, fresh)
assert reconciled[KEY_A]["adv_name"] == "alpha-renamed" # fresh wins
assert reconciled[KEY_A]["nickname"] == "local-only" # survives
assert reconciled[KEY_A]["type"] == 1 # survives
def test_add_remove_and_update_together(self):
cached = {
KEY_A: _contact(KEY_A, "alpha", last_advert=1000),
KEY_B: _contact(KEY_B, "bravo"),
}
fresh = {
KEY_A: _contact(KEY_A, "alpha", last_advert=2000),
KEY_C: _contact(KEY_C, "charlie"),
}
reconciled, stats = reconcile_contacts(cached, fresh)
assert set(reconciled) == {KEY_A, KEY_C}
assert (stats["added"], stats["removed"], stats["updated"]) == (1, 1, 1)
assert stats["added_keys"] == [KEY_C]
assert stats["removed_keys"] == [KEY_B]
def test_empty_fresh_empties_roster(self):
"""An authoritative full fetch of zero contacts means zero contacts.
Guarding this would mean a genuinely-wiped companion could never be
reflected; the caller is responsible for only passing a SUCCESSFUL fetch.
"""
cached = {KEY_A: _contact(KEY_A), KEY_B: _contact(KEY_B)}
reconciled, stats = reconcile_contacts(cached, {})
assert reconciled == {}
assert stats["removed"] == 2
assert stats["after"] == 0
def test_empty_cache_adds_everything(self):
fresh = {KEY_A: _contact(KEY_A), KEY_B: _contact(KEY_B)}
reconciled, stats = reconcile_contacts({}, fresh)
assert set(reconciled) == {KEY_A, KEY_B}
assert stats["added"] == 2
assert stats["before"] == 0
def test_does_not_mutate_inputs(self):
cached = {KEY_A: _contact(KEY_A, "alpha", last_advert=1000)}
fresh = {KEY_B: _contact(KEY_B, "bravo")}
cached_snapshot = {KEY_A: dict(cached[KEY_A])}
reconcile_contacts(cached, fresh)
assert cached == cached_snapshot # caller's cache untouched
assert set(fresh) == {KEY_B}
def test_merge_semantics_alone_can_never_remove(self):
"""Contrast: the lib's merge keeps a stale entry that reconcile drops.
Documents exactly what the reconcile adds over the lib's behavior.
"""
cached = {KEY_A: _contact(KEY_A), KEY_B: _contact(KEY_B)}
fresh = {KEY_A: _contact(KEY_A)}
merged = dict(cached)
merged.update(fresh) # what the lib does
reconciled, _ = reconcile_contacts(cached, fresh) # what we do
assert KEY_B in merged # stale entry survives a merge
assert KEY_B not in reconciled # ...and is dropped by reconcile
# ---------------------------------------------------------------------------
# Part 2: check_route_health — dangling routing cells
# ---------------------------------------------------------------------------
class TestCheckRouteHealth:
CHANNELS = ["Public", "#aida", "#sw-id-aida"]
def test_healthy_cells_report_nothing(self):
cells = {
"weather": {
"SW Idaho": {"mc": "#sw-id-aida", "enabled": True},
"SC Idaho": {"mc": f"room:{KEY_A}", "enabled": True},
}
}
contacts = [_roster(KEY_A, "SC Room", type_=3)]
assert check_route_health(cells, self.CHANNELS, contacts) == []
def test_cell_pointing_at_missing_room_is_flagged(self):
cells = {"weather": {"SC Idaho": {"mc": f"room:{KEY_B}", "enabled": True}}}
contacts = [_roster(KEY_A, "SC Room", type_=3)]
problems = check_route_health(cells, self.CHANNELS, contacts)
assert len(problems) == 1
assert problems[0]["family"] == "weather"
assert problems[0]["region"] == "SC Idaho"
assert problems[0]["kind"] == "room"
assert problems[0]["reason"] == "room_not_found"
assert problems[0]["enabled"] is True
def test_cell_pointing_at_missing_channel_is_flagged(self):
cells = {"fire": {"East Idaho": {"mc": "#e-id-aida", "enabled": True}}}
problems = check_route_health(cells, self.CHANNELS, [])
assert len(problems) == 1
assert problems[0]["kind"] == "channel"
assert problems[0]["reason"] == "channel_not_found"
assert problems[0]["target"] == "#e-id-aida"
def test_room_cell_resolving_to_non_room_is_flagged(self):
"""A room cell that resolves to a plain node would address the wrong kind."""
cells = {"weather": {"SC Idaho": {"mc": f"room:{KEY_A}", "enabled": True}}}
contacts = [_roster(KEY_A, "Just A Node", type_=1)]
problems = check_route_health(cells, self.CHANNELS, contacts)
assert len(problems) == 1
assert problems[0]["reason"] == "not_a_room"
def test_room_cell_matches_by_prefix(self):
"""The send path resolves rooms by pubkey PREFIX — so must this check.
A 12-hex prefix (what the room picker stores) is a legitimate cell
value; treating it as dangling would be a false alarm.
"""
cells = {"weather": {"SC Idaho": {"mc": f"room:{KEY_A[:12]}", "enabled": True}}}
contacts = [_roster(KEY_A, "SC Room", type_=3)]
assert check_route_health(cells, self.CHANNELS, contacts) == []
def test_room_prefix_match_is_case_insensitive(self):
cells = {"weather": {"SC Idaho": {"mc": f"room:{KEY_A[:12].upper()}", "enabled": True}}}
contacts = [_roster(KEY_A, "SC Room", type_=3)]
assert check_route_health(cells, self.CHANNELS, contacts) == []
def test_disabled_cell_still_reported_but_marked(self):
cells = {"weather": {"SC Idaho": {"mc": f"room:{KEY_B}", "enabled": False}}}
problems = check_route_health(cells, self.CHANNELS, [])
assert len(problems) == 1
assert problems[0]["enabled"] is False
def test_cell_without_mc_target_is_skipped(self):
cells = {"weather": {"SW Idaho": {"mt": 3, "mc": None, "enabled": True}}}
assert check_route_health(cells, self.CHANNELS, []) == []
def test_empty_room_pubkey_treated_as_channel_name(self):
"""``room:`` with no pubkey is not a room target (parser yields None)."""
cells = {"weather": {"SC Idaho": {"mc": "room:", "enabled": True}}}
problems = check_route_health(cells, self.CHANNELS, [])
assert len(problems) == 1
assert problems[0]["kind"] == "channel"
def test_empty_cells_and_missing_families(self):
assert check_route_health({}, self.CHANNELS, []) == []
assert check_route_health({"weather": None}, self.CHANNELS, []) == []
def test_multiple_families_and_regions(self):
cells = {
"weather": {
"SW Idaho": {"mc": "#sw-id-aida", "enabled": True}, # healthy
"SC Idaho": {"mc": f"room:{KEY_B}", "enabled": True}, # dangling
},
"fire": {
"East Idaho": {"mc": "#gone", "enabled": True}, # dangling
},
}
contacts = [_roster(KEY_A, "SC Room", type_=3)]
problems = check_route_health(cells, self.CHANNELS, contacts)
assert len(problems) == 2
assert {p["reason"] for p in problems} == {"room_not_found", "channel_not_found"}
# ---------------------------------------------------------------------------
# Part 3: find_name_collisions
# ---------------------------------------------------------------------------
class TestFindNameCollisions:
def test_same_name_different_pubkey_is_a_collision(self):
contacts = [
_roster(KEY_A, "SC ID AIDA Alerts", type_=3),
_roster(KEY_B, "SC ID AIDA Alerts", type_=3),
]
collisions = find_name_collisions(contacts)
assert len(collisions) == 1
assert collisions[0]["name"] == "SC ID AIDA Alerts"
assert collisions[0]["count"] == 2
assert {c["pubkey"] for c in collisions[0]["contacts"]} == {KEY_A, KEY_B}
def test_distinct_names_are_not_collisions(self):
contacts = [_roster(KEY_A, "SC ID AIDA"), _roster(KEY_B, "SC ID AIDA Alerts")]
assert find_name_collisions(contacts) == []
def test_same_pubkey_twice_is_not_a_collision(self):
contacts = [_roster(KEY_A, "dup"), _roster(KEY_A, "dup")]
assert find_name_collisions(contacts) == []
def test_unnamed_contacts_ignored(self):
contacts = [_roster(KEY_A, None), _roster(KEY_B, None)]
assert find_name_collisions(contacts) == []
def test_empty_roster(self):
assert find_name_collisions([]) == []
# ---------------------------------------------------------------------------
# Part 4: transport — self_info() connection reporting
# ---------------------------------------------------------------------------
@pytest.fixture
def fake_meshcore(monkeypatch):
"""Register a fake ``meshcore`` module for the lazy imports in the transport.
monkeypatch.setitem (not sys.modules.setdefault) so this wins even when the
real lib or another test module's fake — is already imported, and is
restored afterwards.
"""
mod = types.ModuleType("meshcore")
class EventType:
ERROR = "ERROR"
CONTACTS = "CONTACTS"
OK = "OK"
mod.EventType = EventType
monkeypatch.setitem(sys.modules, "meshcore", mod)
return mod
class _Event:
def __init__(self, type_, payload=None):
self.type = type_
self.payload = payload or {}
class _FakeCommands:
"""Records calls; returns whatever the test queues up."""
def __init__(self):
self.get_contacts_calls = []
self.removed = []
self.added = []
self.get_contacts_result = None
self.remove_result = _Event("OK")
self.add_result = _Event("OK")
async def get_contacts(self, lastmod=0, timeout=5):
self.get_contacts_calls.append(lastmod)
return self.get_contacts_result
async def remove_contact(self, key):
self.removed.append(key)
return self.remove_result
async def add_contact(self, contact):
self.added.append(contact)
return self.add_result
class _FakeMC:
def __init__(self, contacts=None):
self._contacts = dict(contacts or {})
self._contacts_dirty = True
self._lastmod = 500
self.commands = _FakeCommands()
self.auto_update_contacts = False
@property
def contacts(self):
return self._contacts
def _transport(**cfg_kwargs):
"""Build a transport with a fake MC attached and marked connected."""
from meshai.config import ConnectionConfig
from meshai.transport.meshcore_transport import MeshCoreTransport
transport = MeshCoreTransport(ConnectionConfig(**cfg_kwargs))
return transport
class TestSelfInfoConnectionReporting:
"""self_info() must describe the ACTUAL connection, never a stale config value."""
def test_serial_does_not_report_config_host(self):
"""The bug: a serial connection reporting a leftover meshcore_host.
meshcore_host/port are never read on the serial path, so surfacing them
names a device meshai is not talking to which is what sends an
investigation to the wrong physical radio.
"""
t = _transport(
meshcore_conn_type="serial",
meshcore_serial_port="/dev/meshcore-rak",
meshcore_baud=115200,
# A stale TCP host left in config from a previous companion:
meshcore_host="192.168.1.253",
meshcore_port=5050,
)
t._mc = _FakeMC()
t._connected = True
t._self_info = {"name": "AIDA", "public_key": KEY_A}
info = t.self_info()
assert info["conn_type"] == "serial"
assert info["serial_port"] == "/dev/meshcore-rak"
assert info["baud"] == 115200
assert info["target"] == "serial:/dev/meshcore-rak@115200"
# The stale host must NOT be surfaced:
assert info["host"] is None
assert info["port"] is None
# Identity still comes from the real device:
assert info["name"] == "AIDA"
assert info["pubkey"] == KEY_A
def test_tcp_reports_host_and_port(self):
t = _transport(
meshcore_conn_type="tcp",
meshcore_host="100.64.0.9",
meshcore_port=5050,
)
t._mc = _FakeMC()
t._connected = True
t._self_info = {"name": "TCPNode", "public_key": KEY_B}
info = t.self_info()
assert info["conn_type"] == "tcp"
assert info["host"] == "100.64.0.9"
assert info["port"] == 5050
assert info["target"] == "100.64.0.9:5050"
assert info["serial_port"] is None
assert info["baud"] is None
def test_ble_reports_address_only(self):
t = _transport(
meshcore_conn_type="ble",
meshcore_ble_address="AA:BB:CC:DD:EE:FF",
meshcore_host="192.168.1.253",
)
t._mc = _FakeMC()
t._connected = True
t._self_info = {"name": "BleNode", "public_key": KEY_C}
info = t.self_info()
assert info["conn_type"] == "ble"
assert info["ble_address"] == "AA:BB:CC:DD:EE:FF"
assert info["target"] == "ble:AA:BB:CC:DD:EE:FF"
assert info["host"] is None
assert info["port"] is None
def test_not_connected_reports_only_connected_false(self):
t = _transport(meshcore_conn_type="serial", meshcore_serial_port="/dev/x")
assert t.self_info() == {"connected": False}
def test_descriptor_matches_connect_log_target(self):
"""connect() and self_info() must never disagree about the target."""
t = _transport(
meshcore_conn_type="serial",
meshcore_serial_port="/dev/meshcore-rak",
meshcore_baud=115200,
)
t._mc = _FakeMC()
t._connected = True
t._self_info = {}
assert t.self_info()["target"] == t._connection_descriptor()["target"]
# ---------------------------------------------------------------------------
# Part 5: transport — refresh / remove / import against a mocked companion
# ---------------------------------------------------------------------------
class TestRefreshContactsAsync:
def test_full_refetch_uses_lastmod_zero_and_reconciles(self, fake_meshcore):
"""The resync must be FULL (lastmod=0), not the lib's incremental fetch.
An incremental fetch cannot see a contact whose last_advert predates
_lastmod, and merging its result could never drop the stale KEY_B.
"""
t = _transport(meshcore_conn_type="serial", meshcore_serial_port="/dev/x")
mc = _FakeMC({KEY_A: _contact(KEY_A, "alpha"), KEY_B: _contact(KEY_B, "bravo")})
mc.commands.get_contacts_result = _Event(
"CONTACTS", {KEY_A: _contact(KEY_A, "alpha"), KEY_C: _contact(KEY_C, "charlie")}
)
t._mc = mc
t._connected = True
stats = asyncio.run(t._refresh_contacts_async())
assert mc.commands.get_contacts_calls == [0] # FULL, not _lastmod
assert set(mc._contacts) == {KEY_A, KEY_C} # cache replaced in place
assert stats["removed"] == 1 and stats["added"] == 1
assert t._contacts_synced_at is not None
def test_payload_rekeyed_by_public_key(self, fake_meshcore):
"""The event payload may be keyed by anything; the cache is by pubkey."""
t = _transport()
mc = _FakeMC()
mc.commands.get_contacts_result = _Event(
"CONTACTS", {"some-other-key": _contact(KEY_A, "alpha")}
)
t._mc = mc
t._connected = True
asyncio.run(t._refresh_contacts_async())
assert set(mc._contacts) == {KEY_A}
def test_error_event_leaves_cache_untouched(self, fake_meshcore):
"""A failed fetch must never be treated as authoritative — that would
delete the entire roster."""
t = _transport()
mc = _FakeMC({KEY_A: _contact(KEY_A), KEY_B: _contact(KEY_B)})
mc.commands.get_contacts_result = _Event("ERROR", {"reason": "timeout"})
t._mc = mc
t._connected = True
with pytest.raises(RuntimeError, match="timeout"):
asyncio.run(t._refresh_contacts_async())
assert set(mc._contacts) == {KEY_A, KEY_B} # intact
def test_no_response_raises(self, fake_meshcore):
t = _transport()
mc = _FakeMC({KEY_A: _contact(KEY_A)})
mc.commands.get_contacts_result = None
t._mc = mc
t._connected = True
with pytest.raises(RuntimeError, match="no response"):
asyncio.run(t._refresh_contacts_async())
assert set(mc._contacts) == {KEY_A}
def test_cache_object_identity_preserved(self, fake_meshcore):
"""The lib mutates _contacts in place; replacing the dict would orphan it."""
t = _transport()
mc = _FakeMC({KEY_B: _contact(KEY_B)})
original = mc._contacts
mc.commands.get_contacts_result = _Event("CONTACTS", {KEY_A: _contact(KEY_A)})
t._mc = mc
t._connected = True
asyncio.run(t._refresh_contacts_async())
assert mc._contacts is original
class TestResync:
"""resync() must re-read BOTH halves of the connect-time device view.
Channels are enumerated once at connect (_enumerate_channels) and never
re-read, so a channel provisioned on the radio afterwards stays invisible
until the process restarts the resync is the only path that picks it up.
"""
def _transport_with_loop(self):
"""A transport whose _run_coro works (real loop, fake device)."""
import threading
t = _transport(meshcore_conn_type="serial", meshcore_serial_port="/dev/x")
mc = _FakeMC({KEY_A: _contact(KEY_A, "alpha"), KEY_B: _contact(KEY_B, "bravo")})
mc.commands.get_contacts_result = _Event("CONTACTS", {KEY_A: _contact(KEY_A, "alpha")})
t._mc = mc
t._connected = True
t._loop = asyncio.new_event_loop()
threading.Thread(
target=lambda: (asyncio.set_event_loop(t._loop), t._loop.run_forever()),
daemon=True,
).start()
for _ in range(50):
if t._loop.is_running():
break
__import__("time").sleep(0.02)
return t, mc
def test_resync_reports_contact_and_channel_deltas(self, fake_meshcore, monkeypatch):
t, mc = self._transport_with_loop()
try:
t._chan_name_to_idx = {"#aida": 1, "#old": 2}
# Stand in for the companion's channel table on re-enumeration:
# #old is gone, #new appeared.
def fake_enumerate():
t._chan_name_to_idx = {"#aida": 1, "#new": 3}
monkeypatch.setattr(t, "_enumerate_channels", fake_enumerate)
result = t.resync()
assert result["contacts"]["removed"] == 1 # KEY_B dropped
assert result["channels"]["added"] == ["#new"]
assert result["channels"]["removed"] == ["#old"]
assert result["channels"]["before"] == 2
assert result["channels"]["after"] == 2
finally:
t._loop.call_soon_threadsafe(t._loop.stop)
def test_resync_raises_when_not_connected(self):
with pytest.raises(RuntimeError, match="not connected"):
_transport().resync()
class TestRemoveContact:
def test_rejects_prefix_requiring_full_key(self):
"""A prefix could match the wrong node — and a wrong delete is permanent."""
t = _transport()
t._mc = _FakeMC()
t._connected = True
with pytest.raises(ValueError, match="full 64-character"):
t.remove_contact(KEY_A[:12])
def test_rejects_non_hex(self):
t = _transport()
t._mc = _FakeMC()
t._connected = True
with pytest.raises(ValueError, match="Invalid pubkey hex"):
t.remove_contact("z" * 64)
def test_raises_when_not_connected(self):
t = _transport()
with pytest.raises(RuntimeError, match="not connected"):
t.remove_contact(KEY_A)
class TestImportContact:
def test_rejects_record_without_full_pubkey(self):
t = _transport()
t._mc = _FakeMC()
t._connected = True
with pytest.raises(ValueError, match="full 64-character"):
t.import_contact({"pubkey": "abcd", "name": "x"})
def test_raises_when_not_connected(self):
t = _transport()
with pytest.raises(RuntimeError, match="not connected"):
t.import_contact({"pubkey": KEY_A})
class TestExportRoster:
def test_export_carries_importable_fields(self):
"""An export missing the update_contact field set cannot be re-imported."""
t = _transport()
t._mc = _FakeMC({KEY_A: _contact(KEY_A, "alpha", out_path_len=2, out_path="abcd")})
t._connected = True
records = t.export_roster()
assert len(records) == 1
record = records[0]
for field in (
"name", "pubkey", "type", "flags", "last_advert",
"adv_lat", "adv_lon", "out_path", "out_path_len", "out_path_hash_mode",
):
assert field in record
assert record["pubkey"] == KEY_A
assert record["name"] == "alpha"
assert record["path_established"] is True
def test_export_marks_flood_only_contact(self):
t = _transport()
t._mc = _FakeMC({KEY_A: _contact(KEY_A, "alpha", out_path_len=-1)})
t._connected = True
assert t.export_roster()[0]["path_established"] is False
def test_export_empty_when_not_connected(self):
assert _transport().export_roster() == []