feat: the vessel speaks in the séance — DeviceWhisper panel
Workstream D of the usability wave (#8). A compact live panel in the séance side column, shown only when the seeker has paired hardware: device name, online dot (reading within 60s), latest readings in the terminal readout style, and cold-spot / pressure-anomaly flags from the lib/coldSpot.ts cores. Fetches GET /api/device once on mount; renders nothing on zero devices or fetch failure — the séance never sees an error from this panel. Socket closed on unmount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
148
frontend/src/components/DeviceWhisper.css
Normal file
148
frontend/src/components/DeviceWhisper.css
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/* DeviceWhisper — the vessel speaks in the séance side column. Same fused
|
||||||
|
hacker/witch chrome as RitualPanel: monospace terminal readout inside
|
||||||
|
sigil-lit framing, using SeancePage.css's token palette (bg #07070d ·
|
||||||
|
phosphor #7cffb2 · violet #b26bff · border rgba(124,255,178,0.18)). */
|
||||||
|
|
||||||
|
.device-whisper {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 110% -20%, rgba(124, 255, 178, 0.1), transparent 60%),
|
||||||
|
rgba(10, 10, 18, 0.55);
|
||||||
|
box-shadow: 0 0 18px rgba(124, 255, 178, 0.06) inset;
|
||||||
|
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-whisper-title {
|
||||||
|
margin: 0 0 0.55rem;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
color: rgba(124, 255, 178, 0.85);
|
||||||
|
text-shadow: 0 0 10px rgba(124, 255, 178, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-device + .whisper-device {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
border-top: 1px dashed rgba(124, 255, 178, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-device-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-dot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-dot.is-online {
|
||||||
|
background: #7cffb2;
|
||||||
|
box-shadow: 0 0 8px rgba(124, 255, 178, 0.9);
|
||||||
|
animation: whisperPulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-dot.is-offline {
|
||||||
|
background: rgba(211, 233, 219, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes whisperPulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-device-name {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #d3e9db;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-waiting {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-readings {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-sensor {
|
||||||
|
opacity: 0.55;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-value {
|
||||||
|
color: #7cffb2;
|
||||||
|
text-shadow: 0 0 6px rgba(124, 255, 178, 0.35);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-unit {
|
||||||
|
opacity: 0.55;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-flag {
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-flag-cold {
|
||||||
|
color: #9fd4ff;
|
||||||
|
background: rgba(120, 190, 255, 0.08);
|
||||||
|
border: 1px solid rgba(120, 190, 255, 0.25);
|
||||||
|
text-shadow: 0 0 8px rgba(120, 190, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-flag-pressure {
|
||||||
|
color: #b26bff;
|
||||||
|
background: rgba(178, 107, 255, 0.08);
|
||||||
|
border: 1px solid rgba(178, 107, 255, 0.3);
|
||||||
|
text-shadow: 0 0 8px rgba(178, 107, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Narrow vessels (≤390px): the panel stays a single tight column. */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.device-whisper {
|
||||||
|
padding: 0.55rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-row {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
185
frontend/src/components/DeviceWhisper.test.tsx
Normal file
185
frontend/src/components/DeviceWhisper.test.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import '../i18n'
|
||||||
|
import type { DeviceFeedFrame } from '../lib/deviceFeed'
|
||||||
|
|
||||||
|
// ---- fake DeviceFeedSocket: same stubbing pattern as DevicesPage.test.tsx —
|
||||||
|
// module-mock the socket class so the component's frames come from the test,
|
||||||
|
// no real WebSocket involved. ----
|
||||||
|
|
||||||
|
type FrameHandler = (f: DeviceFeedFrame) => void
|
||||||
|
type StateHandler = (s: string) => void
|
||||||
|
|
||||||
|
class FakeDeviceFeedSocket {
|
||||||
|
frameHandlers: FrameHandler[] = []
|
||||||
|
stateHandlers: StateHandler[] = []
|
||||||
|
closed = false
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
instances.push(this)
|
||||||
|
}
|
||||||
|
onFrame(h: FrameHandler) {
|
||||||
|
this.frameHandlers.push(h)
|
||||||
|
return () => {
|
||||||
|
this.frameHandlers = this.frameHandlers.filter((x) => x !== h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onState(h: StateHandler) {
|
||||||
|
this.stateHandlers.push(h)
|
||||||
|
return () => {
|
||||||
|
this.stateHandlers = this.stateHandlers.filter((x) => x !== h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connect() {
|
||||||
|
this.stateHandlers.forEach((h) => h('open'))
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.closed = true
|
||||||
|
}
|
||||||
|
emit(frame: DeviceFeedFrame) {
|
||||||
|
this.frameHandlers.forEach((h) => h(frame))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let instances: FakeDeviceFeedSocket[] = []
|
||||||
|
|
||||||
|
vi.mock('../lib/deviceFeed', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../lib/deviceFeed')>('../lib/deviceFeed')
|
||||||
|
return { ...actual, DeviceFeedSocket: FakeDeviceFeedSocket }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Imported after the mock above so the module picks up the faked socket.
|
||||||
|
const { DeviceWhisper } = await import('./DeviceWhisper')
|
||||||
|
|
||||||
|
function mockDevicesFetch(response: () => Response | Promise<Response>) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => response()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function devicesResponse(devices: unknown[]): Response {
|
||||||
|
return new Response(JSON.stringify({ devices }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
instances = []
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
function emitReading(
|
||||||
|
socket: FakeDeviceFeedSocket,
|
||||||
|
deviceId: string,
|
||||||
|
sensorType: string,
|
||||||
|
value: unknown,
|
||||||
|
unit: string,
|
||||||
|
at: string,
|
||||||
|
) {
|
||||||
|
act(() => {
|
||||||
|
socket.emit({
|
||||||
|
type: 'reading',
|
||||||
|
device_id: deviceId,
|
||||||
|
sensor_type: sensorType,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
metadata: {},
|
||||||
|
at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DeviceWhisper', () => {
|
||||||
|
it('renders nothing when the user has zero devices, and never opens the feed', async () => {
|
||||||
|
mockDevicesFetch(() => devicesResponse([]))
|
||||||
|
const { container } = render(<DeviceWhisper />)
|
||||||
|
await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalled())
|
||||||
|
// Give the resolved fetch a tick to settle state.
|
||||||
|
await act(async () => {})
|
||||||
|
expect(container.innerHTML).toBe('')
|
||||||
|
expect(instances).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when the devices fetch fails — the séance never sees an error', async () => {
|
||||||
|
mockDevicesFetch(() => new Response('nope', { status: 500 }))
|
||||||
|
const { container } = render(<DeviceWhisper />)
|
||||||
|
await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalled())
|
||||||
|
await act(async () => {})
|
||||||
|
expect(container.innerHTML).toBe('')
|
||||||
|
expect(instances).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the device, renders readings from feed frames, and flags a cold spot', async () => {
|
||||||
|
mockDevicesFetch(() =>
|
||||||
|
devicesResponse([{ id: 'd-1', name: 'attic-node', last_seen_at: null }]),
|
||||||
|
)
|
||||||
|
render(<DeviceWhisper />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(instances).toHaveLength(1))
|
||||||
|
const socket = instances[0]
|
||||||
|
|
||||||
|
expect(screen.getByText('attic-node')).toBeTruthy()
|
||||||
|
// No reading yet → offline dot.
|
||||||
|
expect(screen.getByTestId('whisper-dot-d-1').className).toContain('is-offline')
|
||||||
|
|
||||||
|
const T0 = Date.now()
|
||||||
|
const iso = (ms: number) => new Date(ms).toISOString()
|
||||||
|
|
||||||
|
// Warm the temperature baseline, then drop hard — coldSpot core fires.
|
||||||
|
let t = T0 - 150_000
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
emitReading(socket, 'd-1', 'temperature', 21.0, 'c', iso(t))
|
||||||
|
t += 30_000
|
||||||
|
}
|
||||||
|
expect(screen.getByText('21')).toBeTruthy()
|
||||||
|
expect(screen.queryByTestId('whisper-coldspot-d-1')).toBeNull()
|
||||||
|
|
||||||
|
emitReading(socket, 'd-1', 'temperature', 15.0, 'c', iso(t))
|
||||||
|
emitReading(socket, 'd-1', 'presence', true, 'bool', iso(t))
|
||||||
|
|
||||||
|
expect(screen.getByText('15')).toBeTruthy()
|
||||||
|
expect(screen.getByText('presence')).toBeTruthy()
|
||||||
|
expect(screen.getByTestId('whisper-coldspot-d-1')).toBeTruthy()
|
||||||
|
// The last reading is recent → the dot flips online.
|
||||||
|
expect(screen.getByTestId('whisper-dot-d-1').className).toContain('is-online')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a device whose last reading is older than 60s as offline', async () => {
|
||||||
|
mockDevicesFetch(() =>
|
||||||
|
devicesResponse([{ id: 'd-1', name: 'attic-node', last_seen_at: null }]),
|
||||||
|
)
|
||||||
|
render(<DeviceWhisper />)
|
||||||
|
await waitFor(() => expect(instances).toHaveLength(1))
|
||||||
|
const socket = instances[0]
|
||||||
|
|
||||||
|
// A stale reading (5 minutes ago) must not light the dot.
|
||||||
|
emitReading(
|
||||||
|
socket,
|
||||||
|
'd-1',
|
||||||
|
'temperature',
|
||||||
|
21.0,
|
||||||
|
'c',
|
||||||
|
new Date(Date.now() - 300_000).toISOString(),
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('whisper-dot-d-1').className).toContain('is-offline')
|
||||||
|
|
||||||
|
// A fresh reading flips it online.
|
||||||
|
emitReading(socket, 'd-1', 'temperature', 21.1, 'c', new Date().toISOString())
|
||||||
|
expect(screen.getByTestId('whisper-dot-d-1').className).toContain('is-online')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the socket on unmount', async () => {
|
||||||
|
mockDevicesFetch(() =>
|
||||||
|
devicesResponse([{ id: 'd-1', name: 'attic-node', last_seen_at: null }]),
|
||||||
|
)
|
||||||
|
const { unmount } = render(<DeviceWhisper />)
|
||||||
|
await waitFor(() => expect(instances).toHaveLength(1))
|
||||||
|
unmount()
|
||||||
|
expect(instances[0].closed).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
254
frontend/src/components/DeviceWhisper.tsx
Normal file
254
frontend/src/components/DeviceWhisper.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
// DeviceWhisper — the vessel speaks in the séance. A compact live panel for
|
||||||
|
// the séance side column that surfaces the user's paired hardware (the
|
||||||
|
// Device Bay's ESP32 nodes) without leaving the ritual: device name, an
|
||||||
|
// online dot (a reading within the last minute), the latest readings in the
|
||||||
|
// terminal readout style, and the cold-spot / pressure-anomaly flags from
|
||||||
|
// lib/coldSpot.ts's pure classifiers.
|
||||||
|
//
|
||||||
|
// Contract with the séance: this panel must never error the séance. It
|
||||||
|
// fetches GET /api/device once on mount; if the seeker has no devices, or
|
||||||
|
// the fetch fails for any reason, it renders nothing at all. Only when at
|
||||||
|
// least one device exists does it open a DeviceFeedSocket (lib/deviceFeed),
|
||||||
|
// mirroring DevicesPage's wiring: `devices` frames replace the roster,
|
||||||
|
// `reading` frames update per-sensor rows in place and are threaded through
|
||||||
|
// the coldSpot rolling-baseline cores. The socket is closed on unmount.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { DeviceFeedSocket } from '../lib/deviceFeed'
|
||||||
|
import type { DeviceFeedFrame, DeviceSummary } from '../lib/deviceFeed'
|
||||||
|
import {
|
||||||
|
applyPressureReading,
|
||||||
|
applyTemperatureReading,
|
||||||
|
initPressureBaseline,
|
||||||
|
initTemperatureBaseline,
|
||||||
|
} from '../lib/coldSpot'
|
||||||
|
import type { PressureBaselineState, TemperatureBaselineState } from '../lib/coldSpot'
|
||||||
|
import './DeviceWhisper.css'
|
||||||
|
|
||||||
|
/** A reading counts as "online" if it landed within the last minute. */
|
||||||
|
export const ONLINE_WINDOW_MS = 60_000
|
||||||
|
|
||||||
|
/** How often the online dot re-evaluates its window without a fresh frame
|
||||||
|
* forcing a render — a device that falls silent must visibly go dark. */
|
||||||
|
const ONLINE_TICK_MS = 15_000
|
||||||
|
|
||||||
|
type Reading = { value: unknown; unit: string }
|
||||||
|
|
||||||
|
type WhisperDevice = DeviceSummary & {
|
||||||
|
readings: Record<string, Reading>
|
||||||
|
/** Epoch ms of the newest reading frame (or last_seen_at), null if never. */
|
||||||
|
lastReadingAtMs: number | null
|
||||||
|
tempBaseline: TemperatureBaselineState
|
||||||
|
pressureBaseline: PressureBaselineState
|
||||||
|
isColdSpot: boolean
|
||||||
|
isPressureAnomaly: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWhisperDevice(summary: DeviceSummary): WhisperDevice {
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
readings: {},
|
||||||
|
lastReadingAtMs: summary.last_seen_at ? parseAtMs(summary.last_seen_at) : null,
|
||||||
|
tempBaseline: initTemperatureBaseline(),
|
||||||
|
pressureBaseline: initPressureBaseline(),
|
||||||
|
isColdSpot: false,
|
||||||
|
isPressureAnomaly: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same defensive timestamp parse as DevicesPage: a malformed `at` must not
|
||||||
|
* poison the baseline EMA or the online window. */
|
||||||
|
function parseAtMs(iso: string): number {
|
||||||
|
const ms = Date.parse(iso)
|
||||||
|
return Number.isFinite(ms) ? ms : Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic sensor-value rendering, following DevicesPage's rule that
|
||||||
|
* sensor_type/value/unit are open-ended and must never throw. Compact
|
||||||
|
* variant: objects/arrays collapse to a glyph rather than raw JSON — the
|
||||||
|
* séance readout is a whisper, not a packet dump. */
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (!Number.isFinite(value)) return String(value)
|
||||||
|
return String(Number(value.toFixed(2)))
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') return value ? '1' : '0'
|
||||||
|
if (value === null || value === undefined) return '—'
|
||||||
|
if (typeof value === 'object') return '…'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFrame(
|
||||||
|
device: WhisperDevice,
|
||||||
|
frame: { sensor_type: string; value: unknown; unit: string; at: string },
|
||||||
|
): WhisperDevice {
|
||||||
|
const atMs = parseAtMs(frame.at)
|
||||||
|
let next: WhisperDevice = {
|
||||||
|
...device,
|
||||||
|
lastReadingAtMs: atMs,
|
||||||
|
readings: {
|
||||||
|
...device.readings,
|
||||||
|
[frame.sensor_type]: { value: frame.value, unit: frame.unit },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Non-numeric temperature/pressure stays a generic row (coldSpot's own
|
||||||
|
// no-op guard would ignore it anyway; skipping here keeps state stable).
|
||||||
|
if (typeof frame.value !== 'number' || !Number.isFinite(frame.value)) return next
|
||||||
|
if (frame.sensor_type === 'temperature') {
|
||||||
|
const r = applyTemperatureReading(device.tempBaseline, frame.value, atMs)
|
||||||
|
next = { ...next, tempBaseline: r.state, isColdSpot: r.isColdSpot }
|
||||||
|
} else if (frame.sensor_type === 'pressure') {
|
||||||
|
const r = applyPressureReading(device.pressureBaseline, frame.value, atMs)
|
||||||
|
next = { ...next, pressureBaseline: r.state, isPressureAnomaly: r.isPressureAnomaly }
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceWhisper() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
// null = still asking / no vessel answers (render nothing either way
|
||||||
|
// until a device list with ≥1 entry arrives).
|
||||||
|
const [hasDevices, setHasDevices] = useState<boolean | null>(null)
|
||||||
|
const [devices, setDevices] = useState<Record<string, WhisperDevice>>({})
|
||||||
|
const [order, setOrder] = useState<string[]>([])
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
|
// One fetch on mount decides whether this panel exists at all.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch('/api/device', { credentials: 'include' })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`devices fetch failed: ${res.status}`)
|
||||||
|
return (await res.json()) as { devices?: DeviceSummary[] }
|
||||||
|
})
|
||||||
|
.then((body) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const list = Array.isArray(body.devices) ? body.devices : []
|
||||||
|
if (list.length === 0) {
|
||||||
|
setHasDevices(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next: Record<string, WhisperDevice> = {}
|
||||||
|
for (const d of list) next[d.id] = initWhisperDevice(d)
|
||||||
|
setDevices(next)
|
||||||
|
setOrder(list.map((d) => d.id))
|
||||||
|
setHasDevices(true)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// The veil never scolds: a failed fetch means the panel simply
|
||||||
|
// does not manifest.
|
||||||
|
if (!cancelled) setHasDevices(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Live feed — only once we know the seeker has hardware.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasDevices !== true) return
|
||||||
|
const socket = new DeviceFeedSocket()
|
||||||
|
const offFrame = socket.onFrame((frame: DeviceFeedFrame) => {
|
||||||
|
if (frame.type === 'devices') {
|
||||||
|
setDevices((prev) => {
|
||||||
|
const next: Record<string, WhisperDevice> = {}
|
||||||
|
for (const d of frame.devices) {
|
||||||
|
next[d.id] = prev[d.id]
|
||||||
|
? { ...prev[d.id], name: d.name, last_seen_at: d.last_seen_at }
|
||||||
|
: initWhisperDevice(d)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setOrder(frame.devices.map((d) => d.id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// frame.type === 'reading'
|
||||||
|
setDevices((prev) => {
|
||||||
|
const existing =
|
||||||
|
prev[frame.device_id] ??
|
||||||
|
initWhisperDevice({ id: frame.device_id, name: frame.device_id, last_seen_at: null })
|
||||||
|
return { ...prev, [frame.device_id]: applyFrame(existing, frame) }
|
||||||
|
})
|
||||||
|
setOrder((prev) => (prev.includes(frame.device_id) ? prev : [...prev, frame.device_id]))
|
||||||
|
})
|
||||||
|
socket.connect()
|
||||||
|
return () => {
|
||||||
|
offFrame()
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
}, [hasDevices])
|
||||||
|
|
||||||
|
// The online window must lapse even when no frame arrives to re-render us.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasDevices !== true) return
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), ONLINE_TICK_MS)
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [hasDevices])
|
||||||
|
|
||||||
|
if (hasDevices !== true || order.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="device-whisper" data-testid="device-whisper">
|
||||||
|
<h3 className="device-whisper-title">
|
||||||
|
{t('seance.whisper.title', { defaultValue: 'THE VESSEL WHISPERS' })}
|
||||||
|
</h3>
|
||||||
|
{order.map((id) => {
|
||||||
|
const device = devices[id]
|
||||||
|
if (!device) return null
|
||||||
|
const online =
|
||||||
|
device.lastReadingAtMs !== null && now - device.lastReadingAtMs <= ONLINE_WINDOW_MS
|
||||||
|
const sensorTypes = Object.keys(device.readings)
|
||||||
|
return (
|
||||||
|
<article className="whisper-device" key={id} data-testid={`whisper-device-${id}`}>
|
||||||
|
<header className="whisper-device-head">
|
||||||
|
<span
|
||||||
|
className={`whisper-dot ${online ? 'is-online' : 'is-offline'}`}
|
||||||
|
data-testid={`whisper-dot-${id}`}
|
||||||
|
role="img"
|
||||||
|
aria-label={
|
||||||
|
online
|
||||||
|
? t('seance.whisper.online', { defaultValue: 'the vessel is awake' })
|
||||||
|
: t('seance.whisper.offline', { defaultValue: 'the vessel sleeps' })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="whisper-device-name">{device.name}</span>
|
||||||
|
</header>
|
||||||
|
{sensorTypes.length === 0 ? (
|
||||||
|
<p className="whisper-waiting dim">
|
||||||
|
{t('seance.whisper.waiting', { defaultValue: 'no whisper yet…' })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="whisper-readings">
|
||||||
|
{sensorTypes.map((sensorType) => {
|
||||||
|
const r = device.readings[sensorType]
|
||||||
|
return (
|
||||||
|
<div className="whisper-row" key={sensorType}>
|
||||||
|
<span className="whisper-sensor">{sensorType}</span>
|
||||||
|
<span className="whisper-value flicker-soft">
|
||||||
|
{formatValue(r.value)}{' '}
|
||||||
|
<span className="whisper-unit">{r.unit}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{device.isColdSpot && (
|
||||||
|
<p className="whisper-flag whisper-flag-cold" data-testid={`whisper-coldspot-${id}`} role="status">
|
||||||
|
{t('seance.whisper.coldSpot', { defaultValue: '❄ a cold spot gathers around the vessel' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{device.isPressureAnomaly && (
|
||||||
|
<p className="whisper-flag whisper-flag-pressure" data-testid={`whisper-pressure-${id}`} role="status">
|
||||||
|
{t('seance.whisper.pressureAnomaly', { defaultValue: '◎ the air presses close — the atmosphere shifts' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeviceWhisper
|
||||||
@@ -257,6 +257,14 @@
|
|||||||
"body": "a diagnostic pulse only — nothing risked, nothing gained."
|
"body": "a diagnostic pulse only — nothing risked, nothing gained."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"whisper": {
|
||||||
|
"title": "THE VESSEL WHISPERS",
|
||||||
|
"online": "the vessel is awake",
|
||||||
|
"offline": "the vessel sleeps",
|
||||||
|
"waiting": "no whisper yet…",
|
||||||
|
"coldSpot": "❄ a cold spot gathers around the vessel",
|
||||||
|
"pressureAnomaly": "◎ the air presses close — the atmosphere shifts"
|
||||||
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"rate_limited": "the veil is thin — slow your knocking",
|
"rate_limited": "the veil is thin — slow your knocking",
|
||||||
"veil_crowded": "too many spirits crowd the line"
|
"veil_crowded": "too many spirits crowd the line"
|
||||||
|
|||||||
@@ -257,6 +257,14 @@
|
|||||||
"body": "solo un pulso de diagnóstico — no se arriesga ni se gana nada."
|
"body": "solo un pulso de diagnóstico — no se arriesga ni se gana nada."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"whisper": {
|
||||||
|
"title": "EL RECIPIENTE SUSURRA",
|
||||||
|
"online": "el recipiente está despierto",
|
||||||
|
"offline": "el recipiente duerme",
|
||||||
|
"waiting": "aún ningún susurro…",
|
||||||
|
"coldSpot": "❄ un punto frío se acumula en torno al recipiente",
|
||||||
|
"pressureAnomaly": "◎ el aire se cierra — la atmósfera cambia"
|
||||||
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"rate_limited": "el velo es delgado — golpea más despacio",
|
"rate_limited": "el velo es delgado — golpea más despacio",
|
||||||
"veil_crowded": "demasiados espíritus se apiñan en la línea"
|
"veil_crowded": "demasiados espíritus se apiñan en la línea"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { GraphView } from '../components/GraphView'
|
|||||||
import { Transcript } from '../components/Transcript'
|
import { Transcript } from '../components/Transcript'
|
||||||
import { EntityCard } from '../components/EntityCard'
|
import { EntityCard } from '../components/EntityCard'
|
||||||
import { RitualPanel } from '../components/RitualPanel'
|
import { RitualPanel } from '../components/RitualPanel'
|
||||||
|
import { DeviceWhisper } from '../components/DeviceWhisper'
|
||||||
import { JudgmentPanel } from '../components/JudgmentPanel'
|
import { JudgmentPanel } from '../components/JudgmentPanel'
|
||||||
import { TelemetryReadout } from '../components/TelemetryReadout'
|
import { TelemetryReadout } from '../components/TelemetryReadout'
|
||||||
import { PlanchetteMachine } from '../lib/planchette'
|
import { PlanchetteMachine } from '../lib/planchette'
|
||||||
@@ -362,6 +363,7 @@ function SeanceSession({ username }: { username: string }) {
|
|||||||
onToggleOpen={() => setDossierOpen((o) => !o)}
|
onToggleOpen={() => setDossierOpen((o) => !o)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeviceWhisper />
|
||||||
<RitualPanel />
|
<RitualPanel />
|
||||||
<JudgmentPanel />
|
<JudgmentPanel />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user