feat(frontend): device pairing UI + live telemetry dashboard (Workstream H)
Adds /devices — pair an ESP32 sensor node (POST /api/device), reveal its
raw pairing token exactly once with a hard-to-miss "cannot be shown again"
warning (styled like a real API-key-reveal UI), then a live dashboard
subscribing to /ws/device-feed: the initial `devices` frame seeds paired
devices, and `reading` frames update one row per distinct sensor_type in
place. sensor_type/value/unit are rendered fully generically per the
contract (free-form, open-ended) — an unrecognized sensor_type renders
safely with no special-casing.
- frontend/src/lib/deviceFeed.ts: reconnecting WS client for
/ws/device-feed, mirroring VeilSocket's backoff shape (receive-only, no
outbox needed).
- frontend/src/pages/DevicesPage.{tsx,css}: pairing form + one-time token
reveal + live device-card grid. Leans into "hacker" terminal styling
(monospace readouts, terminal device cards) over the app's usual gothic
chrome, per the design spec, while keeping the existing dark/violet
palette tokens from App.css.
- Route + nav link wired into App.tsx / SeancePage.tsx.
- i18n: new `devices.*` / `nav.devices` keys in en.json + es.json; added a
coverage-check.mjs domain rule for the dynamic connection-state key,
mirroring the existing `seance.connection.` rule.
Tests: deviceFeed.test.ts (backoff/reconnect/frame delivery) and
DevicesPage.test.tsx (empty state, name validation, one-time token reveal
and dismissal, live frame updates in place without duplicating rows,
multi-device/multi-sensor rendering, and a mocked unrecognized sensor_type
that must not crash). Full suite: 154 passed (137 pre-existing + 17 new).
`npx tsc -b` and `npm run build` both clean.
Assumption (undocumented in spec): POST /api/device's JSON response shape
is inferred as `{id, name, token, last_seen_at}` since the Contract section
only describes the endpoint in prose. GET /api/device is intentionally not
called — the live dashboard is fully seeded by /ws/device-feed's initial
`devices` frame per the contract, so it's redundant for this page's scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
157
frontend/src/lib/deviceFeed.test.ts
Normal file
157
frontend/src/lib/deviceFeed.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DeviceFeedSocket } from './deviceFeed'
|
||||
import type { DeviceFeedConnectionState, DeviceFeedFrame } from './deviceFeed'
|
||||
|
||||
const URL = 'ws://quantumancy.test/ws/device-feed'
|
||||
|
||||
/** Minimal stand-in for the DOM WebSocket, driven manually by the tests. */
|
||||
class FakeWebSocket {
|
||||
readyState = 0 // CONNECTING
|
||||
onopen: (() => void) | null = null
|
||||
onmessage: ((ev: { data: unknown }) => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
|
||||
constructor(readonly url: string) {}
|
||||
|
||||
close(): void {
|
||||
this.readyState = 3 // CLOSED
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
// ---- test drives ----
|
||||
|
||||
serverOpen(): void {
|
||||
this.readyState = 1 // WebSocket.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
|
||||
serverSend(data: unknown): void {
|
||||
this.onmessage?.({ data })
|
||||
}
|
||||
|
||||
serverClose(): void {
|
||||
this.readyState = 3
|
||||
this.onclose?.()
|
||||
}
|
||||
}
|
||||
|
||||
function makeHarness(opts: { baseBackoffMs?: number; maxBackoffMs?: number } = {}) {
|
||||
const sockets: FakeWebSocket[] = []
|
||||
const socket = new DeviceFeedSocket({
|
||||
url: URL,
|
||||
baseBackoffMs: opts.baseBackoffMs ?? 100,
|
||||
maxBackoffMs: opts.maxBackoffMs ?? 1000,
|
||||
socketFactory: (url: string): WebSocket => {
|
||||
const fake = new FakeWebSocket(url)
|
||||
sockets.push(fake)
|
||||
return fake as unknown as WebSocket
|
||||
},
|
||||
})
|
||||
return { socket, sockets }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('DeviceFeedSocket', () => {
|
||||
it('starts closed and transitions through connecting -> open', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
const states: DeviceFeedConnectionState[] = []
|
||||
socket.onState((s) => states.push(s))
|
||||
expect(socket.state).toBe('closed')
|
||||
|
||||
socket.connect()
|
||||
expect(socket.state).toBe('connecting')
|
||||
|
||||
sockets[0].serverOpen()
|
||||
expect(socket.state).toBe('open')
|
||||
expect(states).toEqual(['connecting', 'open'])
|
||||
})
|
||||
|
||||
it('delivers parsed frames to handlers', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
const frames: DeviceFeedFrame[] = []
|
||||
socket.onFrame((f) => frames.push(f))
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
|
||||
sockets[0].serverSend(
|
||||
JSON.stringify({
|
||||
type: 'devices',
|
||||
devices: [{ id: 'd1', name: 'attic-node', last_seen_at: null }],
|
||||
}),
|
||||
)
|
||||
sockets[0].serverSend(
|
||||
JSON.stringify({
|
||||
type: 'reading',
|
||||
device_id: 'd1',
|
||||
sensor_type: 'temperature',
|
||||
value: 21.4,
|
||||
unit: 'c',
|
||||
metadata: {},
|
||||
at: '2026-07-23T00:00:00Z',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(frames).toHaveLength(2)
|
||||
expect(frames[0]).toMatchObject({ type: 'devices' })
|
||||
expect(frames[1]).toMatchObject({ type: 'reading', sensor_type: 'temperature' })
|
||||
})
|
||||
|
||||
it('ignores malformed JSON without throwing', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
const frames: DeviceFeedFrame[] = []
|
||||
socket.onFrame((f) => frames.push(f))
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
|
||||
expect(() => sockets[0].serverSend('{not json')).not.toThrow()
|
||||
expect(frames).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reconnects with backoff after an unexpected close, and delivers frames on the new socket', () => {
|
||||
const { socket, sockets } = makeHarness({ baseBackoffMs: 100, maxBackoffMs: 1000 })
|
||||
const states: DeviceFeedConnectionState[] = []
|
||||
socket.onState((s) => states.push(s))
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
|
||||
sockets[0].serverClose()
|
||||
expect(socket.state).toBe('unstable')
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(sockets).toHaveLength(2)
|
||||
sockets[1].serverOpen()
|
||||
expect(socket.state).toBe('open')
|
||||
expect(states).toEqual(['connecting', 'open', 'unstable', 'connecting', 'open'])
|
||||
})
|
||||
|
||||
it('does not reconnect after a deliberate close()', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
socket.close()
|
||||
expect(socket.state).toBe('closed')
|
||||
|
||||
vi.advanceTimersByTime(5000)
|
||||
expect(sockets).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('unsubscribes handlers returned by onFrame/onState', () => {
|
||||
const { socket, sockets } = makeHarness()
|
||||
const frames: DeviceFeedFrame[] = []
|
||||
const unsub = socket.onFrame((f) => frames.push(f))
|
||||
socket.connect()
|
||||
sockets[0].serverOpen()
|
||||
unsub()
|
||||
|
||||
sockets[0].serverSend(JSON.stringify({ type: 'devices', devices: [] }))
|
||||
expect(frames).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
156
frontend/src/lib/deviceFeed.ts
Normal file
156
frontend/src/lib/deviceFeed.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// Reconnecting WebSocket client for /ws/device-feed — the live instrument
|
||||
// panel's data source (paired-device list + live sensor readings).
|
||||
// Structurally mirrors VeilSocket (./ws.ts): same backoff/reconnect shape,
|
||||
// but this feed is receive-only (the dashboard never sends client frames),
|
||||
// so there's no outbox to flush.
|
||||
|
||||
export type DeviceSummary = {
|
||||
id: string
|
||||
name: string
|
||||
last_seen_at: string | null
|
||||
}
|
||||
|
||||
/** `value` is intentionally untyped: sensor_type is free-form by design, so
|
||||
* a reading's value/unit shape is whatever the paired hardware sent — the
|
||||
* frontend must render it generically rather than assume a shape. */
|
||||
export type DeviceReading = {
|
||||
device_id: string
|
||||
sensor_type: string
|
||||
value: unknown
|
||||
unit: string
|
||||
metadata: Record<string, unknown>
|
||||
at: string
|
||||
}
|
||||
|
||||
export type DeviceFeedFrame =
|
||||
| { type: 'devices'; devices: DeviceSummary[] }
|
||||
| ({ type: 'reading' } & DeviceReading)
|
||||
|
||||
export type DeviceFeedConnectionState = 'connecting' | 'open' | 'unstable' | 'closed'
|
||||
|
||||
type FrameHandler = (frame: DeviceFeedFrame) => void
|
||||
type StateHandler = (state: DeviceFeedConnectionState) => void
|
||||
|
||||
export type DeviceFeedSocketOptions = {
|
||||
url?: string
|
||||
/** Base backoff delay (ms); doubles each retry up to maxBackoffMs. */
|
||||
baseBackoffMs?: number
|
||||
maxBackoffMs?: number
|
||||
/** Injectable WebSocket constructor (tests). */
|
||||
socketFactory?: (url: string) => WebSocket
|
||||
}
|
||||
|
||||
export function defaultDeviceFeedUrl(): string {
|
||||
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://'
|
||||
return `${proto}${location.host}/ws/device-feed`
|
||||
}
|
||||
|
||||
export class DeviceFeedSocket {
|
||||
private ws: WebSocket | null = null
|
||||
private frameHandlers = new Set<FrameHandler>()
|
||||
private stateHandlers = new Set<StateHandler>()
|
||||
private readonly url: string
|
||||
private readonly baseBackoffMs: number
|
||||
private readonly maxBackoffMs: number
|
||||
private readonly socketFactory: (url: string) => WebSocket
|
||||
private attempts = 0
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private deliberatelyClosed = false
|
||||
private _state: DeviceFeedConnectionState = 'closed'
|
||||
|
||||
constructor(opts: DeviceFeedSocketOptions = {}) {
|
||||
this.url = opts.url ?? defaultDeviceFeedUrl()
|
||||
this.baseBackoffMs = opts.baseBackoffMs ?? 800
|
||||
this.maxBackoffMs = opts.maxBackoffMs ?? 15000
|
||||
this.socketFactory = opts.socketFactory ?? ((url: string) => new WebSocket(url))
|
||||
}
|
||||
|
||||
get state(): DeviceFeedConnectionState {
|
||||
return this._state
|
||||
}
|
||||
|
||||
onFrame(handler: FrameHandler): () => void {
|
||||
this.frameHandlers.add(handler)
|
||||
return () => this.frameHandlers.delete(handler)
|
||||
}
|
||||
|
||||
onState(handler: StateHandler): () => void {
|
||||
this.stateHandlers.add(handler)
|
||||
return () => this.stateHandlers.delete(handler)
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.deliberatelyClosed = false
|
||||
this.openSocket()
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.deliberatelyClosed = true
|
||||
this.clearTimer()
|
||||
this.ws?.close()
|
||||
this.ws = null
|
||||
this.setState('closed')
|
||||
}
|
||||
|
||||
private setState(state: DeviceFeedConnectionState): void {
|
||||
if (this._state === state) return
|
||||
this._state = state
|
||||
this.stateHandlers.forEach((h) => h(state))
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private openSocket(): void {
|
||||
this.clearTimer()
|
||||
this.setState('connecting')
|
||||
let ws: WebSocket
|
||||
try {
|
||||
ws = this.socketFactory(this.url)
|
||||
} catch {
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
this.ws = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
this.attempts = 0
|
||||
this.setState('open')
|
||||
}
|
||||
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
if (typeof ev.data !== 'string') return
|
||||
let frame: DeviceFeedFrame
|
||||
try {
|
||||
frame = JSON.parse(ev.data) as DeviceFeedFrame
|
||||
} catch {
|
||||
return // ignore malformed frames
|
||||
}
|
||||
this.frameHandlers.forEach((h) => h(frame))
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
this.clearTimer()
|
||||
this.ws = null
|
||||
if (!this.deliberatelyClosed) this.scheduleReconnect()
|
||||
else this.setState('closed')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose follows onerror; nothing extra to do here.
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.setState('unstable')
|
||||
const delay = Math.min(this.maxBackoffMs, this.baseBackoffMs * 2 ** this.attempts)
|
||||
this.attempts++
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
if (!this.deliberatelyClosed) this.openSocket()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user