From a2ba0dff90187d43017896b2ed725a6fd25d6c33 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Wed, 28 Oct 2026 18:13:20 -0700 Subject: [PATCH 1/5] feat(health): count frzWater and sensHealth in the live stream inspector Co-Authored-By: Claude Opus 5.5 --- src/components/Sensors/streamGroups.ts | 4 +- .../Sensors/tests/streamGroups.test.ts | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 src/components/Sensors/tests/streamGroups.test.ts diff --git a/src/components/Sensors/streamGroups.ts b/src/components/Sensors/streamGroups.ts index faae6cf38..91ac03c4f 100644 --- a/src/components/Sensors/streamGroups.ts +++ b/src/components/Sensors/streamGroups.ts @@ -12,9 +12,9 @@ export interface StreamGroup { export const STREAM_GROUPS: readonly StreamGroup[] = [ { key: 'status', label: 'Device', types: ['deviceStatus'] }, { key: 'piezo', label: 'Piezo', types: ['piezo-dual', 'lps'] }, - { key: 'presence', label: 'Presence', types: ['capSense2', 'capSense'] }, + { key: 'presence', label: 'Presence', types: ['capSense2', 'capSense', 'sensHealth'] }, { key: 'bedTemp', label: 'Bed temp', types: ['bedTemp2', 'bedTemp'] }, - { key: 'freezer', label: 'Freezer', types: ['frzHealth', 'frzTemp', 'frzTherm'] }, + { key: 'freezer', label: 'Freezer', types: ['frzHealth', 'frzTemp', 'frzTherm', 'frzWater'] }, { key: 'log', label: 'Log', types: ['log', 'gesture'] }, ] diff --git a/src/components/Sensors/tests/streamGroups.test.ts b/src/components/Sensors/tests/streamGroups.test.ts new file mode 100644 index 000000000..5d20135fc --- /dev/null +++ b/src/components/Sensors/tests/streamGroups.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { ALL_SENSOR_TYPES } from '@/src/hooks/useSensorStream' +import { STREAM_GROUPS, fmtSeen, groupForType } from '../streamGroups' + +describe('groupForType', () => { + it('puts the firmware water and cover-health frames in Freezer and Presence', () => { + expect(groupForType('frzWater')).toBe('freezer') + expect(groupForType('sensHealth')).toBe('presence') + }) + + it('keeps the existing mappings', () => { + expect(groupForType('frzHealth')).toBe('freezer') + expect(groupForType('capSense2')).toBe('presence') + expect(groupForType('lps')).toBe('piezo') + expect(groupForType('deviceStatus')).toBe('status') + }) + + it('returns undefined for types outside every group', () => { + expect(groupForType('nope')).toBeUndefined() + }) + + it('assigns every streamed sensor type to exactly one group', () => { + for (const type of ALL_SENSOR_TYPES) { + expect(STREAM_GROUPS.filter(g => g.types.includes(type)), type).toHaveLength(1) + } + }) +}) + +describe('fmtSeen', () => { + it('compacts ages', () => { + expect(fmtSeen(null)).toBe('—') + expect(fmtSeen(1_234)).toBe('1.2s') + expect(fmtSeen(12_400)).toBe('12s') + expect(fmtSeen(4 * 60_000 + 5_000)).toBe('4m') + expect(fmtSeen(2 * 3_600_000)).toBe('2h') + }) +}) From 721b774f6139ab4f4e1a2d8d93c959344f27c829 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Wed, 28 Oct 2026 18:30:04 -0700 Subject: [PATCH 2/5] feat(ui): show the frzWater tank level and sensHealth cover disconnects Co-Authored-By: Claude Opus 5.5 --- .../AppShell/tests/navigation.test.tsx | 16 ++++- src/components/AppShell/usePodHealth.ts | 9 ++- src/components/Sensors/FreezerHealthCard.tsx | 27 ++++++-- .../Sensors/tests/sensorCards.test.tsx | 22 ++++++ src/components/diagnostics/DashboardPanel.tsx | 11 ++- src/components/diagnostics/dashboardLogic.ts | 24 ++++++- .../tests/DiagnosticsConsole.test.tsx | 36 ++++++++++ .../diagnostics/tests/dashboardLogic.test.ts | 26 ++++++- src/components/status/WaterTankGauge.tsx | 31 +++++++++ .../status/tests/WaterTankGauge.test.tsx | 69 +++++++++++++++++++ src/components/status/waterTank.ts | 31 +++++++++ src/demo/sensorFrames.ts | 7 +- src/demo/socket.ts | 1 + src/demo/tests/socket.test.ts | 4 +- 14 files changed, 297 insertions(+), 17 deletions(-) create mode 100644 src/components/status/WaterTankGauge.tsx create mode 100644 src/components/status/tests/WaterTankGauge.test.tsx create mode 100644 src/components/status/waterTank.ts diff --git a/src/components/AppShell/tests/navigation.test.tsx b/src/components/AppShell/tests/navigation.test.tsx index c4c4d47c0..3ea228722 100644 --- a/src/components/AppShell/tests/navigation.test.tsx +++ b/src/components/AppShell/tests/navigation.test.tsx @@ -8,13 +8,14 @@ import { activeNavId, langFromPath, pathWithoutLang } from '../navItems' const state = vi.hoisted(() => ({ path: '/en', query: '', active: true, base: undefined as { configured: boolean } | undefined, status: undefined as unknown, health: undefined as unknown, - version: undefined as unknown, water: undefined as unknown, + version: undefined as unknown, water: undefined as unknown, frames: {} as Record, start: vi.fn(), end: vi.fn(), })) vi.mock('next/navigation', () => ({ usePathname: () => state.path, useSearchParams: () => new URLSearchParams(state.query) })) vi.mock('@/src/hooks/useScheduleActive', () => ({ useScheduleActive: () => ({ isActive: state.active }) })) vi.mock('@/src/hooks/useSwipeNavigation', () => ({ useSwipeNavigation: () => ({ onTouchStart: state.start, onTouchEnd: state.end }) })) vi.mock('@/src/components/Schedule/CurveChart', () => ({ useNowMinute: () => 29000000 })) +vi.mock('@/src/hooks/useSensorStream', () => ({ useSensorFrame: (type: string) => state.frames[type] })) vi.mock('@/src/utils/trpc', () => ({ trpc: { base: { getAvailability: { useQuery: () => ({ data: state.base }) } }, device: { getStatus: { useQuery: () => ({ data: state.status }) } }, @@ -24,7 +25,7 @@ vi.mock('@/src/utils/trpc', () => ({ trpc: { } })) beforeEach(() => { - Object.assign(state, { path: '/en', query: '', active: true, status: undefined, health: undefined, version: undefined, water: undefined, base: undefined }) + Object.assign(state, { path: '/en', query: '', active: true, status: undefined, health: undefined, version: undefined, water: undefined, base: undefined, frames: {} }) vi.clearAllMocks() }) @@ -91,6 +92,17 @@ describe('responsive navigation', () => { fireEvent.pointerDown(document.body) expect(screen.queryByTestId('build-details')).toBeNull() }) + it('lists a disconnected cover sensor only when sensHealth reports it', () => { + state.health = { status: 'ok', database: { status: 'ok' }, scheduler: {}, iptables: { ok: true } } + const { rerender } = render() + expect(screen.queryByTestId('footer-issues')).toBeNull() + state.frames = { sensHealth: { type: 'sensHealth', ts: 1, version: 1, left: { connected: true }, right: { connected: true } } } + rerender() + expect(screen.queryByTestId('footer-issues')).toBeNull() + state.frames = { sensHealth: { type: 'sensHealth', ts: 2, version: 1, left: { connected: false }, right: { connected: true } } } + rerender() + expect(within(screen.getByTestId('footer-issues')).getAllByRole('listitem').map(li => li.textContent)).toEqual(['Left cover sensor disconnected']) + }) it('shows release versions and preserves an unknown model name', () => { state.status = { podVersion: 'Future pod' } state.version = { version: 'v1.2.3', commitHash: 'unknown' } diff --git a/src/components/AppShell/usePodHealth.ts b/src/components/AppShell/usePodHealth.ts index 26682d7cc..f3cf9d5b4 100644 --- a/src/components/AppShell/usePodHealth.ts +++ b/src/components/AppShell/usePodHealth.ts @@ -1,23 +1,26 @@ 'use client' -import { attentionItems } from '@/src/components/diagnostics/dashboardLogic' +import { attentionItems, disconnectedCovers } from '@/src/components/diagnostics/dashboardLogic' import { useNowMinute } from '@/src/components/Schedule/CurveChart' +import { useSensorFrame } from '@/src/hooks/useSensorStream' import { trpc } from '@/src/utils/trpc' import { footerStatus } from './footerStatus' /** * The pod's health as the navigation shows it: failing system checks plus the * System Dashboard's attention items. Shared by the sidebar footer and the - * phone tab bar's System dot. + * phone tab bar's System dot. Cover health is read from the sensor stream + * without subscribing, so it shows while a page (System) streams sensHealth. */ export function usePodHealth() { const status = trpc.device.getStatus.useQuery({}, { staleTime: 10_000, refetchInterval: 30_000 }) const health = trpc.health.system.useQuery({}, { staleTime: 10_000, refetchInterval: 30_000 }) const maintenance = trpc.health.maintenance.useQuery({}, { staleTime: 30_000, refetchInterval: 60_000 }) const water = trpc.waterLevel.getLatest.useQuery({}, { staleTime: 10_000, refetchInterval: 30_000 }) + const sensHealth = useSensorFrame('sensHealth') const nowMinute = useNowMinute() const attention = nowMinute == null ? [] - : attentionItems(maintenance.data, water.data?.level ?? status.data?.waterLevel, nowMinute * 60_000) + : attentionItems(maintenance.data, water.data?.level ?? status.data?.waterLevel, nowMinute * 60_000, [], disconnectedCovers(sensHealth)) return { footer: footerStatus(health.data, attention), podVersion: status.data?.podVersion } } diff --git a/src/components/Sensors/FreezerHealthCard.tsx b/src/components/Sensors/FreezerHealthCard.tsx index ddb01bbd7..4e05ba601 100644 --- a/src/components/Sensors/FreezerHealthCard.tsx +++ b/src/components/Sensors/FreezerHealthCard.tsx @@ -8,6 +8,8 @@ import { trpc } from '@/src/utils/trpc' import { useTemperatureUnit } from '@/src/hooks/useTemperatureUnit' import { formatDisplayTemp, sensorCToDisplay } from '@/src/lib/tempUtils' import { Card, SectionLabel, Skeleton, StatusDot, type Tone } from '@/src/components/ds' +import { WaterTankGauge } from '@/src/components/status/WaterTankGauge' +import { waterTank } from '@/src/components/status/waterTank' /** Overcurrent threshold in Amperes. Eight Sleep TECs pull ~10-11.5A during startup * circuit calibration and up to 12A under peak thermal demand. Real overcurrent is >13.5A. */ @@ -43,6 +45,9 @@ export function freezerStatus( * Combines live WebSocket frames (frzTemp, frzHealth, frzTherm) with tRPC data: * - environment.getLatestFreezerTemp for stored freezer temps when WS is not streaming * - waterLevel.getLatest for current water level status + * + * Newer thermal firmware also sends frzWater; while it does, the tank gauge + * replaces the binary water level in the secondary line. */ export function FreezerHealthCard() { const { unit } = useTemperatureUnit() @@ -51,6 +56,7 @@ export function FreezerHealthCard() { const frzTemp = useSensorFrame('frzTemp') const frzHealth = useSensorFrame('frzHealth') const frzTherm = useSensorFrame('frzTherm') + const tank = waterTank(useSensorFrame('frzWater')) // tRPC: latest freezer temp from DB (fallback when WS hasn't sent data) const latestFreezerTemp = trpc.environment.getLatestFreezerTemp.useQuery( @@ -116,7 +122,7 @@ export function FreezerHealthCard() { frzHealth && `tec ${frzHealth.left.tecCurrent.toFixed(1)}/${frzHealth.right.tecCurrent.toFixed(1)} A`, frzTherm && `therm ${frzTherm.left.toFixed(1)}/${frzTherm.right.toFixed(1)}`, temps && `hub ${t(temps.ambient)}`, - waterLevel && `water ${waterLevel}`, + !tank && waterLevel && `water ${waterLevel}`, ].filter(Boolean) as string[] return ( @@ -125,6 +131,7 @@ export function FreezerHealthCard() { Freezer + {tank && } {temps ? `${t(temps.heatsink, 0)} sink · ${t(temps.leftWater ?? temps.rightWater, 0)} water` : '--'} @@ -142,11 +149,19 @@ export function FreezerHealthCard() { - {secondary.length > 0 && ( -
- {secondary.join(' · ')} -
- )} + {tank + ? ( +
+ {'tank '} + + {secondary.length > 0 && ` · ${secondary.join(' · ')}`} +
+ ) + : secondary.length > 0 && ( +
+ {secondary.join(' · ')} +
+ )}
) diff --git a/src/components/Sensors/tests/sensorCards.test.tsx b/src/components/Sensors/tests/sensorCards.test.tsx index 451e883e8..346cae394 100644 --- a/src/components/Sensors/tests/sensorCards.test.tsx +++ b/src/components/Sensors/tests/sensorCards.test.tsx @@ -94,6 +94,28 @@ it('shows stored and live freezer readings, water warnings and pump RPM', () => expect(screen.getByText(/therm 1.0\/2.0/)).toBeTruthy() }) +it('keeps the binary water level until a frzWater frame arrives, then shows the tank gauge', () => { + const live = { frzTemp: { left: 24, right: 26, amb: 20, hs: 30 }, frzHealth: { left: { tecCurrent: 1, pumpRpm: 2400, flowrate: null }, right: { tecCurrent: 2, pumpRpm: 2400, flowrate: 2 }, fan: { rpm: 200 } } } + mock.frames = { ...live } + mock.data.water = { level: 'ok' } + const { rerender } = render() + // Older firmware: no frzWater, so no gauge and the DAC level stays in the secondary line. + expect(screen.queryByTestId('water-tank')).toBeNull() + expect(screen.getByText(/water ok$/)).toBeTruthy() + + mock.frames = { ...live, frzWater: { type: 'frzWater', ts: 1, isPriming: true, hasWater: true, capValid: true, capLevel: 2, needsPriming: false } } + rerender() + expect(screen.queryByText(/water ok/)).toBeNull() + expect(screen.getAllByRole('img', { name: 'Water tank 2 of 5' })).toHaveLength(2) + expect(screen.getAllByText('Priming…')).toHaveLength(2) + expect(screen.getByTestId('freezer-secondary').textContent).toMatch(/^tank 2\/5Priming… · fan 200 rpm/) + + mock.frames = { ...live, frzWater: { type: 'frzWater', ts: 2, isPriming: false, hasWater: false, capValid: false, capLevel: 5, needsPriming: true } } + rerender() + expect(screen.getAllByRole('img', { name: 'Water low, tank level unavailable' })).toHaveLength(2) + expect(screen.getAllByText('Needs prime')).toHaveLength(2) +}) + it('uses server occupancy while visualizing frame variance independently', () => { const { rerender } = render() expect(screen.getByTestId('presence-left').textContent).toContain('left · --') diff --git a/src/components/diagnostics/DashboardPanel.tsx b/src/components/diagnostics/DashboardPanel.tsx index 5c3841784..f139e3349 100644 --- a/src/components/diagnostics/DashboardPanel.tsx +++ b/src/components/diagnostics/DashboardPanel.tsx @@ -12,6 +12,7 @@ import type { AppRouter } from '@/src/server/routers/app' import { trpc } from '@/src/utils/trpc' import { useSideNames } from '@/src/hooks/useSideNames' import { useShownSides } from '@/src/providers/SideProvider' +import { useSensorFrame } from '@/src/hooks/useSensorStream' import { useTemperatureUnit } from '@/src/hooks/useTemperatureUnit' import { Button, Card, HoverMark, InlineError, KeyValue, Skeleton, StatusDot, useHoverFraction } from '@/src/components/ds' import { cn } from '@/lib/utils' @@ -20,11 +21,13 @@ import { langFromPath } from '@/src/components/AppShell/navItems' import { HealthRing } from '@/src/components/status/HealthCircle' import { PumpAlertsCard } from '@/src/components/status/PumpAlertsCard' import { formatUptime, useStatusSummary } from '@/src/components/status/StatusScreen' +import { WaterTankGauge } from '@/src/components/status/WaterTankGauge' +import { waterTank } from '@/src/components/status/waterTank' import { CurveChart, CurveLegend, buildTimeline, readAt, useNowMinute } from '@/src/components/Schedule/CurveChart' import { bedTrace } from '@/src/components/Schedule/bedTrace' import { formatCountdown, nightSetPoints } from '@/src/components/Schedule/bothNight' import { fmtClock, fmtF, thermalDirection, type SchedJob } from './diagnosticsLogic' -import { attentionItems, isPodLaneJob, jobsInWindow, nextTemperatureJob, podJobLabel, tonightWindow, TONIGHT_END_H, TONIGHT_START_H } from './dashboardLogic' +import { attentionItems, disconnectedCovers, isPodLaneJob, jobsInWindow, nextTemperatureJob, podJobLabel, tonightWindow, TONIGHT_END_H, TONIGHT_START_H } from './dashboardLogic' import { nearestPoint, panelDomain, seriesPath, THERMAL_PANELS } from './thermalHistoryLogic' import type { DiagSection } from './DiagnosticsConsole' import { OccupancyCheck } from './OccupancyCheck' @@ -64,6 +67,8 @@ export function DashboardPanel({ thermal, onJump }: { thermal: ThermalData | und function StatusLine() { const s = useStatusSummary() + // Newer thermal firmware only; older pods keep the line as it was. + const tank = waterTank(useSensorFrame('frzWater')) const checks = Object.values(s.groups).flat() const failing = checks.filter(c => c.status !== 'ok') const commit = s.version && s.version.commitHash !== 'unknown' ? s.version.commitHash.slice(0, 7) : null @@ -121,6 +126,7 @@ function StatusLine() { : , title: s.firewall && !s.firewall.ok ? `Missing firewall rules: ${s.firewall.missing.join(', ')}` : undefined, }, + ...(tank ? [{ label: 'Water', value: }] : []), { label: 'Disk', value: s.diskPercent !== undefined @@ -200,6 +206,7 @@ function AttentionCard() { const water = trpc.waterLevel.getLatest.useQuery({}, { refetchInterval: 30_000 }) const device = trpc.device.getStatus.useQuery({}, { refetchInterval: 10_000 }) const dataPath = trpc.health.dataPath.useQuery({}, { refetchInterval: 60_000 }) + const sensHealth = useSensorFrame('sensHealth') const enableGuard = trpc.settings.updateDevice.useMutation({ onSuccess: () => { void utils.health.maintenance.invalidate() @@ -214,7 +221,7 @@ function AttentionCard() { if (nowMinute == null) return null const occupancy = dataPath.data?.occupancy const suspectSides = occupancy ? shown.filter(s => occupancy[s] === 'suspect') : [] - const items = attentionItems(maintenance.data, water.data?.level ?? device.data?.waterLevel, nowMinute * 60_000, suspectSides) + const items = attentionItems(maintenance.data, water.data?.level ?? device.data?.waterLevel, nowMinute * 60_000, suspectSides, disconnectedCovers(sensHealth)) if (items.length === 0) return null const priming = device.data?.isPriming ?? false diff --git a/src/components/diagnostics/dashboardLogic.ts b/src/components/diagnostics/dashboardLogic.ts index 6325afc38..32f83b92f 100644 --- a/src/components/diagnostics/dashboardLogic.ts +++ b/src/components/diagnostics/dashboardLogic.ts @@ -1,3 +1,4 @@ +import type { SensHealthFrame } from '@/src/hooks/useSensorStream' import { DAYS_OF_WEEK, type DayOfWeek } from '@/src/lib/scheduleTime' import type { SchedJob } from './diagnosticsLogic' @@ -58,7 +59,7 @@ export function nextTemperatureJob(jobs: SchedJob[] | undefined, now: number): S } export interface AttentionItem { - id: 'pump-stall' | 'prime' | 'water' | 'occupancy' + id: 'pump-stall' | 'prime' | 'water' | 'occupancy' | 'cover' title: string detail: string } @@ -73,6 +74,16 @@ export interface MaintenanceFacts { const PRIME_STALE_MS = 7 * 24 * HOUR +/** + * Sides whose cover sensor reports no link in the latest `sensHealth` frame. + * Empty without a frame: older firmware never sends one. Only an explicit + * `connected: false` counts. + */ +export function disconnectedCovers(frame: SensHealthFrame | undefined): Array<'left' | 'right'> { + if (!frame) return [] + return (['left', 'right'] as const).filter(side => frame[side]?.connected === false) +} + /** * Things on the pod that need someone to act. Empty means the Dashboard hides * the card. A daily prime schedule counts as covered; otherwise a missing @@ -83,8 +94,19 @@ export function attentionItems( waterLevel: string | undefined, now: number, suspectSides: ReadonlyArray = [], + disconnected: ReadonlyArray<'left' | 'right'> = [], ): AttentionItem[] { const out: AttentionItem[] = [] + if (disconnected.length > 0) { + const both = disconnected.length === 2 + out.push({ + id: 'cover', + title: both ? 'Left and right cover sensors disconnected' : `${disconnected[0] === 'left' ? 'Left' : 'Right'} cover sensor disconnected`, + detail: both + ? 'No presence or vitals from either side until the cover reconnects. Check its connection at the hub.' + : 'No presence or vitals from that side until it reconnects. Check the cover\'s connection at the hub.', + }) + } if (suspectSides.length > 0) { out.push({ id: 'occupancy', diff --git a/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx b/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx index 243541a3c..913f16258 100644 --- a/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx +++ b/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as SideProviderModule from '@/src/providers/SideProvider' import type * as CurveChartModule from '@/src/components/Schedule/CurveChart' +import type * as SensorStreamModule from '@/src/hooks/useSensorStream' const mocks = vi.hoisted(() => ({ thermal: undefined as unknown, @@ -13,6 +14,7 @@ const mocks = vi.hoisted(() => ({ schedules: {} as Record, mutations: {} as Record>, summary: undefined as unknown, + frames: {} as Record, timeFormat: '12h' as '12h' | '24h', // 8 PM local on a Monday: inside tonight's 5 PM → 9 AM window. nowMinute: Math.floor(new Date(2026, 8, 28, 20, 0).getTime() / 60_000), @@ -23,6 +25,10 @@ vi.mock('@/src/components/status/StatusScreen', () => ({ formatUptime: (s: number) => `${Math.floor(s / 3600)}h`, })) vi.mock('@/src/components/status/PumpAlertsCard', () => ({ PumpAlertsCard: () => null })) +vi.mock('@/src/hooks/useSensorStream', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useSensorFrame: (type: string) => mocks.frames[type] } +}) vi.mock('@/src/components/Schedule/CurveChart', async (importOriginal) => { const actual = await importOriginal() return { @@ -105,6 +111,7 @@ vi.mock('@/src/providers/SideProvider', async (importOriginal) => { beforeEach(() => { mocks.timeFormat = '12h' mocks.dataPath = undefined + mocks.frames = {} single.sides = null mocks.thermal = { pumpStallProtectionEnabled: true, @@ -202,6 +209,35 @@ describe('DiagnosticsConsole dashboard', () => { expect(screen.queryByTestId('attention')).toBeNull() }) + it('warns per side when sensHealth reports a disconnected cover sensor', () => { + const health = (left: boolean, right: boolean) => ({ type: 'sensHealth', ts: 1, version: 1, left: { connected: left }, right: { connected: right } }) + mocks.frames = { sensHealth: health(true, true) } + const { rerender } = render() + expect(screen.queryByTestId('attention')).toBeNull() + mocks.frames = { sensHealth: health(true, false) } + rerender() + expect(within(screen.getByTestId('attention')).getByText('Right cover sensor disconnected')).toBeTruthy() + mocks.frames = { sensHealth: health(false, true) } + rerender() + expect(within(screen.getByTestId('attention')).getByText('Left cover sensor disconnected')).toBeTruthy() + // Older firmware never sends sensHealth: no warning. + mocks.frames = {} + rerender() + expect(screen.queryByTestId('attention')).toBeNull() + }) + + it('adds a water tank fact only once frzWater arrives', () => { + const { rerender } = render() + const line = () => screen.getByTestId('status-line') + expect(within(line()).queryByText('Water')).toBeNull() + expect(within(line()).queryByTestId('water-tank')).toBeNull() + mocks.frames = { frzWater: { type: 'frzWater', ts: 1, isPriming: false, hasWater: true, capValid: true, capLevel: 3, needsPriming: true } } + rerender() + expect(within(line()).getByText('Water')).toBeTruthy() + expect(within(line()).getByRole('img', { name: 'Water tank 3 of 5' })).toBeTruthy() + expect(within(line()).getByText('Needs prime')).toBeTruthy() + }) + it.each([['left'], ['right'], ['left', 'right']] as Array>)('shows one loading placeholder per displayed side (%j)', (...sides) => { single.sides = sides mocks.thermal = undefined diff --git a/src/components/diagnostics/tests/dashboardLogic.test.ts b/src/components/diagnostics/tests/dashboardLogic.test.ts index d7e6373c8..e1ad2d682 100644 --- a/src/components/diagnostics/tests/dashboardLogic.test.ts +++ b/src/components/diagnostics/tests/dashboardLogic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { - attentionItems, isPodLaneJob, jobsInWindow, nextTemperatureJob, podJobLabel, tonightWindow, + attentionItems, disconnectedCovers, isPodLaneJob, jobsInWindow, nextTemperatureJob, podJobLabel, tonightWindow, } from '../dashboardLogic' import type { SchedJob } from '../diagnosticsLogic' @@ -85,4 +85,28 @@ describe('attentionItems', () => { it('flags low water', () => { expect(attentionItems(ok, 'low', now).map(i => i.id)).toEqual(['water']) }) + it('leads with a disconnected cover sensor, per side', () => { + const [right] = attentionItems(ok, 'ok', now, ['left'], ['right']) + expect(right).toMatchObject({ id: 'cover', title: 'Right cover sensor disconnected' }) + expect(attentionItems(ok, 'ok', now, [], ['left'])[0].title).toBe('Left cover sensor disconnected') + expect(attentionItems(ok, 'ok', now, [], ['left', 'right'])[0].title).toBe('Left and right cover sensors disconnected') + expect(attentionItems(ok, 'ok', now, [], [])).toEqual([]) + }) +}) + +describe('disconnectedCovers', () => { + const frame = (left: boolean, right: boolean) => ({ type: 'sensHealth' as const, ts: 1, version: 1, left: { connected: left }, right: { connected: right } }) + it('is empty without a sensHealth frame (older firmware)', () => { + expect(disconnectedCovers(undefined)).toEqual([]) + }) + it('lists each side that reports no link', () => { + expect(disconnectedCovers(frame(true, true))).toEqual([]) + expect(disconnectedCovers(frame(false, true))).toEqual(['left']) + expect(disconnectedCovers(frame(true, false))).toEqual(['right']) + expect(disconnectedCovers(frame(false, false))).toEqual(['left', 'right']) + }) + it('only counts an explicit connected: false', () => { + expect(disconnectedCovers({ type: 'sensHealth', ts: 1 } as never)).toEqual([]) + expect(disconnectedCovers({ type: 'sensHealth', ts: 1, left: {}, right: { connected: null } } as never)).toEqual([]) + }) }) diff --git a/src/components/status/WaterTankGauge.tsx b/src/components/status/WaterTankGauge.tsx new file mode 100644 index 000000000..29c3cb6d6 --- /dev/null +++ b/src/components/status/WaterTankGauge.tsx @@ -0,0 +1,31 @@ +'use client' + +import { cn } from '@/lib/utils' +import { TANK_BARS, type WaterTank } from './waterTank' + +/** + * Water tank gauge from `frzWater`: five bars filling bottom-up, with the + * tank label and priming state beside it. Bars pulse while the pod primes; an + * invalid capacitive reading leaves every bar unlit and labels the tank from + * `hasWater`. + */ +export function WaterTankGauge({ tank, className }: { tank: WaterTank, className?: string }) { + const lit = tank.level ?? 0 + const bar = tank.tone === 'warn' ? 'bg-warn' : 'bg-cool' + const prime = tank.prime === 'priming' ? 'Priming…' : tank.prime === 'needed' ? 'Needs prime' : null + return ( + + + {Array.from({ length: TANK_BARS }, (_, i) => ( + + ))} + + {tank.label} + {prime && {prime}} + + ) +} diff --git a/src/components/status/tests/WaterTankGauge.test.tsx b/src/components/status/tests/WaterTankGauge.test.tsx new file mode 100644 index 000000000..021a5418c --- /dev/null +++ b/src/components/status/tests/WaterTankGauge.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { FrzWaterFrame } from '@/src/hooks/useSensorStream' +import { WaterTankGauge } from '../WaterTankGauge' +import { waterTank, type WaterTank } from '../waterTank' + +const frame = (over: Partial = {}): FrzWaterFrame => ({ + type: 'frzWater', ts: 1, isPriming: false, hasWater: true, capValid: true, capLevel: 4, needsPriming: false, ...over, +}) + +describe('waterTank', () => { + it('is undefined without a frzWater frame (older firmware)', () => { + expect(waterTank(undefined)).toBeUndefined() + }) + + it('uses capLevel when capValid', () => { + expect(waterTank(frame())).toEqual({ level: 4, label: '4/5', tone: 'ok', prime: null }) + expect(waterTank(frame({ capLevel: 1 }))).toMatchObject({ level: 1, tone: 'warn' }) + expect(waterTank(frame({ capLevel: 0, hasWater: false }))).toMatchObject({ level: 0, label: '0/5', tone: 'warn' }) + }) + + it('clamps out-of-range levels to the five bars', () => { + expect(waterTank(frame({ capLevel: 9 }))?.level).toBe(5) + expect(waterTank(frame({ capLevel: -2 }))?.level).toBe(0) + }) + + it('ignores capLevel when the capacitive reading is invalid', () => { + expect(waterTank(frame({ capValid: false, capLevel: 5 }))).toEqual({ level: null, label: 'OK', tone: 'ok', prime: null }) + expect(waterTank(frame({ capValid: false, capLevel: 5, hasWater: false }))).toEqual({ level: null, label: 'Low', tone: 'warn', prime: null }) + }) + + it('warns whenever the controller reports no water', () => { + expect(waterTank(frame({ capLevel: 3, hasWater: false }))?.tone).toBe('warn') + }) + + it('reports priming ahead of a pending prime', () => { + expect(waterTank(frame({ needsPriming: true }))?.prime).toBe('needed') + expect(waterTank(frame({ isPriming: true, needsPriming: true }))?.prime).toBe('priming') + }) +}) + +describe('WaterTankGauge', () => { + const tank = (over: Partial = {}) => waterTank(frame(over)) as WaterTank + const lit = () => screen.getByRole('img').querySelectorAll('[data-lit="true"]').length + + it('lights one bar per level with the level label', () => { + render() + expect(screen.getByRole('img', { name: 'Water tank 3 of 5' })).toBeTruthy() + expect(screen.getByRole('img').children).toHaveLength(5) + expect(lit()).toBe(3) + expect(screen.getByText('3/5')).toBeTruthy() + }) + + it('shows no level when the reading is invalid', () => { + render() + expect(screen.getByRole('img', { name: 'Water low, tank level unavailable' })).toBeTruthy() + expect(lit()).toBe(0) + expect(screen.getByText('Low').className).toContain('text-warn') + }) + + it('pulses while priming and flags a needed prime', () => { + const { rerender } = render() + expect(screen.getByText('Priming…')).toBeTruthy() + expect(screen.getByRole('img').className).toContain('animate-pulse') + rerender() + expect(screen.getByText('Needs prime').className).toContain('text-warn') + expect(screen.getByRole('img').className).not.toContain('animate-pulse') + }) +}) diff --git a/src/components/status/waterTank.ts b/src/components/status/waterTank.ts new file mode 100644 index 000000000..81f61ed7f --- /dev/null +++ b/src/components/status/waterTank.ts @@ -0,0 +1,31 @@ +import type { FrzWaterFrame } from '@/src/hooks/useSensorStream' + +/** Bars on the tank gauge; `capLevel` runs 0–5. */ +export const TANK_BARS = 5 + +export interface WaterTank { + /** Lit bars, or null when the capacitive reading isn't valid (no gauge level). */ + level: number | null + /** "4/5" when the level is valid, otherwise OK / Low from `hasWater`. */ + label: string + tone: 'ok' | 'warn' + /** Priming now, firmware asking for a prime, or neither. */ + prime: 'priming' | 'needed' | null +} + +/** + * Tank state from the thermal controller's `frzWater` frame (newer firmware). + * Undefined without a frame so callers keep the binary DAC water level. + * `capLevel` is used only when `capValid`. + */ +export function waterTank(frame: FrzWaterFrame | undefined): WaterTank | undefined { + if (!frame) return undefined + const level = frame.capValid ? Math.min(TANK_BARS, Math.max(0, Math.round(frame.capLevel))) : null + const low = !frame.hasWater || (level != null && level <= 1) + return { + level, + label: level != null ? `${level}/${TANK_BARS}` : frame.hasWater ? 'OK' : 'Low', + tone: low ? 'warn' : 'ok', + prime: frame.isPriming ? 'priming' : frame.needsPriming ? 'needed' : null, + } +} diff --git a/src/demo/sensorFrames.ts b/src/demo/sensorFrames.ts index 291be64b6..b45130b2d 100644 --- a/src/demo/sensorFrames.ts +++ b/src/demo/sensorFrames.ts @@ -79,9 +79,14 @@ export function freezerFrames(ts: number) { { type: 'frzTemp', ts, left: waterC(status.leftSide.currentTemperature), right: waterC(status.rightSide.currentTemperature), amb: 2140, hs: 2530 }, { type: 'frzTherm', ts, version: 1, left: therm(status.leftSide), right: therm(status.rightSide) }, { type: 'frzHealth', ts, version: 1, left: sideFrz(status.leftSide), right: sideFrz(status.rightSide), fan: { top: { rpm: Math.round(randomBetween(Math.random, 1100, 1250)) }, bottom: { rpm: 0 } } }, + { type: 'frzWater', ts, isPriming: status.isPriming, hasWater: true, capValid: true, capLevel: 4, needsPriming: false }, ] } +export function sensHealthFrame(ts: number) { + return { type: 'sensHealth', ts, version: 1, left: { connected: true }, right: { connected: true } } +} + export function deviceStatusFrame() { return { type: 'deviceStatus', ts: Date.now(), ...getDemoDeviceStatus('F') } } @@ -95,7 +100,7 @@ export function snapshotMessage() { const ts = nowSec() return { type: 'snapshot', - latest: [bedTempFrame(ts), capSenseFrame(ts), ...freezerFrames(ts), logFrame(ts), deviceStatusFrame()], + latest: [bedTempFrame(ts), capSenseFrame(ts), ...freezerFrames(ts), sensHealthFrame(ts), logFrame(ts), deviceStatusFrame()], waveform: Array.from({ length: 10 }, (_, i) => piezoFrame(ts - 10 + i)), range: { min: ts - 6 * 3600, max: ts }, } diff --git a/src/demo/socket.ts b/src/demo/socket.ts index 42fa2e3e6..30ea11149 100644 --- a/src/demo/socket.ts +++ b/src/demo/socket.ts @@ -82,6 +82,7 @@ class DemoSensorSocket { const ts = f.nowSec() this.emit(f.bedTempFrame(ts)) for (const frame of f.freezerFrames(ts)) this.emit(frame) + this.emit(f.sensHealthFrame(ts)) }) } } diff --git a/src/demo/tests/socket.test.ts b/src/demo/tests/socket.test.ts index ef3351238..0d887ffb2 100644 --- a/src/demo/tests/socket.test.ts +++ b/src/demo/tests/socket.test.ts @@ -30,7 +30,7 @@ describe('demo sensor socket', () => { expect(messages[0]).toMatchObject({ type: 'subscribed' }) const snapshot = messages[1] as unknown as { type: string, latest: Message[], waveform: Message[], range: { min: number, max: number } } expect(snapshot.type).toBe('snapshot') - expect(snapshot.latest.map(f => f.type)).toEqual(expect.arrayContaining(['bedTemp2', 'capSense2', 'frzTemp', 'frzTherm', 'frzHealth', 'log', 'deviceStatus'])) + expect(snapshot.latest.map(f => f.type)).toEqual(expect.arrayContaining(['bedTemp2', 'capSense2', 'frzTemp', 'frzTherm', 'frzHealth', 'frzWater', 'sensHealth', 'log', 'deviceStatus'])) expect(snapshot.waveform).toHaveLength(10) expect(snapshot.range.max).toBeGreaterThan(snapshot.range.min) socket.close() @@ -61,6 +61,8 @@ describe('demo sensor socket', () => { const frz = normalizeFrame(ofType('frzTemp')[0]) as { left: number | null } expect(frz.left).toBeGreaterThan(5) expect(normalizeFrame(ofType('frzTherm')[0])).toMatchObject({ type: 'frzTherm' }) + expect(normalizeFrame(ofType('frzWater')[0])).toMatchObject({ capValid: true, capLevel: 4, isPriming: false }) + expect(normalizeFrame(ofType('sensHealth')[0])).toMatchObject({ left: { connected: true }, right: { connected: true } }) expect(ofType('deviceStatus').length).toBeGreaterThanOrEqual(4) expect(ofType('log').length).toBeGreaterThan(0) From dd64646e1d4ad55e04d52e7b07de804b3bf67973 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Wed, 28 Oct 2026 19:32:51 -0700 Subject: [PATCH 3/5] fix(ui): type frzWater flags as firmware ints and show tank, priming and cover state on the water and presence cards Co-Authored-By: Claude Opus 5.5 --- src/components/Sensors/PresenceCard.tsx | 9 ++++++- .../Sensors/tests/sensorCards.test.tsx | 17 +++++++++++++ src/components/status/WaterLevelCard.tsx | 21 +++++++++++++--- .../status/tests/WaterLevelCard.test.tsx | 25 +++++++++++++++++++ .../status/tests/WaterTankGauge.test.tsx | 7 ++++++ src/hooks/useSensorStream.ts | 12 +++++---- 6 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/components/Sensors/PresenceCard.tsx b/src/components/Sensors/PresenceCard.tsx index dffb98442..32c2af568 100644 --- a/src/components/Sensors/PresenceCard.tsx +++ b/src/components/Sensors/PresenceCard.tsx @@ -17,6 +17,9 @@ import { Card, SectionLabel, Skeleton, StatusDot } from '@/src/components/ds' * agree on bed state. The head/torso/legs bars are a live visualization of * capSense channel variance — useful for seeing what the sensor is doing * right now, but NOT the source of truth for occupancy. + * + * Newer cover firmware sends sensHealth; a side it reports unlinked gets a + * Disconnected dot. Without the frame (older firmware) nothing is added. */ const VARIANCE_WINDOW = 20 @@ -55,6 +58,7 @@ export function PresenceCard() { const capSense = useSensorFrame('capSense') const capSense2 = useSensorFrame('capSense2') const frame: CapSenseFrame | CapSense2Frame | undefined = capSense2 ?? capSense + const sensHealth = useSensorFrame('sensHealth') const { sideName } = useSideNames() const occupancyQuery = trpc.biometrics.getOccupancy.useQuery(undefined, { @@ -125,6 +129,7 @@ export function PresenceCard() { sub={occ ? presenceSub(occ) : '--'} variance={side === 'left' ? variance.leftVariance : variance.rightVariance} hasFrames={!!frame} + disconnected={sensHealth?.[side]?.connected === false} /> ) })} @@ -134,7 +139,7 @@ export function PresenceCard() { ) } -function PresenceSide({ name, side, occupied, known, sub, variance, hasFrames }: { +function PresenceSide({ name, side, occupied, known, sub, variance, hasFrames, disconnected }: { name: string side: 'left' | 'right' occupied: boolean @@ -142,6 +147,7 @@ function PresenceSide({ name, side, occupied, known, sub, variance, hasFrames }: sub: string variance: number[] hasFrames: boolean + disconnected: boolean }) { const accent = side === 'left' ? 'var(--accent-cool)' : 'var(--accent-warm)' const activity = ZONES.map((_, zone) => zoneActivity(variance, zone)) @@ -153,6 +159,7 @@ function PresenceSide({ name, side, occupied, known, sub, variance, hasFrames }: {`${name} · ${known ? (occupied ? 'in bed' : 'empty') : '--'}`} + {disconnected && } {sub}
{ZONES.map((zone, i) => ( diff --git a/src/components/Sensors/tests/sensorCards.test.tsx b/src/components/Sensors/tests/sensorCards.test.tsx index 346cae394..7ec7b68e0 100644 --- a/src/components/Sensors/tests/sensorCards.test.tsx +++ b/src/components/Sensors/tests/sensorCards.test.tsx @@ -134,6 +134,23 @@ it('uses server occupancy while visualizing frame variance independently', () => expect(screen.getByTestId('presence-right').textContent).toContain('in bedstill') }) +it('marks a side Disconnected only when sensHealth reports it unlinked', () => { + mock.data.occupancy = { left: { occupied: false, available: true, classification: 'none', movement: { active: false } }, right: { occupied: false, available: true, classification: 'none', movement: { active: false } } } + const { rerender } = render() + // Older firmware: no sensHealth frame, nothing extra. + expect(screen.queryByText('Disconnected')).toBeNull() + mock.frames.sensHealth = { type: 'sensHealth', ts: 1, version: 1, left: { connected: true }, right: { connected: true } } + rerender() + expect(screen.queryByText('Disconnected')).toBeNull() + mock.frames.sensHealth = { type: 'sensHealth', ts: 2, version: 1, left: { connected: true }, right: { connected: false } } + rerender() + expect(within(screen.getByTestId('presence-right')).getByText('Disconnected')).toBeTruthy() + expect(within(screen.getByTestId('presence-left')).queryByText('Disconnected')).toBeNull() + mock.frames.sensHealth = { type: 'sensHealth', ts: 3, version: 1, left: { connected: false }, right: { connected: false } } + rerender() + expect(screen.getAllByText('Disconnected')).toHaveLength(2) +}) + it('switches flow units and ranges with live readings and downsampled history', () => { const { rerender } = render() expect(screen.getByText('No flow history')).toBeTruthy() diff --git a/src/components/status/WaterLevelCard.tsx b/src/components/status/WaterLevelCard.tsx index 4536ca057..da332dac4 100644 --- a/src/components/status/WaterLevelCard.tsx +++ b/src/components/status/WaterLevelCard.tsx @@ -2,9 +2,12 @@ import { Droplets, Play } from 'lucide-react' import { trpc } from '@/src/utils/trpc' +import { useSensorFrame } from '@/src/hooks/useSensorStream' import { Button, Card, CardHeader, Skeleton } from '@/src/components/ds' import { cn } from '@/lib/utils' import { dailyWaterLevels, useWaterHistory, type WaterDay } from './waterHistory' +import { WaterTankGauge } from './WaterTankGauge' +import { waterTank } from './waterTank' const DAY_TONE: Record<'ok' | 'low' | 'none', { past: string, today: string }> = { ok: { past: 'bg-ok-line', today: 'bg-ok' }, @@ -25,10 +28,15 @@ function dayLabel(level: WaterDay): string { * Wires into: * - waterLevel.getHistory → 7-day strip * - device.getStatus → priming state + prime-complete timestamp + * - frzWater (newer thermal firmware) → tank gauge in the header; its + * isPriming also blocks Start prime */ export function WaterLevelCard({ onOpen }: { onOpen: () => void }) { const { data: history, isLoading: historyLoading } = useWaterHistory() const { data: status, isLoading: statusLoading } = trpc.device.getStatus.useQuery({}, { refetchInterval: 10_000 }) + const frzWater = useSensorFrame('frzWater') + const tank = waterTank(frzWater) + const priming = !!status?.isPriming || !!frzWater?.isPriming const days = dailyWaterLevels(history) const lastPrimed = status?.primeCompletedNotification?.timestamp @@ -39,7 +47,14 @@ export function WaterLevelCard({ onOpen }: { onOpen: () => void }) { title="Water level" icon={Droplets} iconClassName="text-cool" - right={7 days} + right={tank + ? ( + + + 7 days + + ) + : 7 days} /> {historyLoading ? @@ -64,14 +79,14 @@ export function WaterLevelCard({ onOpen }: { onOpen: () => void }) { ? : ( - {status?.isPriming + {priming ? 'Priming…' : lastPrimed ? `Last primed ${new Date(lastPrimed).toLocaleDateString([], { month: 'short', day: 'numeric' })}` : 'No recent prime'} )} -
diff --git a/src/components/status/tests/WaterLevelCard.test.tsx b/src/components/status/tests/WaterLevelCard.test.tsx index ef31b5803..a2ed762cc 100644 --- a/src/components/status/tests/WaterLevelCard.test.tsx +++ b/src/components/status/tests/WaterLevelCard.test.tsx @@ -12,7 +12,9 @@ const mock = vi.hoisted(() => ({ historyLoading: false, status: undefined as unknown, statusLoading: false, + frames: {} as Record, })) +vi.mock('@/src/hooks/useSensorStream', () => ({ useSensorFrame: (type: string) => mock.frames[type] })) vi.mock('@/src/utils/trpc', () => ({ trpc: { waterLevel: { getHistory: { useQuery: () => ({ data: mock.history, isLoading: mock.historyLoading }) } }, device: { getStatus: { useQuery: () => ({ data: mock.status, isLoading: mock.statusLoading }) } }, @@ -23,6 +25,7 @@ beforeEach(() => { mock.historyLoading = false mock.status = undefined mock.statusLoading = false + mock.frames = {} }) it('holds the day strip and prime line with skeletons while queries load', () => { @@ -57,3 +60,25 @@ it('opens the prime dialog and disables the button while priming', () => { expect(screen.getByText('Priming…')).toBeTruthy() expect((screen.getByRole('button', { name: /start prime/i }) as HTMLButtonElement).disabled).toBe(true) }) + +it('keeps the header and prime state from device status without a frzWater frame', () => { + mock.status = { isPriming: false } + render( {}} />) + expect(screen.queryByTestId('water-tank')).toBeNull() + expect(screen.getByText('7 days')).toBeTruthy() + expect((screen.getByRole('button', { name: /start prime/i }) as HTMLButtonElement).disabled).toBe(false) +}) + +it('shows the tank gauge from frzWater and blocks Start prime while it reports priming', () => { + mock.status = { isPriming: false } + mock.frames.frzWater = { type: 'frzWater', version: 1, ts: 1, isPriming: 0, hasWater: 1, capValid: 1, capLevel: 3, needsPriming: 0 } + const { rerender } = render( {}} />) + expect(screen.getByRole('img', { name: 'Water tank 3 of 5' })).toBeTruthy() + expect((screen.getByRole('button', { name: /start prime/i }) as HTMLButtonElement).disabled).toBe(false) + // Pod 3 live payload: integer flags, invalid capacitive level, priming. + mock.frames.frzWater = { type: 'frzWater', version: 1, ts: 2, isPriming: 1, hasWater: 1, capValid: 0, capLevel: 0, needsPriming: 0 } + rerender( {}} />) + expect(screen.getByRole('img', { name: 'Water ok, tank level unavailable' })).toBeTruthy() + expect(screen.getAllByText('Priming…')).toHaveLength(2) + expect((screen.getByRole('button', { name: /start prime/i }) as HTMLButtonElement).disabled).toBe(true) +}) diff --git a/src/components/status/tests/WaterTankGauge.test.tsx b/src/components/status/tests/WaterTankGauge.test.tsx index 021a5418c..30a2ba4db 100644 --- a/src/components/status/tests/WaterTankGauge.test.tsx +++ b/src/components/status/tests/WaterTankGauge.test.tsx @@ -33,6 +33,13 @@ describe('waterTank', () => { expect(waterTank(frame({ capLevel: 3, hasWater: false }))?.tone).toBe('warn') }) + it('reads the 0/1 integer flags a Pod 3 on thermal v1.6.114 sends', () => { + const live = JSON.parse('{"type":"frzWater","version":1,"ts":1793239885,"isPriming":1,"hasWater":1,"capValid":0,"capLevel":0,"needsPriming":0}') as FrzWaterFrame + expect(waterTank(live)).toEqual({ level: null, label: 'OK', tone: 'ok', prime: 'priming' }) + expect(waterTank({ ...live, isPriming: 0, capValid: 1, capLevel: 3 })).toEqual({ level: 3, label: '3/5', tone: 'ok', prime: null }) + expect(waterTank({ ...live, isPriming: 0, hasWater: 0, needsPriming: 1 })).toEqual({ level: null, label: 'Low', tone: 'warn', prime: 'needed' }) + }) + it('reports priming ahead of a pending prime', () => { expect(waterTank(frame({ needsPriming: true }))?.prime).toBe('needed') expect(waterTank(frame({ isPriming: true, needsPriming: true }))?.prime).toBe('priming') diff --git a/src/hooks/useSensorStream.ts b/src/hooks/useSensorStream.ts index 709532956..eb63f2635 100644 --- a/src/hooks/useSensorStream.ts +++ b/src/hooks/useSensorStream.ts @@ -135,15 +135,17 @@ export interface GestureFrame { tapType: string } -/** Freezer water reservoir and priming status frame. */ +/** Freezer water reservoir and priming status frame. Firmware sends the + * flags as 0/1 integers; read them by truthiness, never `=== true`. */ export interface FrzWaterFrame { type: 'frzWater' ts: number - isPriming: boolean - hasWater: boolean - capValid: boolean + version?: number + isPriming: boolean | 0 | 1 + hasWater: boolean | 0 | 1 + capValid: boolean | 0 | 1 capLevel: number - needsPriming: boolean + needsPriming: boolean | 0 | 1 } /** Cover sensor health and connectivity status frame. */ From 6dc20bc28dc0a26957a2c3a4945bd0fc0af4c5dc Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Wed, 28 Oct 2026 20:57:03 -0700 Subject: [PATCH 4/5] test(dashboard): cover the DAC water-level fallback in the attention card Co-Authored-By: Claude Opus 5.5 --- .../diagnostics/tests/DiagnosticsConsole.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx b/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx index 913f16258..91caad646 100644 --- a/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx +++ b/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ mutations: {} as Record>, summary: undefined as unknown, frames: {} as Record, + device: undefined as unknown, timeFormat: '12h' as '12h' | '24h', // 8 PM local on a Monday: inside tonight's 5 PM → 9 AM window. nowMinute: Math.floor(new Date(2026, 8, 28, 20, 0).getTime() / 60_000), @@ -63,6 +64,7 @@ vi.mock('@/src/utils/trpc', () => { if (key === 'health.maintenance') return { data: mocks.maintenance } if (key === 'health.system') return { data: { scheduler: { drift: { drifted: false } } } } if (key === 'waterLevel.getLatest') return { data: mocks.water } + if (key === 'device.getStatus') return { data: mocks.device } if (key === 'schedules.getAll') return { data: mocks.schedules[input?.side ?? ''] } if (key === 'health.thermal') return { data: mocks.thermal, isLoading: false, isFetching: false, error: null, dataUpdatedAt: 1 } if (key === 'health.scheduler') return { data: mocks.scheduler, isLoading: false } @@ -112,6 +114,7 @@ beforeEach(() => { mocks.timeFormat = '12h' mocks.dataPath = undefined mocks.frames = {} + mocks.device = undefined single.sides = null mocks.thermal = { pumpStallProtectionEnabled: true, @@ -226,6 +229,13 @@ describe('DiagnosticsConsole dashboard', () => { expect(screen.queryByTestId('attention')).toBeNull() }) + it('falls back to the DAC water level before the first water reading', () => { + mocks.water = undefined + mocks.device = { waterLevel: 'low', isPriming: false } + render() + expect(within(screen.getByTestId('attention')).getByText('Water level is low')).toBeTruthy() + }) + it('adds a water tank fact only once frzWater arrives', () => { const { rerender } = render() const line = () => screen.getByTestId('status-line') From 1bba8a5f09c1df93bdfd37b0def5e1b6b6401353 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Wed, 28 Oct 2026 22:50:52 -0700 Subject: [PATCH 5/5] fix(ui): keep sensHealth and frzWater subscribed outside System and warn on a low frzWater tank Co-Authored-By: Claude Opus 5.5 --- .../AppShell/tests/navigation.test.tsx | 5 +- src/components/AppShell/usePodHealth.ts | 7 +-- src/components/Sensors/FreezerHealthCard.tsx | 3 +- .../Sensors/tests/sensorCards.test.tsx | 4 ++ src/components/status/WaterLevelCard.tsx | 3 +- .../status/tests/WaterLevelCard.test.tsx | 4 +- src/hooks/tests/useSensorStream.test.ts | 51 +++++++++++++++++++ src/hooks/useSensorStream.ts | 42 +++++++++------ 8 files changed, 96 insertions(+), 23 deletions(-) diff --git a/src/components/AppShell/tests/navigation.test.tsx b/src/components/AppShell/tests/navigation.test.tsx index 3ea228722..7641b0862 100644 --- a/src/components/AppShell/tests/navigation.test.tsx +++ b/src/components/AppShell/tests/navigation.test.tsx @@ -15,7 +15,8 @@ vi.mock('next/navigation', () => ({ usePathname: () => state.path, useSearchPara vi.mock('@/src/hooks/useScheduleActive', () => ({ useScheduleActive: () => ({ isActive: state.active }) })) vi.mock('@/src/hooks/useSwipeNavigation', () => ({ useSwipeNavigation: () => ({ onTouchStart: state.start, onTouchEnd: state.end }) })) vi.mock('@/src/components/Schedule/CurveChart', () => ({ useNowMinute: () => 29000000 })) -vi.mock('@/src/hooks/useSensorStream', () => ({ useSensorFrame: (type: string) => state.frames[type] })) +const subscription = vi.hoisted(() => vi.fn()) +vi.mock('@/src/hooks/useSensorStream', () => ({ useSensorFrame: (type: string) => state.frames[type], useSensorSubscription: subscription })) vi.mock('@/src/utils/trpc', () => ({ trpc: { base: { getAvailability: { useQuery: () => ({ data: state.base }) } }, device: { getStatus: { useQuery: () => ({ data: state.status }) } }, @@ -95,6 +96,8 @@ describe('responsive navigation', () => { it('lists a disconnected cover sensor only when sensHealth reports it', () => { state.health = { status: 'ok', database: { status: 'ok' }, scheduler: {}, iptables: { ok: true } } const { rerender } = render() + // The shell keeps sensHealth subscribed on every page, not just System. + expect(subscription).toHaveBeenCalledWith({ sensors: ['sensHealth'] }) expect(screen.queryByTestId('footer-issues')).toBeNull() state.frames = { sensHealth: { type: 'sensHealth', ts: 1, version: 1, left: { connected: true }, right: { connected: true } } } rerender() diff --git a/src/components/AppShell/usePodHealth.ts b/src/components/AppShell/usePodHealth.ts index f3cf9d5b4..036a578d2 100644 --- a/src/components/AppShell/usePodHealth.ts +++ b/src/components/AppShell/usePodHealth.ts @@ -2,21 +2,22 @@ import { attentionItems, disconnectedCovers } from '@/src/components/diagnostics/dashboardLogic' import { useNowMinute } from '@/src/components/Schedule/CurveChart' -import { useSensorFrame } from '@/src/hooks/useSensorStream' +import { useSensorFrame, useSensorSubscription } from '@/src/hooks/useSensorStream' import { trpc } from '@/src/utils/trpc' import { footerStatus } from './footerStatus' /** * The pod's health as the navigation shows it: failing system checks plus the * System Dashboard's attention items. Shared by the sidebar footer and the - * phone tab bar's System dot. Cover health is read from the sensor stream - * without subscribing, so it shows while a page (System) streams sensHealth. + * phone tab bar's System dot. Cover health comes from the sensor stream's + * sensHealth frames (newer cover firmware; older pods never send one). */ export function usePodHealth() { const status = trpc.device.getStatus.useQuery({}, { staleTime: 10_000, refetchInterval: 30_000 }) const health = trpc.health.system.useQuery({}, { staleTime: 10_000, refetchInterval: 30_000 }) const maintenance = trpc.health.maintenance.useQuery({}, { staleTime: 30_000, refetchInterval: 60_000 }) const water = trpc.waterLevel.getLatest.useQuery({}, { staleTime: 10_000, refetchInterval: 30_000 }) + useSensorSubscription({ sensors: ['sensHealth'] }) const sensHealth = useSensorFrame('sensHealth') const nowMinute = useNowMinute() const attention = nowMinute == null diff --git a/src/components/Sensors/FreezerHealthCard.tsx b/src/components/Sensors/FreezerHealthCard.tsx index 4e05ba601..7fa046b69 100644 --- a/src/components/Sensors/FreezerHealthCard.tsx +++ b/src/components/Sensors/FreezerHealthCard.tsx @@ -106,7 +106,8 @@ export function FreezerHealthCard() { ) } - const status = freezerStatus(frzHealth, waterLevel, hasData) + // A low tank from frzWater warns too, even while the DAC still reads ok. + const status = freezerStatus(frzHealth, tank?.tone === 'warn' ? 'low' : waterLevel, hasData) const t = (v: number | null | undefined, decimals = 1) => formatDisplayTemp(v, unit, { decimals, includeUnit: false, nullDisplay: '--' }) diff --git a/src/components/Sensors/tests/sensorCards.test.tsx b/src/components/Sensors/tests/sensorCards.test.tsx index 7ec7b68e0..7d267b51b 100644 --- a/src/components/Sensors/tests/sensorCards.test.tsx +++ b/src/components/Sensors/tests/sensorCards.test.tsx @@ -105,6 +105,7 @@ it('keeps the binary water level until a frzWater frame arrives, then shows the mock.frames = { ...live, frzWater: { type: 'frzWater', ts: 1, isPriming: true, hasWater: true, capValid: true, capLevel: 2, needsPriming: false } } rerender() + expect(screen.getAllByText('Normal')).toHaveLength(2) expect(screen.queryByText(/water ok/)).toBeNull() expect(screen.getAllByRole('img', { name: 'Water tank 2 of 5' })).toHaveLength(2) expect(screen.getAllByText('Priming…')).toHaveLength(2) @@ -114,6 +115,9 @@ it('keeps the binary water level until a frzWater frame arrives, then shows the rerender() expect(screen.getAllByRole('img', { name: 'Water low, tank level unavailable' })).toHaveLength(2) expect(screen.getAllByText('Needs prime')).toHaveLength(2) + // The DAC still reads ok, but the tank says no water: the status dot warns too. + expect(screen.getAllByText('Water low')).toHaveLength(2) + expect(screen.queryByText('Normal')).toBeNull() }) it('uses server occupancy while visualizing frame variance independently', () => { diff --git a/src/components/status/WaterLevelCard.tsx b/src/components/status/WaterLevelCard.tsx index da332dac4..a1574e908 100644 --- a/src/components/status/WaterLevelCard.tsx +++ b/src/components/status/WaterLevelCard.tsx @@ -2,7 +2,7 @@ import { Droplets, Play } from 'lucide-react' import { trpc } from '@/src/utils/trpc' -import { useSensorFrame } from '@/src/hooks/useSensorStream' +import { useSensorFrame, useSensorSubscription } from '@/src/hooks/useSensorStream' import { Button, Card, CardHeader, Skeleton } from '@/src/components/ds' import { cn } from '@/lib/utils' import { dailyWaterLevels, useWaterHistory, type WaterDay } from './waterHistory' @@ -34,6 +34,7 @@ function dayLabel(level: WaterDay): string { export function WaterLevelCard({ onOpen }: { onOpen: () => void }) { const { data: history, isLoading: historyLoading } = useWaterHistory() const { data: status, isLoading: statusLoading } = trpc.device.getStatus.useQuery({}, { refetchInterval: 10_000 }) + useSensorSubscription({ sensors: ['frzWater'] }) const frzWater = useSensorFrame('frzWater') const tank = waterTank(frzWater) const priming = !!status?.isPriming || !!frzWater?.isPriming diff --git a/src/components/status/tests/WaterLevelCard.test.tsx b/src/components/status/tests/WaterLevelCard.test.tsx index a2ed762cc..2b9153ec5 100644 --- a/src/components/status/tests/WaterLevelCard.test.tsx +++ b/src/components/status/tests/WaterLevelCard.test.tsx @@ -14,7 +14,8 @@ const mock = vi.hoisted(() => ({ statusLoading: false, frames: {} as Record, })) -vi.mock('@/src/hooks/useSensorStream', () => ({ useSensorFrame: (type: string) => mock.frames[type] })) +const subscription = vi.hoisted(() => vi.fn()) +vi.mock('@/src/hooks/useSensorStream', () => ({ useSensorFrame: (type: string) => mock.frames[type], useSensorSubscription: subscription })) vi.mock('@/src/utils/trpc', () => ({ trpc: { waterLevel: { getHistory: { useQuery: () => ({ data: mock.history, isLoading: mock.historyLoading }) } }, device: { getStatus: { useQuery: () => ({ data: mock.status, isLoading: mock.statusLoading }) } }, @@ -64,6 +65,7 @@ it('opens the prime dialog and disables the button while priming', () => { it('keeps the header and prime state from device status without a frzWater frame', () => { mock.status = { isPriming: false } render( {}} />) + expect(subscription).toHaveBeenCalledWith({ sensors: ['frzWater'] }) expect(screen.queryByTestId('water-tank')).toBeNull() expect(screen.getByText('7 days')).toBeTruthy() expect((screen.getByRole('button', { name: /start prime/i }) as HTMLButtonElement).disabled).toBe(false) diff --git a/src/hooks/tests/useSensorStream.test.ts b/src/hooks/tests/useSensorStream.test.ts index 806706dd0..3e27b648f 100644 --- a/src/hooks/tests/useSensorStream.test.ts +++ b/src/hooks/tests/useSensorStream.test.ts @@ -16,6 +16,7 @@ import { useSensorFrame, useSensorStream, useSensorStreamStatus, + useSensorSubscription, } from '../useSensorStream' const sensorSingleton = (globalThis as any).__sleepypod_sensorStream @@ -397,6 +398,56 @@ describe('useSensorStream', () => { }) }) +describe('useSensorSubscription', () => { + it('merges its sensors with other consumers without re-rendering on frames', async () => { + const stream = renderHook(() => useSensorStream({ sensors: ['capSense'] })) + let renders = 0 + const sub = renderHook(() => { + renders++ + useSensorSubscription({ sensors: ['sensHealth'] }) + }) + await waitFor(() => expect(wsMock.sockets.length).toBeGreaterThan(0)) + const ws = wsMock.sockets[0] as FakeWS + act(() => ws.triggerOpen()) + const last = () => JSON.parse(ws.sent.at(-1) as string) + expect(last()).toEqual({ type: 'subscribe', snapshot: true, sensors: expect.arrayContaining(['capSense', 'sensHealth']) }) + const before = renders + act(() => ws.triggerMessage({ type: 'capSense', ts: 1, left: 1, right: 2 })) + expect(renders).toBe(before) + + // The other consumer leaves: the connection stays up with sensHealth alone. + stream.unmount() + expect(ws.readyState).toBe(1) + expect(last().sensors).toEqual(['sensHealth']) + sub.unmount() + expect(ws.readyState).toBe(3) + }) + + it('re-requests a snapshot after a dropped connection so frames recover', async () => { + const sub = renderHook(() => useSensorSubscription({ sensors: ['sensHealth'] })) + const frame = renderHook(() => useSensorFrame('sensHealth')) + await waitFor(() => expect(wsMock.sockets.length).toBeGreaterThan(0)) + vi.useFakeTimers() + const ws = wsMock.sockets[0] as FakeWS + act(() => ws.triggerOpen()) + act(() => ws.triggerClose()) + act(() => vi.advanceTimersByTime(1_000)) + const next = wsMock.sockets[1] as FakeWS + act(() => next.triggerOpen()) + expect(JSON.parse(next.sent.at(-1) as string)).toEqual({ type: 'subscribe', snapshot: true, sensors: ['sensHealth'] }) + const health = { type: 'sensHealth', ts: 5, version: 1, left: { connected: false }, right: { connected: true } } + act(() => next.triggerMessage({ type: 'snapshot', latest: [health], waveform: [] })) + expect(frame.result.current).toEqual(health) + frame.unmount() + sub.unmount() + }) + + it('stays closed when disabled', () => { + renderHook(() => useSensorSubscription({ sensors: ['frzWater'], enabled: false })) + expect(wsMock.sockets.length).toBe(0) + }) +}) + describe('useSensorFrame', () => { it('exposes lps diagnostic payloads without normalizing bytes or temperatures', async () => { const stream = renderHook(() => useSensorStream({ sensors: ['lps'] })) diff --git a/src/hooks/useSensorStream.ts b/src/hooks/useSensorStream.ts index eb63f2635..1c1131ecc 100644 --- a/src/hooks/useSensorStream.ts +++ b/src/hooks/useSensorStream.ts @@ -696,6 +696,32 @@ export interface UseSensorStreamOptions { * ``` */ export function useSensorStream(options: UseSensorStreamOptions = {}) { + useSensorSubscription(options) + + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + + // Stable references for seek API (no deps — they use the singleton directly) + const seek = useCallback((timestamp: number) => sendSeek(timestamp), []) + const getTimeRange = useCallback(() => sendGetTimeRange(), []) + + return { + ...snapshot, + seekWaveform, + goLive, + /** Send a seek request — server replays frames from the given timestamp (epoch seconds). */ + seek, + /** Request the available time range for scrubbing. */ + getTimeRange, + } +} + +/** + * Keep the shared connection open and add `sensors` to the merged + * subscription, without re-rendering on stream state. For long-lived + * consumers (the app shell) that read frames through `useSensorFrame`. + * Each reconnect re-requests a snapshot, so the latest frames come back. + */ +export function useSensorSubscription(options: UseSensorStreamOptions = {}) { const { sensors = null, enabled = true } = options const sensorsKey = sensors ? sensors.slice().sort().join(',') : 'all' @@ -735,22 +761,6 @@ export function useSensorStream(options: UseSensorStreamOptions = {}) { } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sensorsKey, enabled, subId]) - - const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) - - // Stable references for seek API (no deps — they use the singleton directly) - const seek = useCallback((timestamp: number) => sendSeek(timestamp), []) - const getTimeRange = useCallback(() => sendGetTimeRange(), []) - - return { - ...snapshot, - seekWaveform, - goLive, - /** Send a seek request — server replays frames from the given timestamp (epoch seconds). */ - seek, - /** Request the available time range for scrubbing. */ - getTimeRange, - } } /**