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