From 6e74b82d5179c64cd8f2f46e4f0e1427159b2f02 Mon Sep 17 00:00:00 2001 From: malice Date: Wed, 8 Jul 2026 00:54:59 -0600 Subject: [PATCH] feat(meshcore): opt-in telemetry auto-poll on selected contacts (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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 --------- Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.8 (1M context) --- work/dashboard-frontend/src/lib/api.ts | 47 +- .../src/pages/MeshCoreContacts.tsx | 401 +++++++++++++- work/meshai/config.py | 5 + work/meshai/dashboard/api/mesh_send_routes.py | 46 ++ work/meshai/transport/composite_transport.py | 10 + work/meshai/transport/meshcore_transport.py | 243 ++++++++- work/tests/test_meshcore_conn_type.py | 1 + work/tests/test_meshcore_dm_delivery.py | 1 + work/tests/test_meshcore_telemetry.py | 493 ++++++++++++++++++ 9 files changed, 1218 insertions(+), 29 deletions(-) create mode 100644 work/tests/test_meshcore_telemetry.py diff --git a/work/dashboard-frontend/src/lib/api.ts b/work/dashboard-frontend/src/lib/api.ts index 74e6aa6..059c228 100644 --- a/work/dashboard-frontend/src/lib/api.ts +++ b/work/dashboard-frontend/src/lib/api.ts @@ -598,7 +598,7 @@ export async function getMeshcoreChannels(): Promise { 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 { 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 { + return fetchJson('/api/config/connection') +} + +export async function fetchMeshcoreTelemetry(): Promise { + return fetchJson('/api/meshcore/telemetry') +} + +export async function pollMeshcoreContact(contact: string): Promise { + 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 diff --git a/work/dashboard-frontend/src/pages/MeshCoreContacts.tsx b/work/dashboard-frontend/src/pages/MeshCoreContacts.tsx index f9005d0..90373b9 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreContacts.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreContacts.tsx @@ -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 = { - 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 = { + 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 [ + + {f.label} {v.toFixed(f.digits)} + {f.unit} + , + ] + }) + return ( +
+ {chips.length > 0 ? ( + chips + ) : ( + Telemetry received (no standard sensor fields) + )} + polled {polledLabel} +
+ ) +} + export default function MeshCoreContacts() { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [connectionConfig, setConnectionConfig] = useState(null) + const [telemetry, setTelemetry] = useState(null) + + // Per-row transient UI state. + const [savingId, setSavingId] = useState(null) + const [savedId, setSavedId] = useState(null) + const [pollingId, setPollingId] = useState(null) + const [pollResults, setPollResults] = useState>({}) + const [saveError, setSaveError] = useState(null) + + // Interval control (minutes, derived from meshcore_telemetry_interval_seconds). + const [intervalMinutes, setIntervalMinutes] = useState(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 (
{/* Header */} @@ -95,11 +327,46 @@ export default function MeshCoreContacts() {

MeshCore Contacts

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

+ {/* Auto-poll interval control */} + {rosterActive && connectionConfig && ( +
+
+ + setIntervalMinutes(Number(e.target.value))} + className="w-20 px-2 py-1 text-sm bg-[#0a0e17] border border-[#1e2a3a] rounded text-slate-100" + /> + minutes + +
+

+ Polls only the nodes you select below. Keep this list small — telemetry uses mesh + airtime. Minimum {MIN_INTERVAL_MINUTES} minutes. +

+
+ )} + + {saveError && ( +
+ {saveError} +
+ )} + {loading ? (
Loading...
@@ -131,30 +398,110 @@ export default function MeshCoreContacts() { Last heard Position Pubkey + Auto-poll + - {(data?.contacts ?? []).map((c) => ( - - {contactName(c)} - - - - {relativeTime(c.last_advert)} - {position(c)} - - {shortPubkey(c.pubkey)} - - - ))} + {(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 ( + + + {contactName(c)} + + + + {relativeTime(c.last_advert)} + {position(c)} + + {shortPubkey(c.pubkey)} + + + + + + + + + {hasReadout && ( + + + {readoutData ? ( + + ) : ( + + no telemetry + {pollResult?.detail ? ` — ${pollResult.detail}` : ''} + + )} + + + )} + + ) + })}
)} - -

- Telemetry auto-poll is coming in the next pass. -

) } diff --git a/work/meshai/config.py b/work/meshai/config.py index 5fe4f3d..01f1a1e 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -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( diff --git a/work/meshai/dashboard/api/mesh_send_routes.py b/work/meshai/dashboard/api/mesh_send_routes.py index c51d805..aa7a34e 100644 --- a/work/meshai/dashboard/api/mesh_send_routes.py +++ b/work/meshai/dashboard/api/mesh_send_routes.py @@ -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": ""}. 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] diff --git a/work/meshai/transport/composite_transport.py b/work/meshai/transport/composite_transport.py index f358dd7..ab5b64f 100644 --- a/work/meshai/transport/composite_transport.py +++ b/work/meshai/transport/composite_transport.py @@ -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) # ------------------------------------------------------------------ diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index c7176f7..bf7a3aa 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -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_`` 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": }``. + The numeric ``type`` id is mapped to a field name via _LPP_ID_TO_FIELD; + unknown ids become ``lpp_``. 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) diff --git a/work/tests/test_meshcore_conn_type.py b/work/tests/test_meshcore_conn_type.py index e429dee..9eda7dc 100644 --- a/work/tests/test_meshcore_conn_type.py +++ b/work/tests/test_meshcore_conn_type.py @@ -28,6 +28,7 @@ def _build_recording_meshcore(): DISCONNECTED = "DISCONNECTED" CONNECTED = "CONNECTED" ACK = "ACK" + NEW_CONTACT = "NEW_CONTACT" mod.EventType = EventType diff --git a/work/tests/test_meshcore_dm_delivery.py b/work/tests/test_meshcore_dm_delivery.py index 7dfcb3a..3b4304a 100644 --- a/work/tests/test_meshcore_dm_delivery.py +++ b/work/tests/test_meshcore_dm_delivery.py @@ -50,6 +50,7 @@ def _ensure_fake_meshcore(): DISCONNECTED = "DISCONNECTED" CONNECTED = "CONNECTED" ACK = "ACK" + NEW_CONTACT = "NEW_CONTACT" mod.EventType = EventType diff --git a/work/tests/test_meshcore_telemetry.py b/work/tests/test_meshcore_telemetry.py new file mode 100644 index 0000000..6e6f48a --- /dev/null +++ b/work/tests/test_meshcore_telemetry.py @@ -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_ + 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