// The Device Bay — pair ESP32 sensor nodes to your account and watch their // telemetry live. The most overtly "hacker" surface in the app: this reads // as a real instrument panel, not an occult one — terminal readouts, raw // device tokens, packet-by-packet live updates. // // Pairing (POST /api/device) follows the same one-time-secret convention as // a cloud API-key reveal: the raw token is shown exactly once, with a // hard-to-miss warning, and is never retrievable again once dismissed — // losing it means re-pairing the device (see the design spec's Contract // section: the backend never stores or returns the raw token again). // // The live dashboard subscribes to /ws/device-feed. On connect the server // sends the full device list (`{"type":"devices", ...}`); after that, // `{"type":"reading", ...}` frames update each device's per-sensor_type row // in place. sensor_type is free-form by design (the whole point of the // hardware line, per the spec), so rendering is fully generic — an // unrecognized sensor_type renders its raw value + unit with no // special-casing, and can never crash the page. // // One exception carved out of that genericity: `temperature` and // `pressure` get the Cold Spot Detector / Atmospheric Disturbance Index // treatment (../lib/coldSpot.ts + ../components/ColdSpotPanel.tsx) — // folklore's two most iconic paranormal markers, a sudden cold spot and a // rapid barometric swing. This page owns the rolling-baseline state per // device (mirrors GhostLog.tsx owning tell history for computeEvilMeter) // and hands the *result* down to ColdSpotPanel, a pure presentational // component. import { useCallback, useEffect, useState } from 'react' import type { FormEvent } from 'react' import { Link, Navigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useAuth } from '../state/auth' import { DeviceFeedSocket } from '../lib/deviceFeed' import type { DeviceFeedConnectionState, DeviceFeedFrame, DeviceSummary } from '../lib/deviceFeed' import { applyPressureReading, applyTemperatureReading, initPressureBaseline, initTemperatureBaseline, pushSample, } from '../lib/coldSpot' import type { PressureBaselineState, TemperatureBaselineState } from '../lib/coldSpot' import { ColdSpotPanel } from '../components/ColdSpotPanel' import type { PressureReadout, TemperatureReadout } from '../components/ColdSpotPanel' import './DevicesPage.css' type Reading = { value: unknown; unit: string; at: string } // Cold Spot Detector / Atmospheric Disturbance Index — rolling-baseline // state for this device's temperature/pressure readings (lib/coldSpot.ts), // plus the last computed readout for each and a bounded temperature // sample history for the sparkline. Threaded through DeviceRow the same // way `readings` is: plain immutable state replaced on each update, no // mutation. type AtmosphereState = { tempBaseline: TemperatureBaselineState pressureBaseline: PressureBaselineState temp: TemperatureReadout | null pressure: PressureReadout | null tempSparkline: number[] } function initAtmosphere(): AtmosphereState { return { tempBaseline: initTemperatureBaseline(), pressureBaseline: initPressureBaseline(), temp: null, pressure: null, tempSparkline: [], } } /** * Folds one `reading` frame into a device's atmosphere state. A no-op for * any sensor_type other than temperature/pressure, and for a * temperature/pressure reading whose value isn't a finite number (a * malformed reading falls back to the existing generic sensor-row * rendering instead of silently vanishing — see the `hideFromGeneric` * logic in the dashboard render below). */ function applyAtmosphereReading( atmosphere: AtmosphereState, frame: { sensor_type: string; value: unknown; unit: string; at: string }, ): AtmosphereState { if (typeof frame.value !== 'number' || !Number.isFinite(frame.value)) return atmosphere const atMs = parseAtMs(frame.at) if (frame.sensor_type === 'temperature') { const result = applyTemperatureReading(atmosphere.tempBaseline, frame.value, atMs) return { ...atmosphere, tempBaseline: result.state, temp: { value: frame.value, unit: frame.unit, deviation: result.deviation, isColdSpot: result.isColdSpot, severity: result.severity, }, tempSparkline: pushSample(atmosphere.tempSparkline, frame.value), } } if (frame.sensor_type === 'pressure') { const result = applyPressureReading(atmosphere.pressureBaseline, frame.value, atMs) return { ...atmosphere, pressureBaseline: result.state, pressure: { value: frame.value, unit: frame.unit, deviation: result.deviation, isPressureAnomaly: result.isPressureAnomaly, severity: result.severity, direction: result.direction, }, } } return atmosphere } type DeviceRow = DeviceSummary & { readings: Record; atmosphere: AtmosphereState } type PairState = 'idle' | 'pending' | 'error' type PairResponse = { id: string name: string token: string last_seen_at?: string | null } /** * Render a raw sensor value generically. sensor_type/value/unit are * open-ended by contract — this must never assume a shape, and must never * throw regardless of what an unrecognized sensor sends. */ export function formatSensorValue(value: unknown): string { if (typeof value === 'number') { if (!Number.isFinite(value)) return String(value) // Trim float noise without hardcoding a fixed decimal count per unit. return String(Number(value.toFixed(3))) } if (typeof value === 'boolean') return value ? '1' : '0' if (value === null || value === undefined) return '—' if (Array.isArray(value)) return `[${value.map(formatSensorValue).join(', ')}]` if (typeof value === 'object') { try { return JSON.stringify(value) } catch { return '[object]' } } return String(value) } function formatTimestamp(iso: string, locale: string): string { const date = new Date(iso) if (Number.isNaN(date.getTime())) return iso return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }).format(date) } /** Epoch ms for a reading's `at` timestamp, defensively — the rolling * baseline needs a real clock value to weight its EMA by elapsed time * (lib/coldSpot.ts), and hardware/network hiccups can in principle deliver * a malformed timestamp. Falling back to "now" keeps the baseline moving * forward sanely instead of propagating NaN into device state. */ function parseAtMs(iso: string): number { const ms = Date.parse(iso) return Number.isFinite(ms) ? ms : Date.now() } export function DevicesPage() { const { user, checking } = useAuth() const { t } = useTranslation() if (checking) { return (

{t('common.loading')}

) } if (!user) return return } export default DevicesPage // ---- the device bay itself ---- function DevicesSession() { const { t, i18n } = useTranslation() // ---- pairing ---- const [name, setName] = useState('') const [pairState, setPairState] = useState('idle') const [nameError, setNameError] = useState(null) const [revealToken, setRevealToken] = useState(null) const [copied, setCopied] = useState(false) // ---- live dashboard ---- const [devices, setDevices] = useState>({}) const [order, setOrder] = useState([]) const [connection, setConnection] = useState('connecting') const upsertPairedDevice = useCallback((summary: DeviceSummary) => { setDevices((prev) => ({ ...prev, [summary.id]: { ...summary, readings: prev[summary.id]?.readings ?? {}, atmosphere: prev[summary.id]?.atmosphere ?? initAtmosphere(), }, })) setOrder((prev) => (prev.includes(summary.id) ? prev : [...prev, summary.id])) }, []) const submitPair = (e: FormEvent) => { e.preventDefault() if (pairState === 'pending') return const trimmed = name.trim() if (!trimmed) { setNameError(t('devices.pair.nameRequired', 'name the device before pairing')) return } setNameError(null) setPairState('pending') fetch('/api/device', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: trimmed }), }) .then(async (res) => { if (!res.ok) throw new Error(`pairing failed: ${res.status}`) return (await res.json()) as PairResponse }) .then((device) => { setPairState('idle') setName('') setCopied(false) setRevealToken(device.token) upsertPairedDevice({ id: device.id, name: device.name, last_seen_at: device.last_seen_at ?? null, }) }) .catch(() => setPairState('error')) } const copyToken = () => { if (!revealToken) return const clipboard = navigator.clipboard if (!clipboard?.writeText) return clipboard.writeText(revealToken).then( () => setCopied(true), () => undefined, ) } const dismissToken = () => { setRevealToken(null) setCopied(false) } useEffect(() => { const socket = new DeviceFeedSocket() const offFrame = socket.onFrame((frame: DeviceFeedFrame) => { if (frame.type === 'devices') { setDevices((prev) => { const next: Record = {} for (const d of frame.devices) { next[d.id] = { ...d, readings: prev[d.id]?.readings ?? {}, atmosphere: prev[d.id]?.atmosphere ?? initAtmosphere(), } } return next }) setOrder(frame.devices.map((d) => d.id)) return } // frame.type === 'reading' setDevices((prev) => { const existing = prev[frame.device_id] const base: DeviceRow = existing ?? { id: frame.device_id, name: frame.device_id, last_seen_at: null, readings: {}, atmosphere: initAtmosphere(), } return { ...prev, [frame.device_id]: { ...base, last_seen_at: frame.at, readings: { ...base.readings, [frame.sensor_type]: { value: frame.value, unit: frame.unit, at: frame.at }, }, atmosphere: applyAtmosphereReading(base.atmosphere, frame), }, } }) setOrder((prev) => (prev.includes(frame.device_id) ? prev : [...prev, frame.device_id])) }) const offState = socket.onState(setConnection) socket.connect() return () => { offFrame() offState() socket.close() } }, []) return (
) }