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
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>
This commit is contained in:
parent
284fb5cbf2
commit
fa7e39ab34
7 changed files with 1211 additions and 29 deletions
|
|
@ -495,7 +495,7 @@ export async function getMeshcoreChannels(): Promise<MeshcoreChannels> {
|
||||||
export interface MeshcoreContact {
|
export interface MeshcoreContact {
|
||||||
name: string | null
|
name: string | null
|
||||||
pubkey: string
|
pubkey: string
|
||||||
type: string | null
|
type: number | null
|
||||||
last_advert: number | null
|
last_advert: number | null
|
||||||
lat: number | null
|
lat: number | null
|
||||||
lon: number | null
|
lon: number | null
|
||||||
|
|
@ -534,6 +534,51 @@ export async function sendMeshcoreAdvert(): Promise<TestSendResult> {
|
||||||
return response.json()
|
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: {
|
export async function sendTestMessage(body: {
|
||||||
transport: 'meshtastic' | 'meshcore'
|
transport: 'meshtastic' | 'meshcore'
|
||||||
channel: string | number
|
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 { Users } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
fetchMeshcoreContacts,
|
fetchMeshcoreContacts,
|
||||||
|
fetchMeshcoreTelemetry,
|
||||||
|
fetchConnectionConfig,
|
||||||
|
pollMeshcoreContact,
|
||||||
|
updateConfig,
|
||||||
type MeshcoreContacts,
|
type MeshcoreContacts,
|
||||||
type MeshcoreContact,
|
type MeshcoreContact,
|
||||||
|
type MeshcoreTelemetry,
|
||||||
|
type MeshcoreTelemetryEntry,
|
||||||
|
type MeshcoreTelemetryData,
|
||||||
|
type MeshcorePollResult,
|
||||||
|
type ConnectionConfig,
|
||||||
} from '../lib/api'
|
} 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 {
|
function relativeTime(epochSeconds: number | null): string {
|
||||||
if (epochSeconds == null) return '—'
|
if (epochSeconds == null) return '—'
|
||||||
const diff = Math.floor(Date.now() / 1000) - epochSeconds
|
const diff = Math.floor(Date.now() / 1000) - epochSeconds
|
||||||
|
|
@ -19,16 +32,33 @@ function relativeTime(epochSeconds: number | null): string {
|
||||||
return `${days}d ago`
|
return `${days}d ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
|
// Relative time for ISO8601 timestamps (telemetry polled_at).
|
||||||
chat: { label: 'Chat', className: 'bg-sky-500/15 text-sky-400' },
|
function relativeTimeIso(iso: string | null): string {
|
||||||
repeater: { label: 'Repeater', className: 'bg-amber-500/15 text-amber-400' },
|
if (!iso) return '—'
|
||||||
room: { label: 'Room', className: 'bg-violet-500/15 text-violet-400' },
|
const then = Date.parse(iso)
|
||||||
sensor: { label: 'Sensor', className: 'bg-emerald-500/15 text-emerald-400' },
|
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 }) {
|
// MeshCore contact.type is a NUMBER: 0=NONE,1=chat,2=repeater,3=room,4=sensor.
|
||||||
const meta = (type && TYPE_BADGES[type]) || {
|
const TYPE_BADGES: Record<number, { label: string; className: string }> = {
|
||||||
label: type ?? 'unknown',
|
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',
|
className: 'bg-slate-600/30 text-slate-400',
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|
@ -44,6 +74,12 @@ function contactName(c: MeshcoreContact): string {
|
||||||
return 'unnamed'
|
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 {
|
function shortPubkey(pubkey: string): string {
|
||||||
return pubkey.length > 12 ? `${pubkey.slice(0, 12)}…` : pubkey
|
return pubkey.length > 12 ? `${pubkey.slice(0, 12)}…` : pubkey
|
||||||
}
|
}
|
||||||
|
|
@ -55,15 +91,77 @@ function position(c: MeshcoreContact): string {
|
||||||
return '—'
|
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() {
|
export default function MeshCoreContacts() {
|
||||||
const [data, setData] = useState<MeshcoreContacts | null>(null)
|
const [data, setData] = useState<MeshcoreContacts | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
document.title = 'MeshCore Contacts - MeshAI'
|
document.title = 'MeshCore Contacts - MeshAI'
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Roster (once).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-4">
|
<div className="max-w-4xl mx-auto space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -95,11 +327,46 @@ export default function MeshCoreContacts() {
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-slate-100">MeshCore Contacts</h2>
|
<h2 className="text-xl font-semibold text-slate-100">MeshCore Contacts</h2>
|
||||||
<p className="text-sm text-[#777]">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-32">
|
<div className="flex items-center justify-center h-32">
|
||||||
<div className="text-slate-400">Loading...</div>
|
<div className="text-slate-400">Loading...</div>
|
||||||
|
|
@ -131,11 +398,45 @@ 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">Last heard</th>
|
||||||
<th className="px-4 py-2.5 font-medium">Position</th>
|
<th className="px-4 py-2.5 font-medium">Position</th>
|
||||||
<th className="px-4 py-2.5 font-medium">Pubkey</th>
|
<th className="px-4 py-2.5 font-medium">Pubkey</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium">Auto-poll</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{(data?.contacts ?? []).map((c) => (
|
{(data?.contacts ?? []).map((c) => {
|
||||||
<tr key={c.pubkey} className="hover:bg-bg-hover">
|
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 text-slate-100">{contactName(c)}</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<TypeBadge type={c.type} />
|
<TypeBadge type={c.type} />
|
||||||
|
|
@ -145,16 +446,62 @@ export default function MeshCoreContacts() {
|
||||||
<td className="px-4 py-2.5 text-slate-400 font-mono text-xs">
|
<td className="px-4 py-2.5 text-slate-400 font-mono text-xs">
|
||||||
{shortPubkey(c.pubkey)}
|
{shortPubkey(c.pubkey)}
|
||||||
</td>
|
</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>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-[#777]">
|
|
||||||
Telemetry auto-poll is coming in the next pass.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ class ConnectionConfig:
|
||||||
meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect
|
meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect
|
||||||
meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited)
|
meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited)
|
||||||
meshcore_advert_interval_seconds: int = 10800 # periodic self-advert interval (0 = disabled)
|
meshcore_advert_interval_seconds: int = 10800 # periodic self-advert interval (0 = disabled)
|
||||||
|
# --- 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)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,52 @@ async def meshcore_send_advert(request: Request):
|
||||||
return {"sent": False, "detail": str(exc)}
|
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):
|
class TestSendRequest(BaseModel):
|
||||||
transport: str
|
transport: str
|
||||||
channel: Union[str, int]
|
channel: Union[str, int]
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,16 @@ class CompositeTransport(MeshTransport):
|
||||||
child = self.meshcore_child()
|
child = self.meshcore_child()
|
||||||
return child.send_advert() if child is not None else False
|
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)
|
# Routing decision helpers (factored out for unit-test access)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,30 @@ logger = logging.getLogger(__name__)
|
||||||
# Default timeout for command futures (seconds).
|
# Default timeout for command futures (seconds).
|
||||||
_COMMAND_TIMEOUT = 10.0
|
_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):
|
def mc_context_allows(cfg, msg, idx_to_name):
|
||||||
"""Return True if a MeshCore inbound MeshMessage should be forwarded.
|
"""Return True if a MeshCore inbound MeshMessage should be forwarded.
|
||||||
|
|
@ -87,6 +111,13 @@ class MeshCoreTransport(MeshTransport):
|
||||||
self._last_advert_sent: Optional[float] = None # epoch seconds or None
|
self._last_advert_sent: Optional[float] = None # epoch seconds or None
|
||||||
# asyncio.Task handle for the periodic advert loop; None when inactive.
|
# asyncio.Task handle for the periodic advert loop; None when inactive.
|
||||||
self._advert_task = None
|
self._advert_task = None
|
||||||
|
# asyncio.Task handle for the telemetry auto-poll loop; None when inactive.
|
||||||
|
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
|
# Internal helpers
|
||||||
|
|
@ -292,6 +323,208 @@ class MeshCoreTransport(MeshTransport):
|
||||||
if task is not None and self._loop is not None and self._loop.is_running():
|
if task is not None and self._loop is not None and self._loop.is_running():
|
||||||
self._loop.call_soon_threadsafe(task.cancel)
|
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)
|
# Internal coroutines (run on the dedicated loop)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -398,6 +631,13 @@ class MeshCoreTransport(MeshTransport):
|
||||||
if interval > 0:
|
if interval > 0:
|
||||||
self._schedule_periodic_advert(interval)
|
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(
|
logger.info(
|
||||||
"MeshCoreTransport: connected as %s (pubkey %s)",
|
"MeshCoreTransport: connected as %s (pubkey %s)",
|
||||||
self._self_info.get("name", "unknown"),
|
self._self_info.get("name", "unknown"),
|
||||||
|
|
@ -406,8 +646,9 @@ class MeshCoreTransport(MeshTransport):
|
||||||
|
|
||||||
def disconnect(self) -> None:
|
def disconnect(self) -> None:
|
||||||
"""Disconnect and stop the event loop thread."""
|
"""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_periodic_advert()
|
||||||
|
self._cancel_telemetry_poll()
|
||||||
if self._mc is not None:
|
if self._mc is not None:
|
||||||
try:
|
try:
|
||||||
self._run_coro(self._do_disconnect(), timeout=10.0)
|
self._run_coro(self._do_disconnect(), timeout=10.0)
|
||||||
|
|
|
||||||
489
work/tests/test_meshcore_telemetry.py
Normal file
489
work/tests/test_meshcore_telemetry.py
Normal file
|
|
@ -0,0 +1,489 @@
|
||||||
|
"""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"
|
||||||
|
|
||||||
|
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