Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src/components/AppShell/tests/navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ 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<string, 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/components/Schedule/CurveChart', () => ({ useNowMinute: () => 29000000 }))
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 }) } },
Expand All @@ -24,7 +26,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()
})

Expand Down Expand Up @@ -91,6 +93,19 @@ 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(<Sidebar />)
// 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(<Sidebar />)
expect(screen.queryByTestId('footer-issues')).toBeNull()
state.frames = { sensHealth: { type: 'sensHealth', ts: 2, version: 1, left: { connected: false }, right: { connected: true } } }
rerender(<Sidebar />)
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' }
Expand Down
10 changes: 7 additions & 3 deletions src/components/AppShell/usePodHealth.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
'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, 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.
* 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')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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 }
}
30 changes: 23 additions & 7 deletions src/components/Sensors/FreezerHealthCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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()
Expand All @@ -51,6 +56,7 @@ export function FreezerHealthCard() {
const frzTemp = useSensorFrame('frzTemp')
const frzHealth = useSensorFrame('frzHealth')
const frzTherm = useSensorFrame('frzTherm')
const tank = waterTank(useSensorFrame('frzWater'))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// tRPC: latest freezer temp from DB (fallback when WS hasn't sent data)
const latestFreezerTemp = trpc.environment.getLatestFreezerTemp.useQuery(
Expand Down Expand Up @@ -100,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: '--' })
Expand All @@ -116,7 +123,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 (
Expand All @@ -125,6 +132,7 @@ 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]" />
{tank && <WaterTankGauge tank={tank} className="font-mono text-xs text-fg-2" />}
<span className="ml-auto truncate font-mono text-xs text-fg-2">
{temps ? `${t(temps.heatsink, 0)} sink · ${t(temps.leftWater ?? temps.rightWater, 0)} water` : '--'}
</span>
Expand All @@ -142,11 +150,19 @@ export function FreezerHealthCard() {
<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>
{secondary.length > 0 && (
<div className="border-t border-line pt-2.5 font-mono text-[11px] leading-relaxed text-fg-2">
{secondary.join(' · ')}
</div>
)}
{tank
? (
<div className="border-t border-line pt-2.5 font-mono text-[11px] leading-relaxed text-fg-2" data-testid="freezer-secondary">
{'tank '}
<WaterTankGauge tank={tank} className="align-middle" />
{secondary.length > 0 && ` · ${secondary.join(' · ')}`}
</div>
)
: secondary.length > 0 && (
<div className="border-t border-line pt-2.5 font-mono text-[11px] leading-relaxed text-fg-2">
{secondary.join(' · ')}
</div>
)}
</Card>
</>
)
Expand Down
9 changes: 8 additions & 1 deletion src/components/Sensors/PresenceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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}
/>
)
})}
Expand All @@ -134,14 +139,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 @@ -153,6 +159,7 @@ function PresenceSide({ name, side, occupied, known, sub, variance, hasFrames }:
<StatusDot tone={occupied ? 'ok' : 'muted'} />
<span className="truncate">{`${name} · ${known ? (occupied ? 'in bed' : 'empty') : '--'}`}</span>
</span>
{disconnected && <StatusDot tone="warn" label="Disconnected" className="text-[11px]" />}
<span className="font-mono text-[11px] text-fg-2">{sub}</span>
<div className="flex h-[34px] items-end gap-1" aria-label={`${name} zone activity`}>
{ZONES.map((zone, i) => (
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
43 changes: 43 additions & 0 deletions src/components/Sensors/tests/sensorCards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,32 @@ 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(<FreezerHealthCard />)
// 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(<FreezerHealthCard />)
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)
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(<FreezerHealthCard />)
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', () => {
const { rerender } = render(<PresenceCard />)
expect(screen.getByTestId('presence-left').textContent).toContain('left · --')
Expand All @@ -112,6 +138,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(<PresenceCard />)
// 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(<PresenceCard />)
expect(screen.queryByText('Disconnected')).toBeNull()
mock.frames.sensHealth = { type: 'sensHealth', ts: 2, version: 1, left: { connected: true }, right: { connected: false } }
rerender(<PresenceCard />)
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(<PresenceCard />)
expect(screen.getAllByText('Disconnected')).toHaveLength(2)
})

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
37 changes: 37 additions & 0 deletions src/components/Sensors/tests/streamGroups.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading