// Client for the sensor API (SENSORS.md): what Ham Lab's own widgets are built on, and // the same thing any outside consumer uses. import { useEffect, useRef, useState } from 'react' import { apiPath } from './api' export interface Sensor { id: string; name: string; kind: string; unit?: string; value: number | boolean | string; t: number min?: number; max?: number; warn?: number; crit?: number; labels?: Record } export type Shape = string | Shape[] | { [k: string]: Shape } // Where to read from. Inside the console that's the current machine with the session // cookie; an embedded widget names a machine and carries a read-only token. export interface Source { machine?: string | null; token?: string } function url(src: Source | undefined, path: string, params: Record): string { const base = src?.machine !== undefined ? (src.machine ? `/api/nodes/${src.machine}${path.slice(4)}` : path) : apiPath(path) const q = new URLSearchParams(params) if (src?.token) q.set('token', src.token) const s = q.toString() return s ? `${base}?${s}` : base } export async function fetchCatalog(src?: Source): Promise { const r = await fetch(url(src, '/api/sensors', {}), { credentials: 'same-origin' }) if (!r.ok) throw new Error(r.status === 401 ? 'not authorised' : r.statusText) return (await r.json()).sensors } export async function fetchHistory(id: string, src?: Source): Promise<{ t: number[]; v: number[] } | null> { const r = await fetch(url(src, `/api/sensors/${encodeURIComponent(id)}/history`, {}), { credentials: 'same-origin' }) return r.ok ? r.json() : null } export async function fetchHistories(ids: string, src?: Source): Promise<{ id: string; unit?: string; t: number[]; v: number[] }[]> { const r = await fetch(url(src, '/api/sensors/history', { ids }), { credentials: 'same-origin' }) if (!r.ok) throw new Error(r.statusText) return (await r.json()).series } export interface StreamOpts { src?: Source; everyMs?: number; full?: boolean } // useSensorStream keeps a shaped query answered: the object comes back filled (with whole // sensors unless full is false) and is replaced on every sample. One stream serves any // number of widgets. It closes while the page is hidden — a phone in a pocket shouldn't // hold a stream open — and `resumed` counts the times it came back, so a caller that // keeps history knows there is a gap to refill. export function useSensorStream(shape: Shape | null, opts: StreamOpts = {}): { data: T | null; error: string; resumed: number } { const [data, setData] = useState(null) const [error, setError] = useState('') const [resumed, setResumed] = useState(0) const [visible, setVisible] = useState(() => document.visibilityState === 'visible') const key = shape === null ? '' : JSON.stringify(shape) const { src, everyMs, full = true } = opts const srcKey = `${src?.machine ?? '·'}|${src?.token ?? ''}|${everyMs ?? ''}|${full}` const srcRef = useRef(src); srcRef.current = src useEffect(() => { const onVis = () => { const v = document.visibilityState === 'visible'; setVisible(v); if (v) setResumed((n) => n + 1) } document.addEventListener('visibilitychange', onVis) return () => document.removeEventListener('visibilitychange', onVis) }, []) useEffect(() => { if (!key) { setData(null); return } if (!visible) return const params: Record = { q: key } if (full) params.full = '1' if (everyMs) params.every = String(everyMs) const es = new EventSource(url(srcRef.current, '/api/sensors/stream', params)) es.onmessage = (e) => { try { setData(JSON.parse(e.data)); setError('') } catch { /* skip a bad frame */ } } es.onerror = () => { setError('reconnecting…') // EventSource retries by itself, but never says why it failed — so ask once, plainly fetch(url(srcRef.current, '/api/sensors/query', { q: key }), { credentials: 'same-origin' }).then(async (r) => { if (r.ok) return const why = (await r.json().catch(() => ({}))).error setError(r.status === 401 ? 'the token was rejected' : r.status === 429 ? 'too many live streams are open' : r.status === 404 ? 'no such machine' : r.status === 502 ? 'that machine is unreachable' : why || `the API answered ${r.status}`) }).catch(() => setError("can't reach Ham Lab")) } return () => es.close() }, [key, srcKey, visible]) // eslint-disable-line react-hooks/exhaustive-deps return { data, error, resumed } } // ---- formatting, by unit ---- const BYTES = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'] function scaled(v: number): string { let i = 0 while (v >= 1024 && i < BYTES.length - 1) { v /= 1024; i++ } return `${v >= 100 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${BYTES[i]}` } function duration(s: number): string { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60) return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m` : `${Math.round(s)}s` } export function fmtValue(v: Sensor['value'] | null | undefined, unit?: string): string { if (v === null || v === undefined) return '—' if (typeof v === 'boolean') return v ? 'yes' : 'no' if (typeof v === 'string') return v switch (unit) { case 'B': return scaled(v) case 'B/s': return scaled(v) + '/s' case 's': return duration(v) case 'min': return duration(v * 60) case '%': return `${v.toFixed(v < 10 ? 1 : 0)}%` case '°C': return `${v.toFixed(0)}°C` case 'rpm': return `${Math.round(v)} rpm` case undefined: case '': return String(Math.round(v * 100) / 100) default: return `${Math.round(v * 10) / 10} ${unit}` } } // How full the scale is, 0–1, when the sensor has one. Temperatures use the hardware's // own critical threshold as the top of the scale. export function fraction(s: Sensor): number | null { if (typeof s.value !== 'number') return null const max = s.max ?? s.crit ?? s.warn, min = s.min ?? 0 if (max === undefined || max <= min) return null return Math.min(1, Math.max(0, (s.value - min) / (max - min))) } export function severity(s: Sensor): '' | 'warn' | 'bad' { if (typeof s.value !== 'number') return '' if (s.crit !== undefined && s.value >= s.crit) return 'bad' if (s.warn !== undefined && s.value >= s.warn) return 'warn' if (s.kind === 'usage' && s.id !== 'ups.battery') return s.value >= 95 ? 'bad' : s.value >= 85 ? 'warn' : '' return '' }