mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
bbe97398bc
commit
aa18f642aa
14 changed files with 2970 additions and 502 deletions
|
|
@ -699,15 +699,25 @@ export interface MeshcoreContact {
|
|||
export interface MeshcoreContacts {
|
||||
active: boolean
|
||||
contacts: MeshcoreContact[]
|
||||
last_synced_at?: number | null // epoch seconds the roster was pulled from the companion
|
||||
}
|
||||
export interface MeshcoreSelf {
|
||||
name?: string | null
|
||||
pubkey?: string | null
|
||||
connected: boolean
|
||||
host?: string
|
||||
port?: number
|
||||
// Connection reporting: only the fields for the live `conn_type` are set;
|
||||
// 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
|
||||
last_advert_sent?: number | null // epoch seconds; null/absent = never advertised
|
||||
contacts_synced_at?: number | null
|
||||
}
|
||||
|
||||
export async function fetchMeshcoreContacts(): Promise<MeshcoreContacts> {
|
||||
|
|
@ -717,6 +727,109 @@ export async function fetchMeshcoreSelf(): Promise<MeshcoreSelf> {
|
|||
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> {
|
||||
const response = await fetch('/api/meshcore/advert', {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,32 @@ import {
|
|||
type TestSendResult,
|
||||
} 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. */
|
||||
function relativeTime(epochSec: number): string {
|
||||
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>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[#777] mb-1">Host</dt>
|
||||
<dd className="text-slate-100 font-mono">
|
||||
{self?.host ?? '—'}
|
||||
{self?.port != null ? `:${self.port}` : ''}
|
||||
<dt className="text-[#777] mb-1">Connection</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
import { Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import { Users } from 'lucide-react'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertTriangle, Copy, Download, Plus, RefreshCw, Trash2, Users } from 'lucide-react'
|
||||
import {
|
||||
fetchMeshcoreContacts,
|
||||
fetchMeshcoreRouteHealth,
|
||||
fetchMeshcoreTelemetry,
|
||||
fetchConnectionConfig,
|
||||
importMeshcoreContacts,
|
||||
pollMeshcoreContact,
|
||||
refreshMeshcoreContacts,
|
||||
removeMeshcoreContact,
|
||||
updateConfig,
|
||||
type MeshcoreChannelStats,
|
||||
type MeshcoreContacts,
|
||||
type MeshcoreContact,
|
||||
type MeshcoreRefreshStats,
|
||||
type MeshcoreRouteHealth,
|
||||
type MeshcoreTelemetry,
|
||||
type MeshcoreTelemetryEntry,
|
||||
type MeshcoreTelemetryData,
|
||||
|
|
@ -18,10 +25,32 @@ import {
|
|||
const TELEMETRY_POLL_MS = 15000
|
||||
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 {
|
||||
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 < 60) return `${diff}s ago`
|
||||
const mins = Math.floor(diff / 60)
|
||||
|
|
@ -145,6 +174,31 @@ export default function MeshCoreContacts() {
|
|||
const [connectionConfig, setConnectionConfig] = useState<ConnectionConfig | 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.
|
||||
const [savingId, setSavingId] = useState<string | null>(null)
|
||||
const [savedId, setSavedId] = useState<string | null>(null)
|
||||
|
|
@ -169,7 +223,10 @@ export default function MeshCoreContacts() {
|
|||
setError(null)
|
||||
try {
|
||||
const result = await fetchMeshcoreContacts()
|
||||
if (!cancelled) setData(result)
|
||||
if (!cancelled) {
|
||||
setData(result)
|
||||
setLastSyncedAt(result.last_synced_at ?? null)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
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.
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
if (!connectionConfig) return
|
||||
const minutes = Math.max(MIN_INTERVAL_MINUTES, Math.round(intervalMinutes) || MIN_INTERVAL_MINUTES)
|
||||
|
|
@ -316,9 +532,11 @@ export default function MeshCoreContacts() {
|
|||
}, [connectionConfig, intervalMinutes])
|
||||
|
||||
const rosterActive = data?.active !== false
|
||||
const dangling = routeHealth?.dangling ?? []
|
||||
const collisions = routeHealth?.collisions ?? []
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
<div className="max-w-5xl mx-auto space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
|
||||
|
|
@ -333,6 +551,192 @@ export default function MeshCoreContacts() {
|
|||
</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 — 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 — 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 */}
|
||||
{rosterActive && connectionConfig && (
|
||||
<div className="bg-bg-card border border-border p-4 space-y-2">
|
||||
|
|
@ -389,13 +793,56 @@ export default function MeshCoreContacts() {
|
|||
</p>
|
||||
</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">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-[#777]">
|
||||
<th className="px-4 py-2.5 font-medium">Name</th>
|
||||
<th className="px-4 py-2.5 font-medium">Type</th>
|
||||
<th className="px-4 py-2.5 font-medium">Last heard</th>
|
||||
<th className="px-4 py-2.5 font-medium">
|
||||
<button onClick={() => toggleSort('name')} className="hover:text-slate-200 uppercase">
|
||||
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">Pubkey</th>
|
||||
<th className="px-4 py-2.5 font-medium">Auto-poll</th>
|
||||
|
|
@ -403,7 +850,7 @@ export default function MeshCoreContacts() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{(data?.contacts ?? []).map((c) => {
|
||||
{visibleContacts.map((c) => {
|
||||
const id = contactId(c)
|
||||
const entry = entryFor(c)
|
||||
const selected = isSelected(c)
|
||||
|
|
@ -437,14 +884,53 @@ export default function MeshCoreContacts() {
|
|||
return (
|
||||
<Fragment key={c.pubkey}>
|
||||
<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">
|
||||
<TypeBadge type={c.type} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-slate-300">{relativeTime(c.last_advert)}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<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-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 className="px-4 py-2.5">
|
||||
<label
|
||||
|
|
@ -472,15 +958,55 @@ export default function MeshCoreContacts() {
|
|||
</label>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<button
|
||||
onClick={() => handlePollNow(c)}
|
||||
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>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
onClick={() => handlePollNow(c)}
|
||||
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>
|
||||
{/* 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>
|
||||
</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 && (
|
||||
<tr className="bg-[#0a0e17]/40">
|
||||
<td colSpan={7} className="px-4 py-2 border-t border-border/50">
|
||||
|
|
@ -500,6 +1026,12 @@ export default function MeshCoreContacts() {
|
|||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{visibleContacts.length === 0 && (
|
||||
<div className="px-4 py-6 text-sm text-[#777]">
|
||||
No contacts match this filter.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@ class ConnectionConfig:
|
|||
meshcore_baud: int = 115200
|
||||
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)
|
||||
# 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_discovery_wait_seconds: float = 8.0 # path-discovery timeout on the no-ACK fallback (was hardcoded 25s)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
"""Dashboard 'send test message' API routes (meshtastic + meshcore)."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Union
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from meshai import secrets_store
|
||||
from meshai.meshcore_roster import check_route_health, find_name_collisions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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):
|
||||
"""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")
|
||||
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)
|
||||
mc = _find_child(connector, "meshcore")
|
||||
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())
|
||||
except Exception:
|
||||
contacts = []
|
||||
return {"active": True, "contacts": contacts}
|
||||
return {"active": False, "contacts": []}
|
||||
try:
|
||||
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")
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
1
work/meshai/dashboard/static/assets/index-CTVGSJxQ.css
Normal file
1
work/meshai/dashboard/static/assets/index-CTVGSJxQ.css
Normal file
File diff suppressed because one or more lines are too long
475
work/meshai/dashboard/static/assets/index-dwX3LKqm.js
Normal file
475
work/meshai/dashboard/static/assets/index-dwX3LKqm.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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">
|
||||
<script type="module" crossorigin src="/assets/index-BuD2nQeg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-oCy31pjB.css">
|
||||
<script type="module" crossorigin src="/assets/index-dwX3LKqm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CTVGSJxQ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
234
work/meshai/meshcore_roster.py
Normal file
234
work/meshai/meshcore_roster.py
Normal 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"])
|
||||
|
|
@ -21,6 +21,7 @@ from typing import Callable, Optional
|
|||
from .base import MeshTransport
|
||||
from .send_queue import RadioSendQueue
|
||||
from ..connector import MeshMessage
|
||||
from ..meshcore_roster import reconcile_contacts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -123,6 +124,10 @@ class MeshCoreTransport(MeshTransport):
|
|||
self._chan_details: list[dict] = []
|
||||
# Self-advertisement tracking.
|
||||
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.
|
||||
self._advert_task = None
|
||||
# asyncio.Task handle for the telemetry auto-poll loop; None when inactive.
|
||||
|
|
@ -146,6 +151,48 @@ class MeshCoreTransport(MeshTransport):
|
|||
# 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):
|
||||
"""Submit *coro* to the dedicated event loop and block until done.
|
||||
|
||||
|
|
@ -991,6 +1038,238 @@ class MeshCoreTransport(MeshTransport):
|
|||
})
|
||||
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
|
||||
# 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
|
||||
|
|
@ -1106,18 +1385,34 @@ class MeshCoreTransport(MeshTransport):
|
|||
return await self._do_mc_dm_send_async(text, pubkey)
|
||||
|
||||
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:
|
||||
return {"connected": False}
|
||||
info = self._self_info or {}
|
||||
descriptor = self._connection_descriptor()
|
||||
return {
|
||||
"name": info.get("name"),
|
||||
"pubkey": info.get("public_key"),
|
||||
"connected": True,
|
||||
"host": getattr(self.config, "meshcore_host", "100.64.0.9"),
|
||||
"port": getattr(self.config, "meshcore_port", 5050),
|
||||
"conn_type": descriptor["conn_type"],
|
||||
"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()),
|
||||
"last_advert_sent": self._last_advert_sent,
|
||||
"contacts_synced_at": self._contacts_synced_at,
|
||||
}
|
||||
|
||||
def set_context_config(self, cfg) -> None:
|
||||
|
|
@ -1532,8 +1827,28 @@ class MeshCoreTransport(MeshTransport):
|
|||
self._mc.subscribe(EventType.NEW_CONTACT, self._on_new_contact)
|
||||
try:
|
||||
await self._mc.ensure_contacts()
|
||||
self._contacts_synced_at = _time.time()
|
||||
except Exception:
|
||||
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):
|
||||
try:
|
||||
await self._mc.commands.set_autoadd_config(1)
|
||||
|
|
@ -1571,13 +1886,9 @@ class MeshCoreTransport(MeshTransport):
|
|||
auto_reconnect = getattr(self.config, "meshcore_auto_reconnect", True)
|
||||
max_attempts = getattr(self.config, "meshcore_max_reconnect_attempts", 5)
|
||||
|
||||
# Build a human-readable target string for logging.
|
||||
if conn_type == "serial":
|
||||
target = f"serial:{serial_port}@{baud}"
|
||||
elif conn_type == "ble":
|
||||
target = f"ble:{ble_address or 'auto'}"
|
||||
else:
|
||||
target = f"{host}:{port}"
|
||||
# Human-readable target for logging — from the same descriptor that
|
||||
# self_info() reports, so the log and the API never disagree.
|
||||
target = self._connection_descriptor()["target"]
|
||||
|
||||
logger.info("MeshCoreTransport: connecting to %s …", target)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ Uses a bare FastAPI() + TestClient with a hand-seeded ``app.state.connector``
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -198,12 +199,17 @@ _SAMPLE_ROSTER = [
|
|||
def test_meshcore_contacts_active():
|
||||
mc = _child("meshcore", connected=True)
|
||||
mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
|
||||
mc.contacts_synced_at.return_value = 1700000000.0
|
||||
connector = _composite([mc])
|
||||
client = _client(connector)
|
||||
|
||||
r = client.get("/api/meshcore/contacts")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"active": True, "contacts": _SAMPLE_ROSTER}
|
||||
assert r.json() == {
|
||||
"active": True,
|
||||
"contacts": _SAMPLE_ROSTER,
|
||||
"last_synced_at": 1700000000.0,
|
||||
}
|
||||
|
||||
|
||||
def test_meshcore_contacts_no_meshcore():
|
||||
|
|
@ -213,7 +219,7 @@ def test_meshcore_contacts_no_meshcore():
|
|||
|
||||
r = client.get("/api/meshcore/contacts")
|
||||
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():
|
||||
|
|
@ -223,7 +229,250 @@ def test_meshcore_contacts_disconnected():
|
|||
|
||||
r = client.get("/api/meshcore/contacts")
|
||||
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"] == []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
|
|
|||
714
work/tests/test_meshcore_roster.py
Normal file
714
work/tests/test_meshcore_roster.py
Normal 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() == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue