From 942de53a317bfc558cc5ccd1fc829104e523f0fa Mon Sep 17 00:00:00 2001 From: Oliver Nemec <28827159+onemec@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:59:28 -0400 Subject: [PATCH 1/7] feat(sensors): map frzWater and sensHealth in streamGroups (#873) --- src/components/Sensors/streamGroups.ts | 4 ++-- src/components/Sensors/tests/sensorLogic.test.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) 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/sensorLogic.test.ts b/src/components/Sensors/tests/sensorLogic.test.ts index 780a014fe..622f8caa9 100644 --- a/src/components/Sensors/tests/sensorLogic.test.ts +++ b/src/components/Sensors/tests/sensorLogic.test.ts @@ -15,8 +15,10 @@ describe('streamGroups', () => { expect(groupForType('piezo-dual')).toBe('piezo') expect(groupForType('capSense')).toBe('presence') expect(groupForType('capSense2')).toBe('presence') + expect(groupForType('sensHealth')).toBe('presence') expect(groupForType('bedTemp2')).toBe('bedTemp') expect(groupForType('frzTherm')).toBe('freezer') + expect(groupForType('frzWater')).toBe('freezer') expect(groupForType('gesture')).toBe('log') expect(groupForType('unknown')).toBeUndefined() }) From 7015f09155584250486e0f84ed6f72996edb38aa Mon Sep 17 00:00:00 2001 From: Oliver Nemec <28827159+onemec@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:07:09 -0400 Subject: [PATCH 2/7] feat(diagnostics): add cover-disconnect attention item (#873) --- src/components/diagnostics/dashboardLogic.ts | 24 ++++++++++++++++++- .../diagnostics/tests/dashboardLogic.test.ts | 17 +++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/components/diagnostics/dashboardLogic.ts b/src/components/diagnostics/dashboardLogic.ts index 6325afc38..33e4f6032 100644 --- a/src/components/diagnostics/dashboardLogic.ts +++ b/src/components/diagnostics/dashboardLogic.ts @@ -58,7 +58,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-disconnect' title: string detail: string } @@ -83,8 +83,30 @@ export function attentionItems( waterLevel: string | undefined, now: number, suspectSides: ReadonlyArray = [], + coverDisconnects?: { left?: boolean, right?: boolean }, ): AttentionItem[] { const out: AttentionItem[] = [] + if (coverDisconnects?.left && coverDisconnects?.right) { + out.push({ + id: 'cover-disconnect', + title: 'Both cover sensors disconnected', + detail: 'Check physical cable connections to the left and right cover sensors.', + }) + } + else if (coverDisconnects?.left) { + out.push({ + id: 'cover-disconnect', + title: 'Left cover sensor disconnected', + detail: 'Check the physical cable connection to the left cover sensor.', + }) + } + else if (coverDisconnects?.right) { + out.push({ + id: 'cover-disconnect', + title: 'Right cover sensor disconnected', + detail: 'Check the physical cable connection to the right cover sensor.', + }) + } if (suspectSides.length > 0) { out.push({ id: 'occupancy', diff --git a/src/components/diagnostics/tests/dashboardLogic.test.ts b/src/components/diagnostics/tests/dashboardLogic.test.ts index d7e6373c8..95e167ee0 100644 --- a/src/components/diagnostics/tests/dashboardLogic.test.ts +++ b/src/components/diagnostics/tests/dashboardLogic.test.ts @@ -85,4 +85,21 @@ describe('attentionItems', () => { it('flags low water', () => { expect(attentionItems(ok, 'low', now).map(i => i.id)).toEqual(['water']) }) + + it('flags cover sensor disconnects when explicitly reported by sensHealth', () => { + expect(attentionItems(ok, 'ok', now, [], { left: false, right: false })).toEqual([]) + expect(attentionItems(ok, 'ok', now, [], undefined)).toEqual([]) + + const leftOnly = attentionItems(ok, 'ok', now, [], { left: true, right: false }) + expect(leftOnly.map(i => i.id)).toContain('cover-disconnect') + expect(leftOnly.find(i => i.id === 'cover-disconnect')?.title).toBe('Left cover sensor disconnected') + + const rightOnly = attentionItems(ok, 'ok', now, [], { left: false, right: true }) + expect(rightOnly.map(i => i.id)).toContain('cover-disconnect') + expect(rightOnly.find(i => i.id === 'cover-disconnect')?.title).toBe('Right cover sensor disconnected') + + const both = attentionItems(ok, 'ok', now, [], { left: true, right: true }) + expect(both.map(i => i.id)).toContain('cover-disconnect') + expect(both.find(i => i.id === 'cover-disconnect')?.title).toBe('Both cover sensors disconnected') + }) }) From 48cc518cef8b0333b9231dd0e297d0573ef7c81f Mon Sep 17 00:00:00 2001 From: Oliver Nemec <28827159+onemec@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:08:53 -0400 Subject: [PATCH 3/7] feat(system): surface cover disconnect in dashboard attention and footer (#873) --- src/components/AppShell/tests/footerStatus.test.ts | 4 ++++ src/components/AppShell/usePodHealth.ts | 10 +++++++++- src/components/diagnostics/DashboardPanel.tsx | 10 +++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/components/AppShell/tests/footerStatus.test.ts b/src/components/AppShell/tests/footerStatus.test.ts index bbaac6549..864ca6889 100644 --- a/src/components/AppShell/tests/footerStatus.test.ts +++ b/src/components/AppShell/tests/footerStatus.test.ts @@ -26,6 +26,10 @@ describe('footerStatus', () => { const s = footerStatus(ok, [{ id: 'pump-stall', title: 'Pump-stall protection is off', detail: '' }]) expect(s).toEqual({ tone: 'warn', summary: '1 issue', issues: ['Pump-stall protection is off'] }) }) + it('counts cover-disconnect attention items', () => { + const s = footerStatus(ok, [{ id: 'cover-disconnect', title: 'Left cover sensor disconnected', detail: '' }]) + expect(s).toEqual({ tone: 'warn', summary: '1 issue', issues: ['Left cover sensor disconnected'] }) + }) }) describe('buildLine', () => { diff --git a/src/components/AppShell/usePodHealth.ts b/src/components/AppShell/usePodHealth.ts index 26682d7cc..7f0a2ae98 100644 --- a/src/components/AppShell/usePodHealth.ts +++ b/src/components/AppShell/usePodHealth.ts @@ -2,6 +2,7 @@ import { attentionItems } 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' @@ -15,9 +16,16 @@ export function usePodHealth() { 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 coverDisconnects = sensHealth + ? { + left: !sensHealth.left.connected, + right: !sensHealth.right.connected, + } + : undefined 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, [], coverDisconnects) return { footer: footerStatus(health.data, attention), podVersion: status.data?.podVersion } } diff --git a/src/components/diagnostics/DashboardPanel.tsx b/src/components/diagnostics/DashboardPanel.tsx index 5c3841784..fac99c43d 100644 --- a/src/components/diagnostics/DashboardPanel.tsx +++ b/src/components/diagnostics/DashboardPanel.tsx @@ -28,6 +28,7 @@ import { attentionItems, isPodLaneJob, jobsInWindow, nextTemperatureJob, podJobL import { nearestPoint, panelDomain, seriesPath, THERMAL_PANELS } from './thermalHistoryLogic' import type { DiagSection } from './DiagnosticsConsole' import { OccupancyCheck } from './OccupancyCheck' +import { useSensorFrame } from '@/src/hooks/useSensorStream' type ThermalData = inferRouterOutputs['health']['thermal'] type ThermalSide = ThermalData['sides'][number] @@ -209,12 +210,19 @@ function AttentionCard() { const prime = trpc.device.startPriming.useMutation({ onSuccess: () => void utils.device.getStatus.invalidate(), }) + const sensHealth = useSensorFrame('sensHealth') const nowMinute = useNowMinute() 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 coverDisconnects = sensHealth + ? { + left: shown.includes('left') && !sensHealth.left.connected, + right: shown.includes('right') && !sensHealth.right.connected, + } + : undefined + const items = attentionItems(maintenance.data, water.data?.level ?? device.data?.waterLevel, nowMinute * 60_000, suspectSides, coverDisconnects) if (items.length === 0) return null const priming = device.data?.isPriming ?? false From f2fbb448dd1650d1f29d866f7573bda02c1c80ae Mon Sep 17 00:00:00 2001 From: Oliver Nemec <28827159+onemec@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:10:23 -0400 Subject: [PATCH 4/7] feat(sensors): show disconnected indicator in PresenceCard (#873) --- src/components/Sensors/PresenceCard.tsx | 12 ++++++++---- src/components/Sensors/tests/sensorCards.test.tsx | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/components/Sensors/PresenceCard.tsx b/src/components/Sensors/PresenceCard.tsx index dffb98442..18d1dd89a 100644 --- a/src/components/Sensors/PresenceCard.tsx +++ b/src/components/Sensors/PresenceCard.tsx @@ -54,6 +54,7 @@ export function presenceSub(occ: { available: boolean, classification: 'person' export function PresenceCard() { const capSense = useSensorFrame('capSense') const capSense2 = useSensorFrame('capSense2') + const sensHealth = useSensorFrame('sensHealth') const frame: CapSenseFrame | CapSense2Frame | undefined = capSense2 ?? capSense const { sideName } = useSideNames() @@ -115,6 +116,7 @@ export function PresenceCard() {
{(['left', 'right'] as const).map((side) => { const occ = occupancyQuery.data?.[side] + const disconnected = sensHealth ? !sensHealth[side].connected : false return ( ) })} @@ -134,7 +137,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 +145,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)) @@ -150,10 +154,10 @@ function PresenceSide({ name, side, occupied, known, sub, variance, hasFrames }: return (
- - {`${name} · ${known ? (occupied ? 'in bed' : 'empty') : '--'}`} + + {`${name} · ${disconnected ? 'disconnected' : known ? (occupied ? 'in bed' : 'empty') : '--'}`} - {sub} + {disconnected ? 'cover disconnected' : sub}
{ZONES.map((zone, i) => ( expect(screen.getByTestId('presence-right').textContent).toContain('in bedstill') }) +it('warns when a cover sensor is disconnected according to sensHealth', () => { + mock.frames.sensHealth = { type: 'sensHealth', left: { connected: false }, right: { connected: true } } + 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() + expect(screen.getByTestId('presence-left').textContent).toContain('disconnected') + expect(screen.getByTestId('presence-left').textContent).toContain('cover disconnected') + expect(screen.getByTestId('presence-right').textContent).not.toContain('disconnected') + + // When sensHealth is absent (older firmware), it does not show disconnected + delete mock.frames.sensHealth + rerender() + expect(screen.getByTestId('presence-left').textContent).not.toContain('disconnected') +}) + it('switches flow units and ranges with live readings and downsampled history', () => { const { rerender } = render() expect(screen.getByText('No flow history')).toBeTruthy() From 6300dfd6fc320570244e36b2f24ded584a861ba8 Mon Sep 17 00:00:00 2001 From: Oliver Nemec <28827159+onemec@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:17:47 -0400 Subject: [PATCH 5/7] feat(water): add 5-bar capacitive gauge and priming indicator (#873) --- src/components/Sensors/FreezerHealthCard.tsx | 53 ++++++++++-- .../Sensors/tests/sensorCards.test.tsx | 13 +++ .../Sensors/tests/sensorLogic.test.ts | 10 +++ src/components/status/WaterLevelCard.tsx | 29 +++++-- src/components/status/WaterTankGauge.tsx | 83 +++++++++++++++++++ .../status/tests/WaterLevelCard.test.tsx | 19 +++++ .../status/tests/WaterTankGauge.test.tsx | 46 ++++++++++ 7 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 src/components/status/WaterTankGauge.tsx create mode 100644 src/components/status/tests/WaterTankGauge.test.tsx diff --git a/src/components/Sensors/FreezerHealthCard.tsx b/src/components/Sensors/FreezerHealthCard.tsx index ddb01bbd7..9d9a98a0b 100644 --- a/src/components/Sensors/FreezerHealthCard.tsx +++ b/src/components/Sensors/FreezerHealthCard.tsx @@ -3,11 +3,13 @@ import { SensorAge } from './SensorAge' import { useSensorFrame } from '@/src/hooks/useSensorStream' -import type { FrzHealthFrame } from '@/src/hooks/useSensorStream' +import type { FrzHealthFrame, FrzWaterFrame } from '@/src/hooks/useSensorStream' 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 { cn } from '@/lib/utils' +import { WaterTankGauge } from '@/src/components/status/WaterTankGauge' /** 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. */ @@ -24,9 +26,15 @@ export function freezerStatus( health: FrzHealthFrame | undefined, waterLevel: 'low' | 'ok' | undefined, hasData: boolean, + frzWater?: FrzWaterFrame | undefined, ): { tone: Tone, label: string } { if (!hasData) return { tone: 'muted', label: 'No data' } - if (waterLevel === 'low') return { tone: 'warn', label: 'Water low' } + const isLow = frzWater + ? (frzWater.capValid ? frzWater.capLevel <= 1 : !frzWater.hasWater) + : waterLevel === 'low' + if (isLow) return { tone: 'warn', label: 'Water low' } + if (frzWater?.isPriming) return { tone: 'ok', label: 'Priming' } + if (frzWater?.needsPriming) return { tone: 'warn', label: 'Needs prime' } if (health && (health.left.tecCurrent > TEC_OVERCURRENT_AMPS || health.right.tecCurrent > TEC_OVERCURRENT_AMPS)) { return { tone: 'warn', label: 'High TEC' } } @@ -51,6 +59,7 @@ export function FreezerHealthCard() { const frzTemp = useSensorFrame('frzTemp') const frzHealth = useSensorFrame('frzHealth') const frzTherm = useSensorFrame('frzTherm') + const frzWater = useSensorFrame('frzWater') // tRPC: latest freezer temp from DB (fallback when WS hasn't sent data) const latestFreezerTemp = trpc.environment.getLatestFreezerTemp.useQuery( @@ -89,7 +98,7 @@ export function FreezerHealthCard() { : null const waterLevel = waterLevelLatest.data?.level as 'low' | 'ok' | undefined - const hasData = !!(temps || frzHealth || frzTherm) + const hasData = !!(temps || frzHealth || frzTherm || frzWater) // First paint with no live frames yet: hold the slot while the stored fallback loads. if (!hasData && latestFreezerTemp.isLoading) { @@ -100,7 +109,7 @@ export function FreezerHealthCard() { ) } - const status = freezerStatus(frzHealth, waterLevel, hasData) + const status = freezerStatus(frzHealth, waterLevel, hasData, frzWater) const t = (v: number | null | undefined, decimals = 1) => formatDisplayTemp(v, unit, { decimals, includeUnit: false, nullDisplay: '--' }) @@ -111,12 +120,22 @@ export function FreezerHealthCard() { : '--' const rpm = (v: number | undefined) => v === undefined ? '--' : v.toLocaleString() + const tankText = frzWater + ? (frzWater.isPriming + ? 'priming…' + : frzWater.needsPriming + ? 'needs prime' + : frzWater.capValid + ? `tank ${frzWater.capLevel}/5` + : `water ${frzWater.hasWater ? 'ok' : 'low'}`) + : (waterLevel && `water ${waterLevel}`) + const secondary = [ frzHealth && `fan ${frzHealth.fan.rpm.toLocaleString()} rpm`, 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}`, + tankText, ].filter(Boolean) as string[] return ( @@ -125,7 +144,15 @@ export function FreezerHealthCard() { Freezer - + + {temps ? `${t(temps.heatsink, 0)} sink · ${t(temps.leftWater ?? temps.rightWater, 0)} water` : '--'} @@ -138,7 +165,19 @@ export function FreezerHealthCard() {
- +
+
+ {water} + +
+
Water
+
diff --git a/src/components/Sensors/tests/sensorCards.test.tsx b/src/components/Sensors/tests/sensorCards.test.tsx index 0268dee8c..3bd713c28 100644 --- a/src/components/Sensors/tests/sensorCards.test.tsx +++ b/src/components/Sensors/tests/sensorCards.test.tsx @@ -92,6 +92,19 @@ it('shows stored and live freezer readings, water warnings and pump RPM', () => expect(screen.getAllByText('Normal')).toHaveLength(2) expect(screen.getByText('2,400')).toBeTruthy() expect(screen.getByText(/therm 1.0\/2.0/)).toBeTruthy() + + // With frzWater frame, displays capacitive tank gauge and secondary text + mock.frames.frzWater = { type: 'frzWater', capLevel: 3, capValid: true, hasWater: true, isPriming: false, needsPriming: false } + rerender() + expect(screen.getAllByTestId('water-tank-gauge')).toHaveLength(2) + expect(screen.getAllByText('3/5')).toHaveLength(2) + expect(screen.getByText(/tank 3\/5/)).toBeTruthy() + + // When priming in progress + mock.frames.frzWater = { type: 'frzWater', capLevel: 3, capValid: true, hasWater: true, isPriming: true, needsPriming: false } + rerender() + expect(screen.getAllByText('Priming')).toHaveLength(2) + expect(screen.getByText(/priming…/)).toBeTruthy() }) it('uses server occupancy while visualizing frame variance independently', () => { diff --git a/src/components/Sensors/tests/sensorLogic.test.ts b/src/components/Sensors/tests/sensorLogic.test.ts index 622f8caa9..c2d1b3bc8 100644 --- a/src/components/Sensors/tests/sensorLogic.test.ts +++ b/src/components/Sensors/tests/sensorLogic.test.ts @@ -76,6 +76,16 @@ describe('freezerStatus', () => { expect(freezerStatus(health({ fan: 0, fanDuty: 0 }), 'ok', true).label).toBe('Normal') expect(freezerStatus(health({ fan: 100, fanDuty: 50 }), 'ok', true).label).toBe('Normal') }) + + it('evaluates water level and priming state when frzWater frame is available', () => { + const baseWater = { type: 'frzWater' as const, ts: 1, capLevel: 4, capValid: true, hasWater: true, isPriming: false, needsPriming: false } + expect(freezerStatus(health(), 'ok', true, { ...baseWater, capLevel: 1 })).toEqual({ tone: 'warn', label: 'Water low' }) + expect(freezerStatus(health(), 'ok', true, { ...baseWater, capLevel: 4 })).toEqual({ tone: 'ok', label: 'Normal' }) + expect(freezerStatus(health(), 'ok', true, { ...baseWater, isPriming: true })).toEqual({ tone: 'ok', label: 'Priming' }) + expect(freezerStatus(health(), 'ok', true, { ...baseWater, needsPriming: true })).toEqual({ tone: 'warn', label: 'Needs prime' }) + expect(freezerStatus(health(), 'ok', true, { ...baseWater, capValid: false, hasWater: false })).toEqual({ tone: 'warn', label: 'Water low' }) + expect(freezerStatus(health(), 'ok', true, { ...baseWater, capValid: false, hasWater: true })).toEqual({ tone: 'ok', label: 'Normal' }) + }) }) describe('zoneActivity', () => { diff --git a/src/components/status/WaterLevelCard.tsx b/src/components/status/WaterLevelCard.tsx index 4536ca057..55e843aae 100644 --- a/src/components/status/WaterLevelCard.tsx +++ b/src/components/status/WaterLevelCard.tsx @@ -3,8 +3,10 @@ import { Droplets, Play } from 'lucide-react' import { trpc } from '@/src/utils/trpc' import { Button, Card, CardHeader, Skeleton } from '@/src/components/ds' +import { useSensorFrame } from '@/src/hooks/useSensorStream' import { cn } from '@/lib/utils' import { dailyWaterLevels, useWaterHistory, type WaterDay } from './waterHistory' +import { WaterTankGauge } from './WaterTankGauge' const DAY_TONE: Record<'ok' | 'low' | 'none', { past: string, today: string }> = { ok: { past: 'bg-ok-line', today: 'bg-ok' }, @@ -29,9 +31,11 @@ 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 }) + const frzWater = useSensorFrame('frzWater') const days = dailyWaterLevels(history) const lastPrimed = status?.primeCompletedNotification?.timestamp + const isPriming = frzWater?.isPriming ?? status?.isPriming ?? false return ( @@ -39,7 +43,18 @@ export function WaterLevelCard({ onOpen }: { onOpen: () => void }) { title="Water level" icon={Droplets} iconClassName="text-cool" - right={7 days} + right={( +
+ + 7 days +
+ )} /> {historyLoading ? @@ -64,14 +79,16 @@ export function WaterLevelCard({ onOpen }: { onOpen: () => void }) { ? : ( - {status?.isPriming + {isPriming ? 'Priming…' - : lastPrimed - ? `Last primed ${new Date(lastPrimed).toLocaleDateString([], { month: 'short', day: 'numeric' })}` - : 'No recent prime'} + : frzWater?.needsPriming + ? 'Needs priming' + : lastPrimed + ? `Last primed ${new Date(lastPrimed).toLocaleDateString([], { month: 'short', day: 'numeric' })}` + : 'No recent prime'} )} -
diff --git a/src/components/status/WaterTankGauge.tsx b/src/components/status/WaterTankGauge.tsx new file mode 100644 index 000000000..9773dc2ce --- /dev/null +++ b/src/components/status/WaterTankGauge.tsx @@ -0,0 +1,83 @@ +'use client' + +import { cn } from '@/lib/utils' +import { StatusDot } from '@/src/components/ds' + +export interface WaterTankGaugeProps { + /** Capacitive water level 0–5 (from frzWater frame). */ + capLevel?: number + /** True when the capacitive sensor reading is calibrated and valid. */ + capValid?: boolean + /** Active priming in progress. */ + isPriming?: boolean + /** Firmware flagged prime needed (e.g. bubbles or refill). */ + needsPriming?: boolean + /** Binary fallback from DAC / status table: 'ok' | 'low' | undefined. */ + waterLevel?: string + className?: string +} + +export function WaterTankGauge({ + capLevel, + capValid, + isPriming, + needsPriming, + waterLevel, + className, +}: WaterTankGaugeProps) { + // If capacitive level is valid, render the 5-bar gauge + if (capValid && typeof capLevel === 'number') { + const level = Math.max(0, Math.min(5, Math.round(capLevel))) + const isLow = level <= 1 + return ( + + + {[1, 2, 3, 4, 5].map((bar) => { + const filled = bar <= level + return ( + + ) + })} + + {`${level}/5`} + {isPriming + ? ( + priming + ) + : needsPriming + ? ( + needs prime + ) + : null} + + ) + } + + // Fallback to binary ok/low when frzWater has not arrived or capValid is false + if (waterLevel === 'low' || waterLevel === 'ok') { + return ( + + + + ) + } + + return null +} diff --git a/src/components/status/tests/WaterLevelCard.test.tsx b/src/components/status/tests/WaterLevelCard.test.tsx index ef31b5803..e2074228d 100644 --- a/src/components/status/tests/WaterLevelCard.test.tsx +++ b/src/components/status/tests/WaterLevelCard.test.tsx @@ -12,6 +12,10 @@ const mock = vi.hoisted(() => ({ historyLoading: false, status: undefined as unknown, statusLoading: false, + frzWater: undefined as unknown, +})) +vi.mock('@/src/hooks/useSensorStream', () => ({ + useSensorFrame: (type: string) => (type === 'frzWater' ? mock.frzWater : undefined), })) vi.mock('@/src/utils/trpc', () => ({ trpc: { waterLevel: { getHistory: { useQuery: () => ({ data: mock.history, isLoading: mock.historyLoading }) } }, @@ -23,6 +27,7 @@ beforeEach(() => { mock.historyLoading = false mock.status = undefined mock.statusLoading = false + mock.frzWater = undefined }) it('holds the day strip and prime line with skeletons while queries load', () => { @@ -57,3 +62,17 @@ 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('renders capacitive gauge and needs priming state when frzWater is present', () => { + mock.frzWater = { capLevel: 3, capValid: true, hasWater: true, isPriming: false, needsPriming: true } + mock.status = { isPriming: false, waterLevel: 'ok' } + const { rerender } = render( {}} />) + expect(screen.getByTestId('water-tank-gauge')).toBeTruthy() + expect(screen.getByText('3/5')).toBeTruthy() + expect(screen.getByText('Needs priming')).toBeTruthy() + + // When frzWater is absent, falls back to binary state without gauge + mock.frzWater = undefined + rerender( {}} />) + expect(screen.queryByTestId('water-tank-gauge')).toBeNull() +}) diff --git a/src/components/status/tests/WaterTankGauge.test.tsx b/src/components/status/tests/WaterTankGauge.test.tsx new file mode 100644 index 000000000..f556abe73 --- /dev/null +++ b/src/components/status/tests/WaterTankGauge.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { WaterTankGauge } from '../WaterTankGauge' + +describe('WaterTankGauge', () => { + it('renders a 5-bar gauge when capValid is true', () => { + const { rerender } = render() + const gauge = screen.getByTestId('water-tank-gauge') + expect(gauge).toBeTruthy() + expect(gauge.getAttribute('aria-label')).toBe('Tank level 4 of 5 bars') + expect(screen.getByText('4/5')).toBeTruthy() + + const filledBars = gauge.querySelectorAll('.bg-cool') + expect(filledBars).toHaveLength(4) + const emptyBars = gauge.querySelectorAll('.bg-active') + expect(emptyBars).toHaveLength(1) + + // Warn tone when level is low (0 or 1) + rerender() + expect(screen.getByTestId('water-tank-gauge').querySelectorAll('.bg-warn')).toHaveLength(1) + expect(screen.getByText('1/5')).toBeTruthy() + }) + + it('shows priming and needs priming status when capacitive level is valid', () => { + const { rerender } = render() + expect(screen.getByText('priming')).toBeTruthy() + + rerender() + expect(screen.getByText('needs prime')).toBeTruthy() + }) + + it('falls back to binary ok/low state when capValid is false or frzWater is missing', () => { + const { rerender } = render() + expect(screen.queryByTestId('water-tank-gauge')).toBeNull() + expect(screen.getByTestId('water-tank-gauge-fallback')).toBeTruthy() + expect(screen.getByText('OK')).toBeTruthy() + + rerender() + expect(screen.getByText('Low')).toBeTruthy() + }) + + it('returns nothing when neither capLevel nor waterLevel is available', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) +}) From 4c6163073de1e72f0a6ef64a38cc642676f08552 Mon Sep 17 00:00:00 2001 From: Oliver Nemec <28827159+onemec@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:46:50 -0400 Subject: [PATCH 6/7] test(telemetry): expand branch and component coverage for #873 --- .../AppShell/tests/navigation.test.tsx | 14 +++++++++- .../Sensors/tests/sensorCards.test.tsx | 18 +++++++++++++ .../tests/DiagnosticsConsole.test.tsx | 26 +++++++++++++++++++ .../status/tests/WaterLevelCard.test.tsx | 9 +++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/components/AppShell/tests/navigation.test.tsx b/src/components/AppShell/tests/navigation.test.tsx index c4c4d47c0..5ba6b670c 100644 --- a/src/components/AppShell/tests/navigation.test.tsx +++ b/src/components/AppShell/tests/navigation.test.tsx @@ -9,11 +9,15 @@ 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, + sensHealth: undefined as unknown, 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/hooks/useSensorStream', () => ({ + useSensorFrame: (type: string) => (type === 'sensHealth' ? state.sensHealth : undefined), +})) vi.mock('@/src/components/Schedule/CurveChart', () => ({ useNowMinute: () => 29000000 })) vi.mock('@/src/utils/trpc', () => ({ trpc: { base: { getAvailability: { useQuery: () => ({ data: state.base }) } }, @@ -24,7 +28,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, sensHealth: undefined }) vi.clearAllMocks() }) @@ -122,6 +126,14 @@ describe('responsive navigation', () => { expect(screen.getByTestId('system-health-dot').getAttribute('data-tone')).toBe('warn') expect(screen.getByRole('link', { name: /SYSTEM, 1 issue/ })).toBeTruthy() }) + it('surfaces cover sensor disconnect warning in the sidebar footer and tab bar', () => { + state.sensHealth = { type: 'sensHealth', version: 1, left: { connected: false }, right: { connected: true } } + state.health = { status: 'ok', database: { status: 'ok' }, scheduler: {}, iptables: { ok: true } } + render() + const footer = screen.getByRole('link', { name: /Open System dashboard/ }) + expect(footer.textContent).toContain('1 issue') + expect(within(screen.getByTestId('footer-issues')).getAllByRole('listitem').map(li => li.textContent)).toEqual(['Left cover sensor disconnected']) + }) it('renders page content and forwards swipe gestures', () => { render(

Temperature

) fireEvent.touchStart(screen.getByRole('main')) diff --git a/src/components/Sensors/tests/sensorCards.test.tsx b/src/components/Sensors/tests/sensorCards.test.tsx index 3bd713c28..9c1dac9fe 100644 --- a/src/components/Sensors/tests/sensorCards.test.tsx +++ b/src/components/Sensors/tests/sensorCards.test.tsx @@ -105,6 +105,24 @@ it('shows stored and live freezer readings, water warnings and pump RPM', () => rerender() expect(screen.getAllByText('Priming')).toHaveLength(2) expect(screen.getByText(/priming…/)).toBeTruthy() + + // When tank needs priming + mock.frames.frzWater = { type: 'frzWater', capLevel: 3, capValid: true, hasWater: true, isPriming: false, needsPriming: true } + rerender() + expect(screen.getAllByText('Needs prime')).toHaveLength(2) + expect(screen.getByText(/fan.*needs prime/)).toBeTruthy() + + // When capValid is false and hasWater is true + mock.frames.frzWater = { type: 'frzWater', capLevel: 0, capValid: false, hasWater: true, isPriming: false, needsPriming: false } + rerender() + expect(screen.getAllByText('Normal')).toHaveLength(2) + expect(screen.getByText(/fan.*water ok/)).toBeTruthy() + + // When capValid is false and hasWater is false + mock.frames.frzWater = { type: 'frzWater', capLevel: 0, capValid: false, hasWater: false, isPriming: false, needsPriming: false } + rerender() + expect(screen.getAllByText('Water low')).toHaveLength(2) + expect(screen.getByText(/fan.*water low/)).toBeTruthy() }) it('uses server occupancy while visualizing frame variance independently', () => { diff --git a/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx b/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx index 243541a3c..0b3f4f136 100644 --- a/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx +++ b/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ mutations: {} as Record>, summary: undefined as unknown, timeFormat: '12h' as '12h' | '24h', + sensHealth: undefined as unknown, // 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), })) @@ -34,6 +35,14 @@ vi.mock('@/src/components/Schedule/CurveChart', async (importOriginal) => { vi.mock('next/navigation', () => ({ usePathname: () => '/en/system', useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), useSearchParams: () => new URLSearchParams() })) vi.mock('@/src/hooks/useTemperatureUnit', () => ({ useTemperatureUnit: () => ({ unit: 'F', convert: (c: number) => c, formatTemp: String, suffix: '°F' }) })) +vi.mock('@/src/hooks/useSensorStream', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + useSensorFrame: (type: string) => (type === 'sensHealth' ? mocks.sensHealth : undefined), + useOnSensorFrame: () => {}, + } +}) vi.mock('next/dynamic', () => ({ default: () => function DynamicStub() { @@ -149,6 +158,7 @@ describe('DiagnosticsConsole dashboard', () => { mocks.maintenance = { pumpStallProtectionEnabled: true, primePodDaily: true, primePodTime: '14:00', lastPrimeAt: null, firstPrimeRecordedAt: null } mocks.water = { level: 'ok' } mocks.mutations = {} + mocks.sensHealth = undefined mocks.schedules = { left: { temperature: [ { dayOfWeek: 'monday', time: '23:15', temperature: 80, enabled: true }, @@ -237,6 +247,22 @@ describe('DiagnosticsConsole dashboard', () => { expect(mocks.mutations['device.startPriming']).toHaveBeenCalledWith({}) }) + it('warns when a cover sensor is disconnected according to sensHealth', () => { + single.sides = ['left', 'right'] + mocks.sensHealth = { type: 'sensHealth', version: 1, left: { connected: false }, right: { connected: true } } + render() + const card = screen.getByTestId('attention') + expect(within(card).getByText('Left cover sensor disconnected')).toBeTruthy() + }) + + it('warns when both cover sensors are disconnected according to sensHealth', () => { + single.sides = ['left', 'right'] + mocks.sensHealth = { type: 'sensHealth', version: 1, left: { connected: false }, right: { connected: false } } + render() + const card = screen.getByTestId('attention') + expect(within(card).getByText('Both cover sensors disconnected')).toBeTruthy() + }) + it('shows only the sleeper\'s side when the other is away', () => { single.sides = ['left'] try { diff --git a/src/components/status/tests/WaterLevelCard.test.tsx b/src/components/status/tests/WaterLevelCard.test.tsx index e2074228d..7e1fc5fd9 100644 --- a/src/components/status/tests/WaterLevelCard.test.tsx +++ b/src/components/status/tests/WaterLevelCard.test.tsx @@ -71,8 +71,17 @@ it('renders capacitive gauge and needs priming state when frzWater is present', expect(screen.getByText('3/5')).toBeTruthy() expect(screen.getByText('Needs priming')).toBeTruthy() + // When frzWater indicates priming is in progress + mock.frzWater = { capLevel: 3, capValid: true, hasWater: true, isPriming: true, needsPriming: false } + mock.status = { isPriming: false } + rerender( {}} />) + expect(screen.getByText('Priming…')).toBeTruthy() + expect((screen.getByRole('button', { name: /start prime/i }) as HTMLButtonElement).disabled).toBe(true) + // When frzWater is absent, falls back to binary state without gauge mock.frzWater = undefined + mock.status = { isPriming: false, primeCompletedNotification: { timestamp: new Date(2026, 9, 20).toISOString() } } rerender( {}} />) expect(screen.queryByTestId('water-tank-gauge')).toBeNull() + expect(screen.getByText(/Last primed/)).toBeTruthy() }) From 8b22e3a3c9a963ce6ad9f1d32e0cdadb3c41f24b Mon Sep 17 00:00:00 2001 From: Oliver Nemec <28827159+onemec@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:58:22 -0400 Subject: [PATCH 7/7] test(diagnostics): cover fallback water level and hidden-side sensor branches --- .../tests/DiagnosticsConsole.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx b/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx index 0b3f4f136..95f0f795f 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(() => ({ summary: undefined as unknown, timeFormat: '12h' as '12h' | '24h', sensHealth: undefined as unknown, + deviceStatus: undefined as unknown, // 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), })) @@ -65,6 +66,7 @@ vi.mock('@/src/utils/trpc', () => { if (key === 'health.dataPath') return { data: mocks.dataPath } if (key === 'health.maintenance') return { data: mocks.maintenance } if (key === 'health.system') return { data: { scheduler: { drift: { drifted: false } } } } + if (key === 'device.getStatus') return { data: mocks.deviceStatus } if (key === 'waterLevel.getLatest') return { data: mocks.water } 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 } @@ -159,6 +161,7 @@ describe('DiagnosticsConsole dashboard', () => { mocks.water = { level: 'ok' } mocks.mutations = {} mocks.sensHealth = undefined + mocks.deviceStatus = undefined mocks.schedules = { left: { temperature: [ { dayOfWeek: 'monday', time: '23:15', temperature: 80, enabled: true }, @@ -249,6 +252,8 @@ describe('DiagnosticsConsole dashboard', () => { it('warns when a cover sensor is disconnected according to sensHealth', () => { single.sides = ['left', 'right'] + mocks.water = undefined + mocks.deviceStatus = { waterLevel: 'ok' } mocks.sensHealth = { type: 'sensHealth', version: 1, left: { connected: false }, right: { connected: true } } render() const card = screen.getByTestId('attention') @@ -263,6 +268,18 @@ describe('DiagnosticsConsole dashboard', () => { expect(within(card).getByText('Both cover sensors disconnected')).toBeTruthy() }) + it('ignores disconnected sensor for a side that is not shown', () => { + single.sides = ['right'] + mocks.sensHealth = { type: 'sensHealth', version: 1, left: { connected: false }, right: { connected: true } } + render() + expect(screen.queryByTestId('attention')).toBeNull() + + single.sides = ['left'] + mocks.sensHealth = { type: 'sensHealth', version: 1, left: { connected: true }, right: { connected: false } } + render() + expect(screen.queryByTestId('attention')).toBeNull() + }) + it('shows only the sleeper\'s side when the other is away', () => { single.sides = ['left'] try {