Complete type-safe API layer for sleepypod-core. Provides frontend access to hardware control, settings, schedules, and biometrics data.
src/server/routers/
├── app.ts # Main router aggregator
├── device.ts # Real-time hardware control
├── settings.ts # Configuration management
├── schedules.ts # Automation schedules
└── biometrics.ts # Health data queries
Device Router (device.*)
- Real-time operations that execute immediately
- Creates new hardware connection per operation
- Use for user-initiated actions (button press, manual control)
- Higher latency due to connection overhead (~25s timeout)
Schedules Router (schedules.*)
- Define future/recurring operations
- Executed by background scheduler
- Use for automated temperature changes, alarms, power events
- No immediate hardware interaction
Current Pattern:
const client = await createHardwareClient({ socketPath })
try {
await client.executeCommand(...)
} finally {
client.disconnect() // Closes connection after every operation
}Why: Prevents connection pooling complexity and socket leaks. Trade-off: reconnection overhead for reliability.
Device Status — WebSocket First, tRPC Fallback:
Device status is primarily pushed via WebSocket. tRPC polling remains available for initial page load, non-WebSocket clients (iOS, CLI), and fallback when WS is unavailable:
sequenceDiagram
participant HW as Pod Hardware
participant DM as DacMonitor (read bus)
participant SCH as Scheduler
participant API as tRPC API :3000
participant BMS as broadcastMutationStatus (write bus)
participant WS as piezoStream WS :3001
participant UI as Browser UI
Note over DM,WS: Read bus — authoritative 2s poll
loop Every 2 seconds
DM->>HW: getDeviceStatus()
HW-->>DM: temps, power, priming, water
DM->>WS: broadcastFrame(deviceStatus)
WS->>UI: deviceStatus frame (push)
end
Note over API,BMS: Write bus — immediate after mutation
UI->>API: setTemperature / setPower / setAlarm
API->>HW: hardware command
HW-->>API: success
API->>BMS: overlay mutation onto last polled status
BMS->>WS: broadcastFrame(deviceStatus)
WS->>UI: deviceStatus frame (all clients, ~200ms)
Note over SCH,BMS: Scheduler uses same write bus
SCH->>HW: hardware command (via shared client)
HW-->>SCH: success
SCH->>BMS: overlay mutation onto last polled status
BMS->>WS: broadcastFrame(deviceStatus)
WS->>UI: deviceStatus frame (all clients)
Note over UI: useDeviceStatus() hook
Note over UI: WS primary, tRPC HTTP fallback for initial load
Note over UI: Polling disabled once WS frames arrive
The useDeviceStatus() hook (src/hooks/useDeviceStatus.ts) provides this:
- Primary:
deviceStatusframes viauseSensorFrame('deviceStatus')(~2s push) - Fallback:
trpc.device.getStatus.useQuery(HTTP, for initial load or WS disconnect) - Consumers:
TempScreen,PowerButton,SideSelector
The device.getStatus tRPC endpoint still exists for:
- Initial page load (before WS connects)
- Non-browser clients (iOS app, CLI tools)
- Fallback when WS is unavailable
All hardware operations wrap errors in TRPCError:
catch (error) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Failed to X: ${error.message}`,
cause: error
})
}Limitation: Generic error code loses hardware-specific error types. Frontend can't distinguish between connection failure vs hardware rejection.
Future: Consider error code mapping for common hardware errors (TIMEOUT, HARDWARE_BUSY, INVALID_COMMAND).
Pattern: Optimistic database updates
await client.setTemperature(...)
await db.update(deviceState).set({ targetTemperature: ... }) // OptimisticRace Condition: Clients calling getStatus() immediately after may see:
- Database: Updated target temperature
- Hardware: Still reporting old current temperature
Why: Hardware heating/cooling takes time (1-2°F per minute). Database reflects intent, hardware reflects reality.
Real-time Pod hardware control.
Procedures:
getStatus()- Current hardware state (temp, power, alarm, priming status)setTemperature(side, temp, duration?)- Set target temp, optionally with auto-off timersetPower(side, on, temp?)- Power control (on = heat/cool, off = neutral)setAlarm(side, config)- Start vibration alarm (patterns: double/rise, 1-100 intensity, 0-180s max)clearAlarm(side)- Stop vibrationsnoozeAlarm(side, duration, config)- Stop alarm, restart after snooze durationstartPriming()- Run water circulation sequence (2-5 min, loud, don't run during sleep)
Event bus: All mutation procedures (setTemperature, setPower, setAlarm, clearAlarm, snoozeAlarm) call broadcastMutationStatus() after hardware success. The scheduler's job callbacks (scheduleTemperature, schedulePowerOn/Off, scheduleAlarm) call the same function. This overlays the mutation onto dacMonitor.getLastStatus() and broadcasts a deviceStatus frame to all WS clients. Fire-and-forget — never blocks the caller. DacMonitor's 2s poll remains the authoritative consistency backstop. The shared helper lives in src/streaming/broadcastMutationStatus.ts.
Hardware Timing:
- Commands execute in ~100-500ms
- Temperature changes take 4-7 minutes (1-2°F per minute heating/cooling rate)
- Priming completes in 2-5 minutes
- Connection timeout: 25 seconds
Temperature Range: 55-110°F (hardware heating/cooling capacity limits)
Important:
- Level 0 = 82.5°F (neutral, no heating/cooling)
- No true "off" state in hardware
- Alarms start immediately (not scheduled)
- Only one alarm per side at a time
Device and side configuration management.
Procedures:
getAll()- Fetch all settings (device + both sides + gestures)updateDevice(timezone, tempUnit, reboot, priming)- Device-wide settingsupdateSide(side, name, awayMode)- Per-side configurationsetGesture(side, tapType, action, params)- Configure tap behaviorsdeleteGesture(side, tapType)- Remove tap gesture
Gestures:
- Tap types: doubleTap, tripleTap, quadTap
- Actions: temperature, alarm, snooze, power, priming
- Hardware executes gestures locally (low latency)
Away Mode:
- Disables scheduled operations for a side
- Manual control still works
- Use for extended absences
- Exactly one side away = single-sleeper bed: the biometrics modules merge
that side's presence, movement and vitals into the other side's session
(see
docs/sleep-detector.md§ Single-Sleeper Mode)
Temperature, power, and alarm automation.
Procedures:
getAll(side)- All schedules for a sidegetByDay(side, day)- Schedules for specific day of week- CRUD operations for each schedule type (temperature, power, alarm)
Schedule Types:
-
Temperature Schedules
time: When to change temperature (HH:MM format)temperature: Target in Fahrenheit (55-110°F)dayOfWeek: Which day schedule runs- Use case: Warm bed before sleep, cool during night
-
Power Schedules
onTime,offTime: Power state change timesonTemperature: Target temp when powering on- Use case: Energy saving, daily rhythm
-
Alarm Schedules
alarmTime: When vibration startsvibrationIntensity: 1-100vibrationPattern: 'double' or 'rise'duration: 0-180 seconds maxalarmTemperature: Temp to set when alarm activates- Use case: Smart wake-up with vibration + temperature
Execution:
- Background scheduler (node-schedule) executes schedules
- Timezone-aware (uses device.timezone setting)
- Disabled schedules remain in database but don't execute
- Away mode per side disables all schedules for that side
Conflict Handling:
- Multiple schedules at same time: Last created wins (no guaranteed order)
- Manual control doesn't disable schedules
- Next scheduled event will override manual setting
Query sleep and health data from Pod sensors.
Procedures:
getSleepRecords(side, dateRange?, limit)- Sleep session historygetVitals(side, dateRange?, limit)- Heart rate, HRV, breathing rategetMovement(side, dateRange?, limit)- Movement score per 60s epoch (0-1000, PIM delta-based)getLatestSleep(side)- Most recent sleep sessiongetVitalsSummary(side, startDate, endDate)- Aggregated vitals statistics
Data Collection:
- Vitals: Sampled every ~60 seconds during sleep (ballistocardiography)
- Movement: Every 60s via capacitance sensor deltas (sum of absolute sample-to-sample changes)
- Sleep records: Created on bed entry/exit detection
Data Fields:
- Heart rate: Beats per minute (SHS autocorrelation, 0.8-8.5 Hz band)
- HRV: HR variability index in ms (window-level IBI, 5-100 ms range, NOT clinical RMSSD)
- Breathing rate: Breaths per minute (Hilbert envelope of cardiac band)
- Movement: Integer 0-1000 (0-50=still, 50-200=fidgeting, 200-500=repositioning, 500+=major)
- Fields may be null if sensor couldn't get reliable reading
Performance:
- Historical data only (60-second lag for vitals and movement)
- Large date ranges can be slow (consider caching)
- Default limits sized for typical use cases (30 sleep records = ~1 month, 288 vitals = 24 hours)
CRITICAL: All procedures currently use publicProcedure - no authentication.
Before Production:
const protectedProcedure = publicProcedure.use(async ({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({ ctx: { ...ctx, user: ctx.session.user } })
})Replace all publicProcedure with protectedProcedure once session management is implemented.
// All biometrics procedures
.input(z.object({
side: z.enum(['left', 'right']),
startDate: z.date().optional(), // Inclusive
endDate: z.date().optional(), // Inclusive
limit: z.number().min(1).max(N).default(X)
}))const client = await createHardwareClient({ socketPath: DAC_SOCK_PATH })
try {
await client.executeCommand(...)
await db.update(...) // Optimistic state sync
return { success: true }
} catch (error) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: ... })
} finally {
client.disconnect()
}const conditions = [eq(table.side, input.side)]
if (input.startDate) conditions.push(gte(table.timestamp, input.startDate))
if (input.endDate) conditions.push(lte(table.timestamp, input.endDate))
const results = await db
.select()
.from(table)
.where(and(...conditions))
.orderBy(desc(table.timestamp))
.limit(input.limit)import { useDeviceStatus } from '@/src/hooks/useDeviceStatus'
// WS push (~2s) with HTTP fallback — replaces trpc.device.getStatus.useQuery
const { status, isLoading, refetch, isStreaming } = useDeviceStatus()
const leftTemp = status?.leftSide.currentTemperatureimport { useSensorStream, useSensorFrame } from '@/src/hooks/useSensorStream'
// Subscribe to specific sensor types
const { status, latestFrames } = useSensorStream({ sensors: ['capSense', 'bedTemp'] })
// Or get a single sensor's latest frame
const presence = useSensorFrame('capSense')import { trpc } from '@/src/utils/trpc'
// Historical data, settings, schedules — still HTTP
const vitals = trpc.biometrics.getVitals.useQuery({ side: 'left', limit: 100 })
// Mutations — always HTTP (need success/error confirmation)
const setTemp = trpc.device.setTemperature.useMutation()
await setTemp.mutateAsync({ side: 'left', temperature: 72 })| Data type | Transport | Hook |
|---|---|---|
| Device status (temp, power, alarm) | WebSocket push | useDeviceStatus() |
| Sensor data (piezo, bed temp) | WebSocket push | useSensorStream() / useSensorFrame() |
| Mutations (set temp, power, alarm) | tRPC HTTP | trpc.device.*.useMutation() |
| Historical data (vitals, sleep) | tRPC HTTP | trpc.biometrics.*.useQuery() |
| Settings, schedules | tRPC HTTP | trpc.settings/schedules.*.useQuery() |
Missing Test Coverage:
- No unit tests for any routers
- No integration tests for hardware operations
- No mock hardware client for testing
Recommended:
- Unit tests for input validation (Zod schemas)
- Integration tests with mock hardware client
- Database query tests with in-memory SQLite
- Error handling tests (timeout, connection failure, invalid commands)
- Hardware abstraction:
src/hardware/client.ts(detailed hardware protocol docs) - Database schema:
src/db/schema.ts - Scheduling:
src/scheduler/(execution of scheduled operations) - Frontend integration:
src/utils/trpc.ts