mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(meshcore): opt-in telemetry auto-poll on selected contacts (#92)
* feat(meshcore): opt-in telemetry auto-poll on selected contacts req_telemetry + a poller for selected contacts (meshcore_telemetry_contacts, interval with a min floor, availability detection). Contacts page gains per-node auto-poll toggles + battery/sensor readouts + Poll-now, and maps numeric contact type codes to Chat/Repeater/Room/Sensor badges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(meshcore-telemetry): reconcile with current transport/meshcore-lib API - Add EventType.ACK + NEW_CONTACT to the telemetry test's fake module; _setup_subscriptions() subscribes to both (added in main before rebase) and the stale stub caused all three TestPollerScheduler tests to abort with AttributeError on connect(). - Same NEW_CONTACT gap fixed in test_meshcore_conn_type.py and test_meshcore_dm_delivery.py — these ran first (alphabetically) via setdefault, contaminating the shared sys.modules["meshcore"] stub for all downstream test files and causing 9 extra connect()-path failures suite-wide (TestPeriodicAdvertScheduler, TestAdvertOnConnect, etc.). - req_telemetry_sync(contact, min_timeout=5) matches the installed lib (meshcore-2.3.7 binary.py) exactly — no production-code change needed. - All 30 test_meshcore_telemetry tests pass; full-suite failures drop 16 → 7 (remaining 7 are pre-existing, unrelated to telemetry). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e3b93f652c
commit
6e74b82d51
9 changed files with 1218 additions and 29 deletions
|
|
@ -598,7 +598,7 @@ export async function getMeshcoreChannels(): Promise<MeshcoreChannels> {
|
|||
export interface MeshcoreContact {
|
||||
name: string | null
|
||||
pubkey: string
|
||||
type: string | null
|
||||
type: number | null
|
||||
last_advert: number | null
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
|
|
@ -637,6 +637,51 @@ export async function sendMeshcoreAdvert(): Promise<TestSendResult> {
|
|||
return response.json()
|
||||
}
|
||||
|
||||
// --- MeshCore telemetry ---
|
||||
|
||||
export interface MeshcoreTelemetryData {
|
||||
voltage?: number; temperature?: number; humidity?: number; battery_pct?: number;
|
||||
current?: number; illuminance?: number; barometer?: number; power?: number;
|
||||
altitude?: number; distance?: number; gps?: unknown;
|
||||
raw?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface MeshcoreTelemetryEntry {
|
||||
contact: string;
|
||||
data: MeshcoreTelemetryData | null;
|
||||
polled_at: string | null;
|
||||
available: boolean;
|
||||
}
|
||||
export interface MeshcoreTelemetry { active: boolean; entries: MeshcoreTelemetryEntry[]; }
|
||||
export interface MeshcorePollResult { available: boolean; contact: string; data?: MeshcoreTelemetryData; detail?: string; }
|
||||
|
||||
// Connection config subset the telemetry UI reads/writes. The rest of the
|
||||
// connection object is preserved verbatim via the index signature so PUTs can
|
||||
// send the WHOLE object back (the backend coerces the body into the full
|
||||
// ConnectionConfig dataclass — a partial PUT would reset omitted fields).
|
||||
export interface ConnectionConfig {
|
||||
meshcore_telemetry_contacts?: string[]
|
||||
meshcore_telemetry_interval_seconds?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function fetchConnectionConfig(): Promise<ConnectionConfig> {
|
||||
return fetchJson<ConnectionConfig>('/api/config/connection')
|
||||
}
|
||||
|
||||
export async function fetchMeshcoreTelemetry(): Promise<MeshcoreTelemetry> {
|
||||
return fetchJson<MeshcoreTelemetry>('/api/meshcore/telemetry')
|
||||
}
|
||||
|
||||
export async function pollMeshcoreContact(contact: string): Promise<MeshcorePollResult> {
|
||||
const response = await fetch('/api/meshcore/telemetry/poll', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ contact }),
|
||||
})
|
||||
if (!response.ok) throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function sendTestMessage(body: {
|
||||
transport: 'meshtastic' | 'meshcore'
|
||||
channel: string | number
|
||||
|
|
|
|||
|
|
@ -1,11 +1,24 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import { Users } from 'lucide-react'
|
||||
import {
|
||||
fetchMeshcoreContacts,
|
||||
fetchMeshcoreTelemetry,
|
||||
fetchConnectionConfig,
|
||||
pollMeshcoreContact,
|
||||
updateConfig,
|
||||
type MeshcoreContacts,
|
||||
type MeshcoreContact,
|
||||
type MeshcoreTelemetry,
|
||||
type MeshcoreTelemetryEntry,
|
||||
type MeshcoreTelemetryData,
|
||||
type MeshcorePollResult,
|
||||
type ConnectionConfig,
|
||||
} from '../lib/api'
|
||||
|
||||
const TELEMETRY_POLL_MS = 15000
|
||||
const MIN_INTERVAL_MINUTES = 5
|
||||
|
||||
// Relative time for epoch-seconds fields (last_advert).
|
||||
function relativeTime(epochSeconds: number | null): string {
|
||||
if (epochSeconds == null) return '—'
|
||||
const diff = Math.floor(Date.now() / 1000) - epochSeconds
|
||||
|
|
@ -19,16 +32,33 @@ function relativeTime(epochSeconds: number | null): string {
|
|||
return `${days}d ago`
|
||||
}
|
||||
|
||||
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
|
||||
chat: { label: 'Chat', className: 'bg-sky-500/15 text-sky-400' },
|
||||
repeater: { label: 'Repeater', className: 'bg-amber-500/15 text-amber-400' },
|
||||
room: { label: 'Room', className: 'bg-violet-500/15 text-violet-400' },
|
||||
sensor: { label: 'Sensor', className: 'bg-emerald-500/15 text-emerald-400' },
|
||||
// Relative time for ISO8601 timestamps (telemetry polled_at).
|
||||
function relativeTimeIso(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
const then = Date.parse(iso)
|
||||
if (Number.isNaN(then)) return '—'
|
||||
const diff = Math.floor((Date.now() - then) / 1000)
|
||||
if (diff < 5) return 'just now'
|
||||
if (diff < 60) return `${diff}s ago`
|
||||
const mins = Math.floor(diff / 60)
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string | null }) {
|
||||
const meta = (type && TYPE_BADGES[type]) || {
|
||||
label: type ?? 'unknown',
|
||||
// MeshCore contact.type is a NUMBER: 0=NONE,1=chat,2=repeater,3=room,4=sensor.
|
||||
const TYPE_BADGES: Record<number, { label: string; className: string }> = {
|
||||
1: { label: 'Chat', className: 'bg-sky-500/15 text-sky-400' },
|
||||
2: { label: 'Repeater', className: 'bg-amber-500/15 text-amber-400' },
|
||||
3: { label: 'Room', className: 'bg-violet-500/15 text-violet-400' },
|
||||
4: { label: 'Sensor', className: 'bg-emerald-500/15 text-emerald-400' },
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: number | null }) {
|
||||
const meta = (type != null && TYPE_BADGES[type]) || {
|
||||
label: 'Unknown',
|
||||
className: 'bg-slate-600/30 text-slate-400',
|
||||
}
|
||||
return (
|
||||
|
|
@ -44,6 +74,12 @@ function contactName(c: MeshcoreContact): string {
|
|||
return 'unnamed'
|
||||
}
|
||||
|
||||
// Stable identifier the backend resolves against (pubkey == meshcore
|
||||
// public_key; adv_name is the fallback when a contact has no key).
|
||||
function contactId(c: MeshcoreContact): string {
|
||||
return c.pubkey || c.name || ''
|
||||
}
|
||||
|
||||
function shortPubkey(pubkey: string): string {
|
||||
return pubkey.length > 12 ? `${pubkey.slice(0, 12)}…` : pubkey
|
||||
}
|
||||
|
|
@ -55,15 +91,77 @@ function position(c: MeshcoreContact): string {
|
|||
return '—'
|
||||
}
|
||||
|
||||
// Generic sensor field rendering, in display order.
|
||||
const SENSOR_FIELDS: { key: string; label: string; unit: string; digits: number }[] = [
|
||||
{ key: 'battery_pct', label: 'Battery', unit: '%', digits: 0 },
|
||||
{ key: 'voltage', label: 'Voltage', unit: 'V', digits: 2 },
|
||||
{ key: 'temperature', label: 'Temp', unit: '°C', digits: 1 },
|
||||
{ key: 'humidity', label: 'Humidity', unit: '%', digits: 0 },
|
||||
{ key: 'current', label: 'Current', unit: 'A', digits: 2 },
|
||||
{ key: 'illuminance', label: 'Light', unit: 'lx', digits: 0 },
|
||||
{ key: 'barometer', label: 'Pressure', unit: 'hPa', digits: 1 },
|
||||
{ key: 'power', label: 'Power', unit: 'W', digits: 1 },
|
||||
{ key: 'altitude', label: 'Alt', unit: 'm', digits: 0 },
|
||||
{ key: 'distance', label: 'Dist', unit: 'm', digits: 0 },
|
||||
]
|
||||
|
||||
function TelemetryReadout({
|
||||
data,
|
||||
polledLabel,
|
||||
}: {
|
||||
data: MeshcoreTelemetryData
|
||||
polledLabel: string
|
||||
}) {
|
||||
const chips = SENSOR_FIELDS.flatMap((f) => {
|
||||
const v = data[f.key]
|
||||
if (typeof v !== 'number' || Number.isNaN(v)) return []
|
||||
return [
|
||||
<span
|
||||
key={f.key}
|
||||
className="px-2 py-0.5 text-xs rounded bg-[#0a0e17] border border-[#1e2a3a] text-slate-200"
|
||||
>
|
||||
<span className="text-[#777]">{f.label}</span> {v.toFixed(f.digits)}
|
||||
{f.unit}
|
||||
</span>,
|
||||
]
|
||||
})
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chips.length > 0 ? (
|
||||
chips
|
||||
) : (
|
||||
<span className="text-xs text-[#777]">Telemetry received (no standard sensor fields)</span>
|
||||
)}
|
||||
<span className="text-[11px] text-[#777] ml-1">polled {polledLabel}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MeshCoreContacts() {
|
||||
const [data, setData] = useState<MeshcoreContacts | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [connectionConfig, setConnectionConfig] = useState<ConnectionConfig | null>(null)
|
||||
const [telemetry, setTelemetry] = useState<MeshcoreTelemetry | null>(null)
|
||||
|
||||
// Per-row transient UI state.
|
||||
const [savingId, setSavingId] = useState<string | null>(null)
|
||||
const [savedId, setSavedId] = useState<string | null>(null)
|
||||
const [pollingId, setPollingId] = useState<string | null>(null)
|
||||
const [pollResults, setPollResults] = useState<Record<string, MeshcorePollResult>>({})
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
// Interval control (minutes, derived from meshcore_telemetry_interval_seconds).
|
||||
const [intervalMinutes, setIntervalMinutes] = useState<number>(30)
|
||||
const [intervalSaving, setIntervalSaving] = useState(false)
|
||||
const [intervalSaved, setIntervalSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'MeshCore Contacts - MeshAI'
|
||||
}, [])
|
||||
|
||||
// Roster (once).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
|
|
@ -85,6 +183,140 @@ export default function MeshCoreContacts() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Connection config (once) — kept whole so PUTs send it back intact.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const cfg = await fetchConnectionConfig()
|
||||
if (cancelled) return
|
||||
setConnectionConfig(cfg)
|
||||
const sec = cfg.meshcore_telemetry_interval_seconds
|
||||
if (typeof sec === 'number' && sec > 0) {
|
||||
setIntervalMinutes(Math.max(MIN_INTERVAL_MINUTES, Math.round(sec / 60)))
|
||||
}
|
||||
} catch {
|
||||
// non-fatal — auto-poll controls degrade gracefully
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Telemetry: on mount + every 15s.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
try {
|
||||
const t = await fetchMeshcoreTelemetry()
|
||||
if (!cancelled) setTelemetry(t)
|
||||
} catch {
|
||||
// non-fatal — keep last-known telemetry
|
||||
}
|
||||
}
|
||||
load()
|
||||
const id = setInterval(load, TELEMETRY_POLL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const telemetryContacts: string[] = connectionConfig?.meshcore_telemetry_contacts ?? []
|
||||
|
||||
// Match a roster contact to its telemetry entry (by pubkey or name — the
|
||||
// config list may store either identifier).
|
||||
const entryFor = useCallback(
|
||||
(c: MeshcoreContact): MeshcoreTelemetryEntry | undefined => {
|
||||
const entries = telemetry?.entries ?? []
|
||||
return entries.find((e) => e.contact === c.pubkey || (c.name != null && e.contact === c.name))
|
||||
},
|
||||
[telemetry]
|
||||
)
|
||||
|
||||
const isSelected = useCallback(
|
||||
(c: MeshcoreContact): boolean =>
|
||||
telemetryContacts.includes(c.pubkey) || (c.name != null && telemetryContacts.includes(c.name)),
|
||||
[telemetryContacts]
|
||||
)
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (c: MeshcoreContact, turnOn: boolean) => {
|
||||
if (!connectionConfig) return
|
||||
const id = contactId(c)
|
||||
if (!id) return
|
||||
setSaveError(null)
|
||||
setSavingId(id)
|
||||
// Build the new list: add pubkey when enabling; drop both pubkey and
|
||||
// name when disabling (either could be present).
|
||||
const current = connectionConfig.meshcore_telemetry_contacts ?? []
|
||||
let next: string[]
|
||||
if (turnOn) {
|
||||
next = current.includes(id) ? current : [...current, id]
|
||||
} else {
|
||||
next = current.filter((x) => x !== c.pubkey && x !== c.name)
|
||||
}
|
||||
const nextConfig: ConnectionConfig = {
|
||||
...connectionConfig,
|
||||
meshcore_telemetry_contacts: next,
|
||||
}
|
||||
try {
|
||||
await updateConfig('connection', nextConfig)
|
||||
setConnectionConfig(nextConfig)
|
||||
setSavedId(id)
|
||||
setTimeout(() => setSavedId((s) => (s === id ? null : s)), 1500)
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save')
|
||||
} finally {
|
||||
setSavingId((s) => (s === id ? null : s))
|
||||
}
|
||||
},
|
||||
[connectionConfig]
|
||||
)
|
||||
|
||||
const handlePollNow = useCallback(async (c: MeshcoreContact) => {
|
||||
const id = contactId(c)
|
||||
if (!id) return
|
||||
setPollingId(id)
|
||||
try {
|
||||
const result = await pollMeshcoreContact(id)
|
||||
setPollResults((prev) => ({ ...prev, [id]: result }))
|
||||
} catch (err) {
|
||||
setPollResults((prev) => ({
|
||||
...prev,
|
||||
[id]: { available: false, contact: id, detail: err instanceof Error ? err.message : 'Poll failed' },
|
||||
}))
|
||||
} finally {
|
||||
setPollingId((p) => (p === id ? null : p))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSaveInterval = useCallback(async () => {
|
||||
if (!connectionConfig) return
|
||||
const minutes = Math.max(MIN_INTERVAL_MINUTES, Math.round(intervalMinutes) || MIN_INTERVAL_MINUTES)
|
||||
const nextConfig: ConnectionConfig = {
|
||||
...connectionConfig,
|
||||
meshcore_telemetry_interval_seconds: minutes * 60,
|
||||
}
|
||||
setIntervalSaving(true)
|
||||
setIntervalSaved(false)
|
||||
setSaveError(null)
|
||||
try {
|
||||
await updateConfig('connection', nextConfig)
|
||||
setConnectionConfig(nextConfig)
|
||||
setIntervalMinutes(minutes)
|
||||
setIntervalSaved(true)
|
||||
setTimeout(() => setIntervalSaved(false), 2000)
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save interval')
|
||||
} finally {
|
||||
setIntervalSaving(false)
|
||||
}
|
||||
}, [connectionConfig, intervalMinutes])
|
||||
|
||||
const rosterActive = data?.active !== false
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
{/* Header */}
|
||||
|
|
@ -95,11 +327,46 @@ export default function MeshCoreContacts() {
|
|||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-100">MeshCore Contacts</h2>
|
||||
<p className="text-sm text-[#777]">
|
||||
The companion's known contact roster — names, types, and last-heard times.
|
||||
The companion's known contact roster — names, types, last-heard times, and
|
||||
telemetry auto-poll.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-poll interval control */}
|
||||
{rosterActive && connectionConfig && (
|
||||
<div className="bg-bg-card border border-border p-4 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm text-slate-200">Auto-poll every</label>
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_INTERVAL_MINUTES}
|
||||
value={intervalMinutes}
|
||||
onChange={(e) => setIntervalMinutes(Number(e.target.value))}
|
||||
className="w-20 px-2 py-1 text-sm bg-[#0a0e17] border border-[#1e2a3a] rounded text-slate-100"
|
||||
/>
|
||||
<span className="text-sm text-slate-300">minutes</span>
|
||||
<button
|
||||
onClick={handleSaveInterval}
|
||||
disabled={intervalSaving}
|
||||
className="px-3 py-1 text-sm rounded bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50"
|
||||
>
|
||||
{intervalSaving ? 'Saving…' : intervalSaved ? 'Saved' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-[#777] max-w-prose">
|
||||
Polls only the nodes you select below. Keep this list small — telemetry uses mesh
|
||||
airtime. Minimum {MIN_INTERVAL_MINUTES} minutes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-slate-400">Loading...</div>
|
||||
|
|
@ -131,30 +398,110 @@ export default function MeshCoreContacts() {
|
|||
<th className="px-4 py-2.5 font-medium">Last heard</th>
|
||||
<th className="px-4 py-2.5 font-medium">Position</th>
|
||||
<th className="px-4 py-2.5 font-medium">Pubkey</th>
|
||||
<th className="px-4 py-2.5 font-medium">Auto-poll</th>
|
||||
<th className="px-4 py-2.5 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{(data?.contacts ?? []).map((c) => (
|
||||
<tr key={c.pubkey} className="hover:bg-bg-hover">
|
||||
<td className="px-4 py-2.5 text-slate-100">{contactName(c)}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<TypeBadge type={c.type} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-slate-300">{relativeTime(c.last_advert)}</td>
|
||||
<td className="px-4 py-2.5 text-slate-300 font-mono text-xs">{position(c)}</td>
|
||||
<td className="px-4 py-2.5 text-slate-400 font-mono text-xs">
|
||||
{shortPubkey(c.pubkey)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(data?.contacts ?? []).map((c) => {
|
||||
const id = contactId(c)
|
||||
const entry = entryFor(c)
|
||||
const selected = isSelected(c)
|
||||
const pollResult = pollResults[id]
|
||||
// A contact is unavailable if its cached entry says so.
|
||||
const unavailable = entry != null && entry.available === false
|
||||
const toggleDisabled = savingId === id || (unavailable && !selected)
|
||||
|
||||
// Resolve the readout to show: a fresh Poll-now result wins,
|
||||
// otherwise the cached telemetry entry.
|
||||
let readoutData: MeshcoreTelemetryData | null = null
|
||||
let readoutLabel = ''
|
||||
let showUnavailable = false
|
||||
if (pollResult) {
|
||||
if (pollResult.available && pollResult.data) {
|
||||
readoutData = pollResult.data
|
||||
readoutLabel = 'just now'
|
||||
} else {
|
||||
showUnavailable = true
|
||||
}
|
||||
} else if (entry) {
|
||||
if (entry.available && entry.data) {
|
||||
readoutData = entry.data
|
||||
readoutLabel = relativeTimeIso(entry.polled_at)
|
||||
} else {
|
||||
showUnavailable = true
|
||||
}
|
||||
}
|
||||
const hasReadout = readoutData != null || showUnavailable
|
||||
|
||||
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">
|
||||
<TypeBadge type={c.type} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-slate-300">{relativeTime(c.last_advert)}</td>
|
||||
<td className="px-4 py-2.5 text-slate-300 font-mono text-xs">{position(c)}</td>
|
||||
<td className="px-4 py-2.5 text-slate-400 font-mono text-xs">
|
||||
{shortPubkey(c.pubkey)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<label
|
||||
className={`inline-flex items-center gap-2 ${
|
||||
toggleDisabled ? 'opacity-50' : 'cursor-pointer'
|
||||
}`}
|
||||
title={unavailable && !selected ? 'no telemetry available' : undefined}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
disabled={toggleDisabled}
|
||||
onChange={(e) => handleToggle(c, e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span className="text-xs text-slate-300">
|
||||
{savingId === id
|
||||
? 'saving…'
|
||||
: savedId === id
|
||||
? 'saved'
|
||||
: unavailable && !selected
|
||||
? 'no telemetry'
|
||||
: 'auto-poll'}
|
||||
</span>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
{hasReadout && (
|
||||
<tr className="bg-[#0a0e17]/40">
|
||||
<td colSpan={7} className="px-4 py-2 border-t border-border/50">
|
||||
{readoutData ? (
|
||||
<TelemetryReadout data={readoutData} polledLabel={readoutLabel} />
|
||||
) : (
|
||||
<span className="text-xs text-[#777]">
|
||||
no telemetry
|
||||
{pollResult?.detail ? ` — ${pollResult.detail}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[#777]">
|
||||
Telemetry auto-poll is coming in the next pass.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ class ConnectionConfig:
|
|||
meshcore_ack_wait_seconds: float = 6.0 # wait for delivery ACK before falling back to path discovery
|
||||
meshcore_discovery_wait_seconds: float = 8.0 # path-discovery timeout on the no-ACK fallback (was hardcoded 25s)
|
||||
|
||||
# --- MeshCore telemetry auto-poll settings ---
|
||||
# Selected contacts (names or pubkeys) to auto-poll for telemetry; empty = none.
|
||||
meshcore_telemetry_contacts: list = field(default_factory=list)
|
||||
meshcore_telemetry_interval_seconds: int = 1800 # auto-poll interval (0 = disabled; floor 300)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.meshcore_conn_type not in {"tcp", "serial", "ble"}:
|
||||
raise ValueError(
|
||||
|
|
|
|||
|
|
@ -87,6 +87,52 @@ async def meshcore_send_advert(request: Request):
|
|||
return {"sent": False, "detail": str(exc)}
|
||||
|
||||
|
||||
@router.get("/meshcore/telemetry")
|
||||
async def meshcore_telemetry(request: Request):
|
||||
"""Cached telemetry readings for auto-polled MeshCore contacts.
|
||||
|
||||
Returns {active: bool, entries: list}. entries is [] (and active False)
|
||||
when MeshCore is not connected.
|
||||
"""
|
||||
connector = getattr(request.app.state, "connector", None)
|
||||
mc = _find_child(connector, "meshcore")
|
||||
if mc is not None and getattr(mc, "connected", False):
|
||||
try:
|
||||
entries = list(mc.get_telemetry_cache())
|
||||
except Exception:
|
||||
entries = []
|
||||
return {"active": True, "entries": entries}
|
||||
return {"active": False, "entries": []}
|
||||
|
||||
|
||||
@router.post("/meshcore/telemetry/poll")
|
||||
async def meshcore_telemetry_poll(request: Request):
|
||||
"""On-demand ('Poll now') telemetry request for a single MeshCore contact.
|
||||
|
||||
Body: {"contact": "<name-or-pubkey>"}. Returns {available, contact, data}
|
||||
on success, or {available: False, detail: ...} when unavailable/inactive.
|
||||
"""
|
||||
connector = getattr(request.app.state, "connector", None)
|
||||
mc = _find_child(connector, "meshcore")
|
||||
if mc is None or not getattr(mc, "connected", False):
|
||||
return {"available": False, "detail": "MeshCore not connected"}
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
contact = (body or {}).get("contact")
|
||||
if not contact:
|
||||
return {"available": False, "detail": "Missing 'contact'"}
|
||||
try:
|
||||
data = mc.req_telemetry(contact)
|
||||
if data is None:
|
||||
return {"available": False, "contact": contact, "detail": "No telemetry response"}
|
||||
return {"available": True, "contact": contact, "data": data}
|
||||
except Exception as exc:
|
||||
logger.error("dashboard: meshcore telemetry poll error: %s", exc)
|
||||
return {"available": False, "contact": contact, "detail": str(exc)}
|
||||
|
||||
|
||||
class TestSendRequest(BaseModel):
|
||||
transport: str
|
||||
channel: Union[str, int]
|
||||
|
|
|
|||
|
|
@ -107,6 +107,16 @@ class CompositeTransport(MeshTransport):
|
|||
child = self.meshcore_child()
|
||||
return child.send_advert() if child is not None else False
|
||||
|
||||
def req_telemetry(self, contact_id):
|
||||
"""Passthrough to the MeshCore child's on-demand telemetry poll; None if no child."""
|
||||
child = self.meshcore_child()
|
||||
return child.req_telemetry(contact_id) if child is not None else None
|
||||
|
||||
def get_telemetry_cache(self):
|
||||
"""Passthrough to the MeshCore child's telemetry cache; [] if no meshcore child."""
|
||||
child = self.meshcore_child()
|
||||
return child.get_telemetry_cache() if child is not None else []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Routing decision helpers (factored out for unit-test access)
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -24,6 +24,30 @@ logger = logging.getLogger(__name__)
|
|||
# Default timeout for command futures (seconds).
|
||||
_COMMAND_TIMEOUT = 10.0
|
||||
|
||||
# --- Telemetry auto-poll tuning -------------------------------------------
|
||||
# Hard floor on the auto-poll interval (seconds) — airtime protection: a
|
||||
# misconfigured tiny interval can never flood the mesh with telemetry requests.
|
||||
_TELEMETRY_MIN_INTERVAL_SECONDS = 300
|
||||
# Consecutive-timeout threshold before a contact is marked unavailable and
|
||||
# dropped from the auto-poll rotation (a manual "Poll now" un-sticks it).
|
||||
_TELEMETRY_MAX_FAILURES = 3
|
||||
|
||||
# Numeric Cayenne-LPP type id → decoded field name. Ids not in this map are
|
||||
# passed through as ``lpp_<id>`` so nothing is silently dropped.
|
||||
_LPP_ID_TO_FIELD = {
|
||||
101: "illuminance",
|
||||
103: "temperature",
|
||||
104: "humidity",
|
||||
115: "barometer",
|
||||
116: "voltage",
|
||||
117: "current",
|
||||
120: "battery_pct",
|
||||
121: "altitude",
|
||||
128: "power",
|
||||
130: "distance",
|
||||
136: "gps",
|
||||
}
|
||||
|
||||
|
||||
def mc_context_allows(cfg, msg, idx_to_name):
|
||||
"""Return True if a MeshCore inbound MeshMessage should be forwarded.
|
||||
|
|
@ -94,6 +118,13 @@ class MeshCoreTransport(MeshTransport):
|
|||
self._last_advert_sent: Optional[float] = None # epoch seconds or None
|
||||
# asyncio.Task handle for the periodic advert loop; None when inactive.
|
||||
self._advert_task = None
|
||||
# asyncio.Task handle for the telemetry auto-poll loop; None when inactive.
|
||||
self._telemetry_task = None
|
||||
# Telemetry availability/bookkeeping (shared by poller + on-demand):
|
||||
# _telemetry_cache: contact-id -> {contact, data, polled_at, available}
|
||||
# _telemetry_failures: contact-id -> consecutive-timeout count
|
||||
self._telemetry_cache: dict[str, dict] = {}
|
||||
self._telemetry_failures: dict[str, int] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
|
|
@ -501,6 +532,208 @@ class MeshCoreTransport(MeshTransport):
|
|||
if task is not None and self._loop is not None and self._loop.is_running():
|
||||
self._loop.call_soon_threadsafe(task.cancel)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Telemetry (MeshCore sensor auto-poll)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_contact(self, contact_id: str):
|
||||
"""Resolve *contact_id* (a pubkey/prefix OR a name) to a contact dict.
|
||||
|
||||
Mirrors DM / get_node_name resolution: try key-prefix first, then name.
|
||||
Returns the raw contact dict, or None if not resolvable / not connected.
|
||||
"""
|
||||
if self._mc is None or not self._connected:
|
||||
return None
|
||||
try:
|
||||
contact = self._mc.get_contact_by_key_prefix(contact_id)
|
||||
if contact:
|
||||
return contact
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
contact = self._mc.get_contact_by_name(contact_id)
|
||||
if contact:
|
||||
return contact
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _decode_lpp(lpp) -> dict:
|
||||
"""Decode a MeshCore telemetry ``lpp`` list into a flat field dict.
|
||||
|
||||
Each element is ``{"channel": int, "type": int, "value": <num|dict>}``.
|
||||
The numeric ``type`` id is mapped to a field name via _LPP_ID_TO_FIELD;
|
||||
unknown ids become ``lpp_<id>``. The original list is always preserved
|
||||
under the ``raw`` key.
|
||||
"""
|
||||
out: dict = {}
|
||||
for elem in (lpp or []):
|
||||
if not isinstance(elem, dict):
|
||||
continue
|
||||
lpp_id = elem.get("type")
|
||||
field_name = _LPP_ID_TO_FIELD.get(lpp_id, f"lpp_{lpp_id}")
|
||||
out[field_name] = elem.get("value")
|
||||
out["raw"] = lpp
|
||||
return out
|
||||
|
||||
def _record_telemetry_result(self, contact_id: str, data) -> None:
|
||||
"""Update the shared cache/failure bookkeeping for a poll result.
|
||||
|
||||
``data`` is a decoded dict on success or None on timeout/no-response.
|
||||
On success: cache the reading, reset the failure counter, available=True.
|
||||
On None: bump the failure counter; once it reaches _TELEMETRY_MAX_FAILURES
|
||||
the entry is marked available=False (last data retained); before that the
|
||||
entry stays available with its previous data (only polled_at is refreshed).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
if data is not None:
|
||||
self._telemetry_failures[contact_id] = 0
|
||||
self._telemetry_cache[contact_id] = {
|
||||
"contact": contact_id,
|
||||
"data": data,
|
||||
"polled_at": now,
|
||||
"available": True,
|
||||
}
|
||||
return
|
||||
# Miss: increment consecutive-failure counter.
|
||||
fails = self._telemetry_failures.get(contact_id, 0) + 1
|
||||
self._telemetry_failures[contact_id] = fails
|
||||
prev = self._telemetry_cache.get(contact_id, {})
|
||||
entry = {
|
||||
"contact": contact_id,
|
||||
"data": prev.get("data"),
|
||||
"polled_at": now,
|
||||
"available": prev.get("available", True),
|
||||
}
|
||||
if fails >= _TELEMETRY_MAX_FAILURES:
|
||||
entry["available"] = False
|
||||
self._telemetry_cache[contact_id] = entry
|
||||
|
||||
async def _req_telemetry_async(self, contact_id):
|
||||
"""Resolve, request+await, decode telemetry for *contact_id* (on the loop).
|
||||
|
||||
Runs entirely on the dedicated event loop so the poller (already on that
|
||||
loop) can await it directly WITHOUT a nested _run_coro deadlock. Updates
|
||||
the shared cache/failure bookkeeping so poller and on-demand paths agree.
|
||||
Returns the decoded dict, or None on unresolved/timeout/no-response/error.
|
||||
"""
|
||||
contact = self._resolve_contact(contact_id)
|
||||
if contact is None:
|
||||
return None
|
||||
try:
|
||||
lpp = await self._mc.commands.req_telemetry_sync(contact, min_timeout=5)
|
||||
except Exception as exc:
|
||||
logger.warning("MeshCore: req_telemetry(%s) error: %s", contact_id, exc)
|
||||
self._record_telemetry_result(contact_id, None)
|
||||
return None
|
||||
if lpp is None:
|
||||
self._record_telemetry_result(contact_id, None)
|
||||
return None
|
||||
data = self._decode_lpp(lpp)
|
||||
self._record_telemetry_result(contact_id, data)
|
||||
return data
|
||||
|
||||
def req_telemetry(self, contact_id):
|
||||
"""On-demand telemetry poll for *contact_id* (sync, bridged to the loop).
|
||||
|
||||
A successful manual poll resets the contact's failure counter and flips
|
||||
it back to available in the cache (un-sticks an unavailable node).
|
||||
Returns the decoded telemetry dict, or None if unresolved / no response /
|
||||
not connected.
|
||||
"""
|
||||
if self._mc is None or not self._connected:
|
||||
return None
|
||||
try:
|
||||
return self._run_coro(
|
||||
self._req_telemetry_async(contact_id), timeout=25.0
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("MeshCore: req_telemetry(%s) failed: %s", contact_id, exc)
|
||||
return None
|
||||
|
||||
def get_telemetry_cache(self) -> list[dict]:
|
||||
"""Return the current telemetry cache entries (list of dicts)."""
|
||||
return list(self._telemetry_cache.values())
|
||||
|
||||
def _effective_telemetry_interval(self):
|
||||
"""Compute the effective auto-poll interval (seconds), or None if disabled.
|
||||
|
||||
raw <= 0 → disabled (None). Otherwise the raw interval clamped UP to the
|
||||
_TELEMETRY_MIN_INTERVAL_SECONDS floor (airtime protection).
|
||||
"""
|
||||
raw = getattr(self.config, "meshcore_telemetry_interval_seconds", 1800)
|
||||
if raw <= 0:
|
||||
return None
|
||||
return max(raw, _TELEMETRY_MIN_INTERVAL_SECONDS)
|
||||
|
||||
async def _telemetry_poll_loop(self) -> None:
|
||||
"""Auto-poll selected contacts for telemetry (Task on the dedicated loop).
|
||||
|
||||
Airtime guards:
|
||||
- min-floor: interval is clamped up to _TELEMETRY_MIN_INTERVAL_SECONDS.
|
||||
- selected-only: iterates ONLY config.meshcore_telemetry_contacts, never
|
||||
the whole roster.
|
||||
- sequential + gap: one contact at a time with a 2 s gap between each
|
||||
(the lib also serializes mesh requests with an internal lock).
|
||||
- availability stop: contacts at/over _TELEMETRY_MAX_FAILURES are skipped
|
||||
(not auto-polled) until a manual poll un-sticks them.
|
||||
Stops on CancelledError (disconnect) or when the transport drops its link.
|
||||
"""
|
||||
interval = self._effective_telemetry_interval()
|
||||
if interval is None:
|
||||
return # disabled
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
if not self._connected or self._mc is None:
|
||||
return
|
||||
# Read the SELECTED list fresh each cycle (GUI may have changed it).
|
||||
contacts = list(
|
||||
getattr(self.config, "meshcore_telemetry_contacts", []) or []
|
||||
)
|
||||
for c in contacts:
|
||||
if not self._connected or self._mc is None:
|
||||
return
|
||||
# Availability stop: don't auto-poll a stuck contact.
|
||||
if self._telemetry_failures.get(c, 0) >= _TELEMETRY_MAX_FAILURES:
|
||||
continue
|
||||
try:
|
||||
data = await self._req_telemetry_async(c)
|
||||
if data is not None:
|
||||
logger.info("MeshCore: telemetry polled %s", c)
|
||||
else:
|
||||
logger.debug("MeshCore: telemetry miss for %s", c)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"MeshCore: telemetry poll error for %s: %s", c, exc
|
||||
)
|
||||
# Sequential gap between contacts (airtime spacing).
|
||||
await asyncio.sleep(2)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("MeshCore: telemetry poll task cancelled")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("MeshCore: telemetry poll loop error: %s", exc)
|
||||
|
||||
def _schedule_telemetry_poll(self) -> None:
|
||||
"""Create the telemetry auto-poll Task on the dedicated loop (thread-safe)."""
|
||||
def _arm() -> None:
|
||||
self._telemetry_task = asyncio.get_event_loop().create_task(
|
||||
self._telemetry_poll_loop()
|
||||
)
|
||||
self._loop.call_soon_threadsafe(_arm)
|
||||
|
||||
def _cancel_telemetry_poll(self) -> None:
|
||||
"""Cancel the telemetry auto-poll task (thread-safe). Called at disconnect."""
|
||||
task = self._telemetry_task
|
||||
self._telemetry_task = None
|
||||
if task is not None and self._loop is not None and self._loop.is_running():
|
||||
self._loop.call_soon_threadsafe(task.cancel)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal coroutines (run on the dedicated loop)
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -663,6 +896,13 @@ class MeshCoreTransport(MeshTransport):
|
|||
if interval > 0:
|
||||
self._schedule_periodic_advert(interval)
|
||||
|
||||
# Arm telemetry auto-poll if configured (0 = disabled).
|
||||
telem_interval = getattr(
|
||||
self.config, "meshcore_telemetry_interval_seconds", 1800
|
||||
)
|
||||
if telem_interval > 0:
|
||||
self._schedule_telemetry_poll()
|
||||
|
||||
logger.info(
|
||||
"MeshCoreTransport: connected as %s (pubkey %s)",
|
||||
self._self_info.get("name", "unknown"),
|
||||
|
|
@ -671,8 +911,9 @@ class MeshCoreTransport(MeshTransport):
|
|||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect and stop the event loop thread."""
|
||||
# Cancel periodic advert before tearing down the loop.
|
||||
# Cancel periodic advert + telemetry poll before tearing down the loop.
|
||||
self._cancel_periodic_advert()
|
||||
self._cancel_telemetry_poll()
|
||||
if self._mc is not None:
|
||||
try:
|
||||
self._run_coro(self._do_disconnect(), timeout=10.0)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ def _build_recording_meshcore():
|
|||
DISCONNECTED = "DISCONNECTED"
|
||||
CONNECTED = "CONNECTED"
|
||||
ACK = "ACK"
|
||||
NEW_CONTACT = "NEW_CONTACT"
|
||||
|
||||
mod.EventType = EventType
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ def _ensure_fake_meshcore():
|
|||
DISCONNECTED = "DISCONNECTED"
|
||||
CONNECTED = "CONNECTED"
|
||||
ACK = "ACK"
|
||||
NEW_CONTACT = "NEW_CONTACT"
|
||||
|
||||
mod.EventType = EventType
|
||||
|
||||
|
|
|
|||
493
work/tests/test_meshcore_telemetry.py
Normal file
493
work/tests/test_meshcore_telemetry.py
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
"""Tests for MeshCore telemetry auto-poll (backend).
|
||||
|
||||
Fully mocked — no real socket, no meshcore lib required. A minimal fake
|
||||
``meshcore`` module is injected into sys.modules before the production code's
|
||||
lazy import triggers, mirroring test_meshcore_transport.py.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake meshcore module (registered before production imports)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_fake_meshcore():
|
||||
mod = types.ModuleType("meshcore")
|
||||
|
||||
class EventType:
|
||||
CONTACT_MSG_RECV = "CONTACT_MSG_RECV"
|
||||
CHANNEL_MSG_RECV = "CHANNEL_MSG_RECV"
|
||||
DISCONNECTED = "DISCONNECTED"
|
||||
CONNECTED = "CONNECTED"
|
||||
# Added in main alongside ACK-wait delivery confirmation — required by
|
||||
# _setup_subscriptions(); fake must include them or connect() blows up.
|
||||
ACK = "acknowledgement"
|
||||
NEW_CONTACT = "new_contact"
|
||||
|
||||
mod.EventType = EventType
|
||||
|
||||
class _FakeMeshCore:
|
||||
self_info = {"public_key": "aabbccdd1122", "name": "FakeNode"}
|
||||
contacts = {}
|
||||
|
||||
async def start_auto_message_fetching(self):
|
||||
pass
|
||||
|
||||
async def stop_auto_message_fetching(self):
|
||||
pass
|
||||
|
||||
async def disconnect(self):
|
||||
pass
|
||||
|
||||
def subscribe(self, event_type, callback):
|
||||
pass
|
||||
|
||||
def get_contact_by_key_prefix(self, prefix):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def create_tcp(cls, host, port,
|
||||
auto_reconnect=True, max_reconnect_attempts=5):
|
||||
return cls()
|
||||
|
||||
class commands:
|
||||
@staticmethod
|
||||
async def req_telemetry_sync(contact, timeout=0, min_timeout=0):
|
||||
return None
|
||||
|
||||
mod.MeshCore = _FakeMeshCore
|
||||
return mod
|
||||
|
||||
|
||||
sys.modules.setdefault("meshcore", _build_fake_meshcore())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Production imports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from meshai.config import ( # noqa: E402
|
||||
ConnectionConfig, _dataclass_to_dict, _dict_to_dataclass,
|
||||
)
|
||||
from meshai.transport.meshcore_transport import ( # noqa: E402
|
||||
MeshCoreTransport,
|
||||
_TELEMETRY_MAX_FAILURES,
|
||||
_TELEMETRY_MIN_INTERVAL_SECONDS,
|
||||
)
|
||||
from meshai.dashboard.api.mesh_send_routes import router # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mc_config(**overrides):
|
||||
cfg = ConnectionConfig(meshcore_host="127.0.0.1", meshcore_port=5050)
|
||||
for k, v in overrides.items():
|
||||
setattr(cfg, k, v)
|
||||
return cfg
|
||||
|
||||
|
||||
def _transport_with_mock_mc(mc_overrides=None, **cfg_overrides):
|
||||
"""MeshCoreTransport with a MagicMock _mc + a live dedicated loop thread."""
|
||||
cfg = _mc_config(**cfg_overrides)
|
||||
t = MeshCoreTransport(cfg)
|
||||
|
||||
mc = MagicMock()
|
||||
mc.get_contact_by_key_prefix.return_value = None
|
||||
mc.get_contact_by_name.return_value = None
|
||||
if mc_overrides:
|
||||
for k, v in mc_overrides.items():
|
||||
setattr(mc, k, v)
|
||||
|
||||
t._mc = mc
|
||||
t._connected = True
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
t._loop = loop
|
||||
thread = threading.Thread(target=loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
t._loop_thread = thread
|
||||
return t, mc, loop
|
||||
|
||||
|
||||
def _cleanup(t):
|
||||
try:
|
||||
if t._loop and t._loop.is_running():
|
||||
t._loop.call_soon_threadsafe(t._loop.stop)
|
||||
if t._loop_thread and t._loop_thread.is_alive():
|
||||
t._loop_thread.join(timeout=2.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# A sample telemetry lpp list: voltage, temperature, humidity, battery %, and
|
||||
# an unknown id (200) that must fall through to lpp_200.
|
||||
_SAMPLE_LPP = [
|
||||
{"channel": 0, "type": 116, "value": 3.98},
|
||||
{"channel": 1, "type": 103, "value": 21.5},
|
||||
{"channel": 2, "type": 104, "value": 44},
|
||||
{"channel": 3, "type": 120, "value": 87},
|
||||
{"channel": 4, "type": 200, "value": 999},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. _decode_lpp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDecodeLpp:
|
||||
def test_maps_known_ids_and_preserves_raw(self):
|
||||
data = MeshCoreTransport._decode_lpp(_SAMPLE_LPP)
|
||||
assert data["voltage"] == 3.98
|
||||
assert data["temperature"] == 21.5
|
||||
assert data["humidity"] == 44
|
||||
assert data["battery_pct"] == 87
|
||||
# Unknown id → lpp_<id>
|
||||
assert data["lpp_200"] == 999
|
||||
# raw is always the original list
|
||||
assert data["raw"] == _SAMPLE_LPP
|
||||
|
||||
def test_empty_list_yields_only_raw(self):
|
||||
data = MeshCoreTransport._decode_lpp([])
|
||||
assert data == {"raw": []}
|
||||
|
||||
def test_none_yields_raw_none(self):
|
||||
data = MeshCoreTransport._decode_lpp(None)
|
||||
assert data["raw"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. req_telemetry (sync wrapper, bridged)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReqTelemetry:
|
||||
def test_returns_decoded_dict_on_lpp(self):
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=_SAMPLE_LPP)
|
||||
data = t.req_telemetry("aabbcc")
|
||||
assert data is not None
|
||||
assert data["voltage"] == 3.98
|
||||
assert data["temperature"] == 21.5
|
||||
mc.commands.req_telemetry_sync.assert_awaited_once()
|
||||
# min_timeout is passed so a node gets a reasonable window.
|
||||
_, kwargs = mc.commands.req_telemetry_sync.call_args
|
||||
assert kwargs.get("min_timeout") == 5
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_returns_none_on_timeout(self):
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
|
||||
assert t.req_telemetry("aabbcc") is None
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_returns_none_when_unresolved(self):
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.get_contact_by_key_prefix.return_value = None
|
||||
mc.get_contact_by_name.return_value = None
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=_SAMPLE_LPP)
|
||||
assert t.req_telemetry("ghost") is None
|
||||
mc.commands.req_telemetry_sync.assert_not_awaited()
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_resolves_by_name_when_prefix_misses(self):
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.get_contact_by_key_prefix.return_value = None
|
||||
mc.get_contact_by_name.return_value = {"adv_name": "ByName"}
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=_SAMPLE_LPP)
|
||||
data = t.req_telemetry("ByName")
|
||||
assert data is not None and data["humidity"] == 44
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_returns_none_when_not_connected(self):
|
||||
t = MeshCoreTransport(_mc_config()) # _mc None, no loop
|
||||
assert t.req_telemetry("aabbcc") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Poller bookkeeping — via _req_telemetry_async on the loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPollerBookkeeping:
|
||||
def _run(self, t, coro):
|
||||
return t._run_coro(coro, timeout=5.0)
|
||||
|
||||
def test_caches_reading_for_contact(self):
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=_SAMPLE_LPP)
|
||||
self._run(t, t._req_telemetry_async("nodeA"))
|
||||
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
|
||||
assert "nodeA" in cache
|
||||
assert cache["nodeA"]["available"] is True
|
||||
assert cache["nodeA"]["data"]["voltage"] == 3.98
|
||||
assert cache["nodeA"]["polled_at"] is not None
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_marks_unavailable_after_max_failures(self):
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
|
||||
for _ in range(_TELEMETRY_MAX_FAILURES):
|
||||
self._run(t, t._req_telemetry_async("nodeB"))
|
||||
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
|
||||
assert cache["nodeB"]["available"] is False
|
||||
assert t._telemetry_failures["nodeB"] >= _TELEMETRY_MAX_FAILURES
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_stays_available_before_max_failures(self):
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
|
||||
# One miss (< max) — still available.
|
||||
self._run(t, t._req_telemetry_async("nodeC"))
|
||||
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
|
||||
assert cache["nodeC"]["available"] is True
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_success_after_failures_flips_back_available(self):
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
|
||||
# Drive it unavailable.
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
|
||||
for _ in range(_TELEMETRY_MAX_FAILURES):
|
||||
self._run(t, t._req_telemetry_async("nodeD"))
|
||||
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
|
||||
assert cache["nodeD"]["available"] is False
|
||||
# A later success un-sticks it and resets the counter.
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=_SAMPLE_LPP)
|
||||
self._run(t, t._req_telemetry_async("nodeD"))
|
||||
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
|
||||
assert cache["nodeD"]["available"] is True
|
||||
assert cache["nodeD"]["data"]["voltage"] == 3.98
|
||||
assert t._telemetry_failures["nodeD"] == 0
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_manual_poll_unsticks_unavailable(self):
|
||||
"""The sync req_telemetry wrapper shares bookkeeping: a manual poll
|
||||
after failures flips availability back on."""
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
|
||||
for _ in range(_TELEMETRY_MAX_FAILURES):
|
||||
t.req_telemetry("nodeE")
|
||||
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
|
||||
assert cache["nodeE"]["available"] is False
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=_SAMPLE_LPP)
|
||||
assert t.req_telemetry("nodeE") is not None
|
||||
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
|
||||
assert cache["nodeE"]["available"] is True
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Effective interval (min-floor airtime guard)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEffectiveInterval:
|
||||
def test_below_floor_clamped_up(self):
|
||||
t = MeshCoreTransport(_mc_config(meshcore_telemetry_interval_seconds=60))
|
||||
assert t._effective_telemetry_interval() == _TELEMETRY_MIN_INTERVAL_SECONDS
|
||||
assert t._effective_telemetry_interval() == 300
|
||||
|
||||
def test_above_floor_preserved(self):
|
||||
t = MeshCoreTransport(_mc_config(meshcore_telemetry_interval_seconds=1800))
|
||||
assert t._effective_telemetry_interval() == 1800
|
||||
|
||||
def test_zero_disables(self):
|
||||
t = MeshCoreTransport(_mc_config(meshcore_telemetry_interval_seconds=0))
|
||||
assert t._effective_telemetry_interval() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Poller scheduler lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPollerScheduler:
|
||||
def test_task_armed_when_interval_nonzero(self):
|
||||
import time
|
||||
cfg = _mc_config(meshcore_telemetry_interval_seconds=1800,
|
||||
meshcore_advert_interval_seconds=0)
|
||||
t = MeshCoreTransport(cfg)
|
||||
try:
|
||||
t.connect()
|
||||
time.sleep(0.1)
|
||||
assert t._telemetry_task is not None
|
||||
finally:
|
||||
t.disconnect()
|
||||
|
||||
def test_task_not_armed_when_interval_zero(self):
|
||||
import time
|
||||
cfg = _mc_config(meshcore_telemetry_interval_seconds=0,
|
||||
meshcore_advert_interval_seconds=0)
|
||||
t = MeshCoreTransport(cfg)
|
||||
try:
|
||||
t.connect()
|
||||
time.sleep(0.1)
|
||||
assert t._telemetry_task is None
|
||||
finally:
|
||||
t.disconnect()
|
||||
|
||||
def test_task_cleared_after_disconnect(self):
|
||||
import time
|
||||
cfg = _mc_config(meshcore_telemetry_interval_seconds=1800,
|
||||
meshcore_advert_interval_seconds=0)
|
||||
t = MeshCoreTransport(cfg)
|
||||
t.connect()
|
||||
time.sleep(0.1)
|
||||
assert t._telemetry_task is not None
|
||||
t.disconnect()
|
||||
assert t._telemetry_task is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Dashboard endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _child(transport_name, connected=True):
|
||||
c = MagicMock()
|
||||
c.transport_name = transport_name
|
||||
c.connected = connected
|
||||
return c
|
||||
|
||||
|
||||
def _composite(children):
|
||||
connector = MagicMock()
|
||||
connector.transport_name = None
|
||||
connector.children = list(children)
|
||||
return connector
|
||||
|
||||
|
||||
def _client(connector):
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api")
|
||||
app.state.connector = connector
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestTelemetryEndpoints:
|
||||
def test_get_active_returns_entries(self):
|
||||
mc = _child("meshcore", connected=True)
|
||||
mc.get_telemetry_cache.return_value = [
|
||||
{"contact": "nodeA", "data": {"voltage": 3.98}, "polled_at": "x", "available": True}
|
||||
]
|
||||
client = _client(_composite([mc]))
|
||||
r = client.get("/api/meshcore/telemetry")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["active"] is True
|
||||
assert body["entries"][0]["contact"] == "nodeA"
|
||||
|
||||
def test_get_inactive_when_not_connected(self):
|
||||
mc = _child("meshcore", connected=False)
|
||||
client = _client(_composite([mc]))
|
||||
r = client.get("/api/meshcore/telemetry")
|
||||
assert r.json() == {"active": False, "entries": []}
|
||||
|
||||
def test_get_inactive_when_no_meshcore(self):
|
||||
mt = _child("meshtastic", connected=True)
|
||||
client = _client(_composite([mt]))
|
||||
r = client.get("/api/meshcore/telemetry")
|
||||
assert r.json() == {"active": False, "entries": []}
|
||||
|
||||
def test_poll_available(self):
|
||||
mc = _child("meshcore", connected=True)
|
||||
mc.req_telemetry.return_value = {"voltage": 3.98, "raw": []}
|
||||
client = _client(_composite([mc]))
|
||||
r = client.post("/api/meshcore/telemetry/poll", json={"contact": "nodeA"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["available"] is True
|
||||
assert body["contact"] == "nodeA"
|
||||
assert body["data"]["voltage"] == 3.98
|
||||
|
||||
def test_poll_no_response(self):
|
||||
mc = _child("meshcore", connected=True)
|
||||
mc.req_telemetry.return_value = None
|
||||
client = _client(_composite([mc]))
|
||||
r = client.post("/api/meshcore/telemetry/poll", json={"contact": "nodeA"})
|
||||
body = r.json()
|
||||
assert body["available"] is False
|
||||
assert body["detail"] == "No telemetry response"
|
||||
|
||||
def test_poll_missing_contact(self):
|
||||
mc = _child("meshcore", connected=True)
|
||||
client = _client(_composite([mc]))
|
||||
r = client.post("/api/meshcore/telemetry/poll", json={})
|
||||
body = r.json()
|
||||
assert body["available"] is False
|
||||
assert "Missing" in body["detail"]
|
||||
|
||||
def test_poll_not_connected(self):
|
||||
mc = _child("meshcore", connected=False)
|
||||
client = _client(_composite([mc]))
|
||||
r = client.post("/api/meshcore/telemetry/poll", json={"contact": "nodeA"})
|
||||
body = r.json()
|
||||
assert body["available"] is False
|
||||
assert body["detail"] == "MeshCore not connected"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Config round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConfigRoundTrip:
|
||||
def test_defaults(self):
|
||||
cfg = ConnectionConfig()
|
||||
assert cfg.meshcore_telemetry_contacts == []
|
||||
assert cfg.meshcore_telemetry_interval_seconds == 1800
|
||||
|
||||
def test_construct_with_values(self):
|
||||
cfg = ConnectionConfig(
|
||||
meshcore_telemetry_contacts=["abc"],
|
||||
meshcore_telemetry_interval_seconds=900,
|
||||
)
|
||||
assert cfg.meshcore_telemetry_contacts == ["abc"]
|
||||
assert cfg.meshcore_telemetry_interval_seconds == 900
|
||||
|
||||
def test_independent_default_lists(self):
|
||||
a = ConnectionConfig()
|
||||
b = ConnectionConfig()
|
||||
a.meshcore_telemetry_contacts.append("x")
|
||||
assert b.meshcore_telemetry_contacts == []
|
||||
|
||||
def test_yaml_round_trip(self):
|
||||
cfg = ConnectionConfig(
|
||||
meshcore_telemetry_contacts=["n1", "n2"],
|
||||
meshcore_telemetry_interval_seconds=600,
|
||||
)
|
||||
data = _dataclass_to_dict(cfg)
|
||||
assert data["meshcore_telemetry_contacts"] == ["n1", "n2"]
|
||||
assert data["meshcore_telemetry_interval_seconds"] == 600
|
||||
cfg2 = _dict_to_dataclass(ConnectionConfig, data)
|
||||
assert cfg2.meshcore_telemetry_contacts == ["n1", "n2"]
|
||||
assert cfg2.meshcore_telemetry_interval_seconds == 600
|
||||
Loading…
Add table
Add a link
Reference in a new issue