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/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/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/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/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.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()
+
+ // 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', () => {
@@ -112,6 +143,20 @@ it('uses server occupancy while visualizing frame variance independently', () =>
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()
diff --git a/src/components/Sensors/tests/sensorLogic.test.ts b/src/components/Sensors/tests/sensorLogic.test.ts
index 780a014fe..c2d1b3bc8 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()
})
@@ -74,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/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
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/DiagnosticsConsole.test.tsx b/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx
index 243541a3c..95f0f795f 100644
--- a/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx
+++ b/src/components/diagnostics/tests/DiagnosticsConsole.test.tsx
@@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => ({
mutations: {} as Record>,
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),
}))
@@ -34,6 +36,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() {
@@ -56,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 }
@@ -149,6 +160,8 @@ 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.deviceStatus = undefined
mocks.schedules = {
left: { temperature: [
{ dayOfWeek: 'monday', time: '23:15', temperature: 80, enabled: true },
@@ -237,6 +250,36 @@ 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.water = undefined
+ mocks.deviceStatus = { waterLevel: 'ok' }
+ 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('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 {
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')
+ })
})
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..7e1fc5fd9 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,26 @@ 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 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()
+})
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()
+ })
+})