Real-time anomaly visualization for temperature/pressure readings on the device-feed dashboard, folklore's two most iconic paranormal markers: sudden cold spots and rapid barometric swings. - lib/coldSpot.ts: pure, directly-testable rolling-baseline tracker (time-aware EMA, since hardware doesn't report on a fixed schedule), cold-spot and pressure-anomaly classifiers, and a composite Atmospheric Disturbance Index that rewards correlated anomalies (a lone signal caps at 50/100; only both deviating together can reach 100) — modeled on evilMeter.ts's threaded-state pattern. - components/ColdSpotPanel.tsx/.css: frost treatment + sparkline for temperature, ripple treatment for pressure, and a crescent-arc composite gauge (GhostLog's evil-meter gauge as the visual family reference) that only appears once a device has reported both sensors. - DevicesPage.tsx: owns per-device baseline state, feeds it from `reading` frames, falls back to the existing generic row for any non-numeric temperature/pressure value. 26 new coldSpot.test.ts cases (warm-up, genuine vs. fluctuation, correlated-vs-solo index, gappy/out-of-order data) and 7 new DevicesPage integration tests. Full suite: 302/302 passing, tsc clean, i18n coverage clean (en/es). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
485 lines
18 KiB
TypeScript
485 lines
18 KiB
TypeScript
// 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<string, Reading>; 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 (
|
|
<div className="devices-loading" role="status">
|
|
<p className="loading-text">{t('common.loading')}</p>
|
|
</div>
|
|
)
|
|
}
|
|
if (!user) return <Navigate to="/enter" replace />
|
|
|
|
return <DevicesSession />
|
|
}
|
|
|
|
export default DevicesPage
|
|
|
|
// ---- the device bay itself ----
|
|
|
|
function DevicesSession() {
|
|
const { t, i18n } = useTranslation()
|
|
|
|
// ---- pairing ----
|
|
const [name, setName] = useState('')
|
|
const [pairState, setPairState] = useState<PairState>('idle')
|
|
const [nameError, setNameError] = useState<string | null>(null)
|
|
const [revealToken, setRevealToken] = useState<string | null>(null)
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
// ---- live dashboard ----
|
|
const [devices, setDevices] = useState<Record<string, DeviceRow>>({})
|
|
const [order, setOrder] = useState<string[]>([])
|
|
const [connection, setConnection] = useState<DeviceFeedConnectionState>('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<HTMLFormElement>) => {
|
|
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<string, DeviceRow> = {}
|
|
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 (
|
|
<div className="devices-page">
|
|
<div className="devices-overlay" aria-hidden="true" />
|
|
|
|
<header className="devices-header">
|
|
<Link to="/seance" className="devices-back">
|
|
{t('devices.back', '← the threshold')}
|
|
</Link>
|
|
<h1 className="devices-title">{t('devices.title', 'DEVICE BAY')}</h1>
|
|
<p className="devices-subtitle">
|
|
{t('devices.subtitle', 'pair hardware to your account and watch its telemetry live')}
|
|
</p>
|
|
</header>
|
|
|
|
<section className="devices-pair" aria-label={t('devices.pair.title', 'PAIR A NEW DEVICE')}>
|
|
<h2 className="devices-section-title">
|
|
{t('devices.pair.title', 'PAIR A NEW DEVICE')}
|
|
</h2>
|
|
<form className="pair-form" onSubmit={submitPair}>
|
|
<label className="visually-hidden" htmlFor="device-name">
|
|
{t('devices.pair.nameLabel', 'device name')}
|
|
</label>
|
|
<input
|
|
id="device-name"
|
|
className="pair-input"
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => {
|
|
setName(e.target.value)
|
|
if (nameError) setNameError(null)
|
|
}}
|
|
placeholder={t('devices.pair.namePlaceholder', 'e.g. attic-node-01')}
|
|
disabled={pairState === 'pending'}
|
|
/>
|
|
<button className="pair-submit" type="submit" disabled={pairState === 'pending'}>
|
|
{pairState === 'pending'
|
|
? t('devices.pair.pending', 'pairing…')
|
|
: t('devices.pair.submit', 'pair device')}
|
|
</button>
|
|
</form>
|
|
{nameError && (
|
|
<p className="pair-message is-error" role="alert">
|
|
{nameError}
|
|
</p>
|
|
)}
|
|
{pairState === 'error' && (
|
|
<p className="pair-message is-error" role="alert">
|
|
{t('devices.pair.error', 'the pairing handshake failed — try again')}
|
|
</p>
|
|
)}
|
|
|
|
{revealToken && (
|
|
<div className="token-reveal" role="alert">
|
|
<p className="token-reveal-warning">
|
|
<span className="token-reveal-icon" aria-hidden="true">
|
|
⚠
|
|
</span>
|
|
{t(
|
|
'devices.token.warning',
|
|
'copy this now — it cannot be shown again. losing it means re-pairing the device.',
|
|
)}
|
|
</p>
|
|
<div className="token-reveal-box">
|
|
<code className="token-reveal-value">{revealToken}</code>
|
|
<button type="button" className="token-copy" onClick={copyToken}>
|
|
{copied ? t('devices.token.copied', 'copied') : t('devices.token.copy', 'copy')}
|
|
</button>
|
|
</div>
|
|
<button type="button" className="token-confirm" onClick={dismissToken}>
|
|
{t('devices.token.confirm', "I've saved it — hide token")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="devices-dashboard" aria-label={t('devices.dashboard.title', 'LIVE FEED')}>
|
|
<div className="dashboard-heading">
|
|
<h2 className="devices-section-title">{t('devices.dashboard.title', 'LIVE FEED')}</h2>
|
|
<span
|
|
className={`feed-dot feed-${connection}`}
|
|
title={t(`devices.dashboard.connection.${connection}`)}
|
|
aria-label={t(`devices.dashboard.connection.${connection}`)}
|
|
/>
|
|
</div>
|
|
|
|
{order.length === 0 ? (
|
|
<p className="devices-state">
|
|
{t('devices.dashboard.empty', 'no devices paired yet — pair one above to see it here')}
|
|
</p>
|
|
) : (
|
|
<div className="device-grid">
|
|
{order.map((id) => {
|
|
const device = devices[id]
|
|
if (!device) return null
|
|
const sensorTypes = Object.keys(device.readings)
|
|
// temperature/pressure get the Cold Spot Detector treatment
|
|
// (ColdSpotPanel below) once they've reported a usable
|
|
// numeric value at least once; a malformed non-numeric
|
|
// reading for either falls back to the generic row instead
|
|
// of vanishing (see applyAtmosphereReading's no-op guard).
|
|
const genericSensorTypes = sensorTypes.filter((sensorType) => {
|
|
if (sensorType === 'temperature' && device.atmosphere.temp) return false
|
|
if (sensorType === 'pressure' && device.atmosphere.pressure) return false
|
|
return true
|
|
})
|
|
const hasAtmosphere = device.atmosphere.temp !== null || device.atmosphere.pressure !== null
|
|
const hasAnyReadout = hasAtmosphere || genericSensorTypes.length > 0
|
|
return (
|
|
<article className="device-card" key={id} data-testid={`device-card-${id}`}>
|
|
<header className="device-card-head">
|
|
<h3 className="device-name">{device.name}</h3>
|
|
<p className="device-lastseen">
|
|
{t('devices.dashboard.lastSeen', 'last seen')}:{' '}
|
|
{device.last_seen_at
|
|
? formatTimestamp(device.last_seen_at, i18n.language)
|
|
: t('devices.dashboard.lastSeenNever', 'never')}
|
|
</p>
|
|
</header>
|
|
{!hasAnyReadout ? (
|
|
<p className="device-noreadings">
|
|
{t('devices.dashboard.noReadings', 'awaiting first reading…')}
|
|
</p>
|
|
) : (
|
|
<>
|
|
{hasAtmosphere && (
|
|
<ColdSpotPanel
|
|
temperature={device.atmosphere.temp}
|
|
pressure={device.atmosphere.pressure}
|
|
tempSparkline={device.atmosphere.tempSparkline}
|
|
/>
|
|
)}
|
|
{genericSensorTypes.length > 0 && (
|
|
<div className="sensor-grid">
|
|
{genericSensorTypes.map((sensorType) => {
|
|
const r = device.readings[sensorType]
|
|
return (
|
|
<div className="sensor-row" key={sensorType}>
|
|
<span className="sensor-type">{sensorType}</span>
|
|
<span className="sensor-value flicker-soft">
|
|
{formatSensorValue(r.value)}{' '}
|
|
<span className="sensor-unit">{r.unit}</span>
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</article>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|