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:
malice 2026-07-08 00:54:59 -06:00 committed by GitHub
commit 6e74b82d51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1218 additions and 29 deletions

View file

@ -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

View file

@ -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 &mdash; names, types, and last-heard times.
The companion's known contact roster &mdash; 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 &mdash; 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>
)
}

View file

@ -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(

View file

@ -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]

View file

@ -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)
# ------------------------------------------------------------------

View file

@ -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)

View file

@ -28,6 +28,7 @@ def _build_recording_meshcore():
DISCONNECTED = "DISCONNECTED"
CONNECTED = "CONNECTED"
ACK = "ACK"
NEW_CONTACT = "NEW_CONTACT"
mod.EventType = EventType

View file

@ -50,6 +50,7 @@ def _ensure_fake_meshcore():
DISCONNECTED = "DISCONNECTED"
CONNECTED = "CONNECTED"
ACK = "ACK"
NEW_CONTACT = "NEW_CONTACT"
mod.EventType = EventType

View 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