Skip to content
Draft
4 changes: 4 additions & 0 deletions src/components/AppShell/tests/footerStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
14 changes: 13 additions & 1 deletion src/components/AppShell/tests/navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) } },
Expand All @@ -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()
})

Expand Down Expand Up @@ -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(<Sidebar />)
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(<AppShell><h1>Temperature</h1></AppShell>)
fireEvent.touchStart(screen.getByRole('main'))
Expand Down
10 changes: 9 additions & 1 deletion src/components/AppShell/usePodHealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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 }
}
53 changes: 46 additions & 7 deletions src/components/Sensors/FreezerHealthCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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' }
}
Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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: '--' })
Expand All @@ -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 (
Expand All @@ -125,7 +144,15 @@ export function FreezerHealthCard() {
<Card className="flex-row items-center gap-2.5 px-4 py-3.5 min-[900px]:hidden">
<span className="sp-label">Freezer</span>
<StatusDot tone={status.tone} label={status.label} className="text-[13px]" />
<span className="ml-auto truncate font-mono text-xs text-fg-2">
<WaterTankGauge
capLevel={frzWater?.capLevel}
capValid={frzWater?.capValid}
isPriming={frzWater?.isPriming}
needsPriming={frzWater?.needsPriming}
waterLevel={waterLevel}
className="ml-auto"
/>
<span className={cn('truncate font-mono text-xs text-fg-2', !frzWater?.capValid && 'ml-auto')}>
{temps ? `${t(temps.heatsink, 0)} sink · ${t(temps.leftWater ?? temps.rightWater, 0)} water` : '--'}
</span>
</Card>
Expand All @@ -138,7 +165,19 @@ export function FreezerHealthCard() {
</SectionLabel>
<div className="grid grid-cols-2 gap-2.5">
<BigStat value={temps ? `${t(temps.heatsink)}` : '--'} label="Heatsink" warn={false} />
<BigStat value={water} label="Water" warn={false} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-mono text-lg font-light">{water}</span>
<WaterTankGauge
capLevel={frzWater?.capLevel}
capValid={frzWater?.capValid}
isPriming={frzWater?.isPriming}
needsPriming={frzWater?.needsPriming}
waterLevel={waterLevel}
/>
</div>
<div className="text-[11px] text-fg-2">Water</div>
</div>
<BigStat value={rpm(frzHealth?.left.pumpRpm)} label="Pump L · rpm" warn={frzHealth?.left.pumpRpm === 0} />
<BigStat value={rpm(frzHealth?.right.pumpRpm)} label="Pump R · rpm" warn={frzHealth?.right.pumpRpm === 0} />
</div>
Expand Down
12 changes: 8 additions & 4 deletions src/components/Sensors/PresenceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -115,6 +116,7 @@ export function PresenceCard() {
<div className="grid grid-cols-2 gap-3">
{(['left', 'right'] as const).map((side) => {
const occ = occupancyQuery.data?.[side]
const disconnected = sensHealth ? !sensHealth[side].connected : false
return (
<PresenceSide
key={side}
Expand All @@ -125,6 +127,7 @@ export function PresenceCard() {
sub={occ ? presenceSub(occ) : '--'}
variance={side === 'left' ? variance.leftVariance : variance.rightVariance}
hasFrames={!!frame}
disconnected={disconnected}
/>
)
})}
Expand All @@ -134,14 +137,15 @@ 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
known: boolean
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))
Expand All @@ -150,10 +154,10 @@ function PresenceSide({ name, side, occupied, known, sub, variance, hasFrames }:
return (
<div className="flex min-w-0 flex-col gap-1.5" data-testid={`presence-${side}`}>
<span className="flex min-w-0 items-center gap-1.5 text-[13px]">
<StatusDot tone={occupied ? 'ok' : 'muted'} />
<span className="truncate">{`${name} · ${known ? (occupied ? 'in bed' : 'empty') : '--'}`}</span>
<StatusDot tone={disconnected ? 'warn' : occupied ? 'ok' : 'muted'} />
<span className="truncate">{`${name} · ${disconnected ? 'disconnected' : known ? (occupied ? 'in bed' : 'empty') : '--'}`}</span>
</span>
<span className="font-mono text-[11px] text-fg-2">{sub}</span>
<span className="font-mono text-[11px] text-fg-2">{disconnected ? 'cover disconnected' : sub}</span>
<div className="flex h-[34px] items-end gap-1" aria-label={`${name} zone activity`}>
{ZONES.map((zone, i) => (
<span
Expand Down
4 changes: 2 additions & 2 deletions src/components/Sensors/streamGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
]

Expand Down
45 changes: 45 additions & 0 deletions src/components/Sensors/tests/sensorCards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,37 @@ 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(<FreezerHealthCard />)
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(<FreezerHealthCard />)
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(<FreezerHealthCard />)
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(<FreezerHealthCard />)
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(<FreezerHealthCard />)
expect(screen.getAllByText('Water low')).toHaveLength(2)
expect(screen.getByText(/fan.*water low/)).toBeTruthy()
})

it('uses server occupancy while visualizing frame variance independently', () => {
Expand All @@ -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(<PresenceCard />)
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(<PresenceCard />)
expect(screen.getByTestId('presence-left').textContent).not.toContain('disconnected')
})

it('switches flow units and ranges with live readings and downsampled history', () => {
const { rerender } = render(<FlowrateChart />)
expect(screen.getByText('No flow history')).toBeTruthy()
Expand Down
12 changes: 12 additions & 0 deletions src/components/Sensors/tests/sensorLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading