diff --git a/docs/adr/0023-autopilot-reactive-automations.md b/docs/adr/0023-autopilot-reactive-automations.md index a38755ddb..c031b63f2 100644 --- a/docs/adr/0023-autopilot-reactive-automations.md +++ b/docs/adr/0023-autopilot-reactive-automations.md @@ -246,10 +246,13 @@ the engine — so shipping it in P0 is premature surface. aggregates (the "P2" capability set). The engine supports them; what gates them firing on live data is the **signal-source wiring**, not the engine. - The engine handles **numeric** signals only. The builder's catalog is - therefore the numeric/backtestable subset; enum/bool signals (sleep stage, - occupancy) in the full catalog read `undefined` → "skip" until their sources - are wired. This is the safe degradation, but it means the shipped builder is - intentionally a subset of the documented catalog. + therefore the numeric/backtestable subset; enum/bool signals (sleep stage) + in the full catalog read `undefined` → "skip" until their sources are + wired. This is the safe degradation, but it means the shipped builder is + intentionally a subset of the documented catalog. Occupancy is wired as the + numeric `{side}.presence` (1 = person, 0 = empty or a light load such as a + pet; `undefined` while unsensable) and `{side}.presence.zones` — see ADR + 0025. Both are live-only: the classified signal has no history table. ### Open questions diff --git a/docs/adr/0025-presence-auto-on.md b/docs/adr/0025-presence-auto-on.md new file mode 100644 index 000000000..3e27ba7c0 --- /dev/null +++ b/docs/adr/0025-presence-auto-on.md @@ -0,0 +1,99 @@ +# ADR 0025: Presence auto-on and person-vs-pet occupancy classification + +**Status:** Accepted, shipped as experimental +**Date:** 2026-10-11 + +## Context + +Auto-off has existed since `autoOffWatcher` (per-side `autoOffEnabled` / +`autoOffMinutes`): once the shared virtual occupancy sensor +(`src/lib/occupancy.ts`) reads a side as reliably empty for the timeout, the +side is powered off. The mirror feature was missing: a sleeper who gets into a +cold bed before their schedule starts has to reach for the app. + +Both directions share one failure mode. The occupancy sensor ORs a movement +window with a calibrated capSense2 level signal, and the level signal is the +sum of three zone deviations (head, torso, legs) over one threshold. A cat on +the pillow deviates one zone hard enough to clear the sum, so it reads as +"in bed": it would hold auto-off open indefinitely today, and would trip any +naive auto-on. + +The question was whether auto-on belongs in Settings (a toggle), in the +automation engine (a rule), or both. + +## Decision + +1. **Classify in the sensor, not in the consumers.** `getOccupancy()` gains + `level.zoneDeviations`, `level.zones` (zones whose deviation exceeds a third + of the calibrated threshold), `vitals` (latest piezo HR/BR within 5 min, + with a resting-human-range verdict) and `classification`: + - `person`: occupied and ≥ 2 zones lit, or human-range vitals, or movement + only with no level signal to say otherwise. + - `light`: occupied, level signal evaluable, single-zone load, no human + vitals. A pet or an object. + - `none`: not occupied. + + Vitals only ever upgrade to `person`. They drop out whenever the sleeper + moves, so a missing or odd reading must never demote a person. + `occupied` and `available` keep their meaning for HomeKit and the API. + +2. **Auto-off treats `light` as empty** when the level signal is evaluable. + Movement-only occupancy with an unsensable level still counts as occupied, + as before. + +3. **Auto-on is a per-side setting, shipped experimental**, implemented in + `src/services/autoOnWatcher.ts` beside the auto-off watcher rather than as + an automation rule. It has to coordinate with away mode, run-once + sessions, the pump-stall guard, the alarm's held power-off and the + controller's baseline selection; the scheduler's power-on paths already + have that plumbing and an automation rule would re-implement it badly. + Guardrails: + - fires only on `classification === 'person'`, sustained for + `autoOnMinutes` (default 5); + - only inside `autoOnStart`..`autoOnEnd` local time (default 20:00–08:00, + wraps midnight, equal times = any time) so a nap does not fire unless + the user widens it; + - a side turned off while a person is still in it (manual, scheduled, or + the global cap) suppresses auto-on until the bed has read non-person for + the dwell, so the bed cannot bounce straight back on; + - unsensable presence resets the dwell and never fires. + + There is no separate "auto-on temperature". `powerOnLocked` resolves the + setpoint from the controller baseline (an active or upcoming schedule), + then the last target the watcher saw while powered, then 75 °F. Auto-on is + an early schedule start, not another preference to keep in sync. + +4. **Expose presence to the automation engine as well.** `{side}.presence` + (1/0, `undefined` while unsensable) and `{side}.presence.zones` join the + signal catalog and the builder, live-only. Power users can build "when + presence changes, set power on at 72 °F" rules; the toggle remains the + default UX. + +## Alternatives considered + +- **A pet preference ("I have a pet").** Rejected: it would still need the + zone/vitals evidence to act on, and the classification is useful without + it (auto-off, the Presence card, HomeKit consumers). +- **Automation rule only.** Rejected for the coordination reasons above, and + because the engine deliberately handles numeric signals with no notion of + suppression after an intentional off. +- **Ask for an auto-on temperature.** Rejected: duplicates the schedule's + setpoint and drifts from it. + +## Consequences + +- Classification thresholds (`PERSON_MIN_ZONES = 2`, zone share = threshold/3, + HR 40–110, BR 8–22) are first guesses tuned against Pod 5 capSense2 frames. + A large dog spanning two zones will read as a person. The Presence card + shows "light load" so users can see what the sensor decided. +- Pod 3 (legacy scalar capSense) has no level signal, so `zones` is null and + movement-only occupancy classifies as `person`; auto-on stands down there + because presence is unsensable. +- Auto-off behaviour changes for pets: a cat left on the bed no longer keeps + it on. + +## References + +- `src/lib/occupancy.ts`, `src/services/autoOnWatcher.ts`, + `src/services/autoOffWatcher.ts`, `src/automation/signals.biometrics.ts` +- ADR 0023 (automation signal catalog) diff --git a/src/automation/signals.biometrics.ts b/src/automation/signals.biometrics.ts index 1ad964d8c..78585237c 100644 --- a/src/automation/signals.biometrics.ts +++ b/src/automation/signals.biometrics.ts @@ -18,6 +18,12 @@ * `streaming/capFramePersistence`). (`reduceCap` also derives a peakZone index, * used by the UI zone visualization rather than the engine.) * + * `{side}.presence` is the shared virtual occupancy sensor (`lib/occupancy`) + * collapsed to 1 (a person is in bed) / 0 (empty, or a light load such as a + * pet). It reads `undefined` while presence can't be sensed — no fresh + * capSense2 frame or no calibration — so rules skip rather than fire blind. + * `{side}.presence.zones` is the lit head/torso/legs count behind that call. + * * Automation temperature signals are Fahrenheit by contract because automation * actions target hardware setpoints, which are also Fahrenheit. */ @@ -26,6 +32,7 @@ import { desc, eq } from 'drizzle-orm' import { biometricsDb } from '@/src/db' import { ambientLight, bedTemp, freezerTemp, movement, vitals } from '@/src/db/biometrics-schema' import { centiDegreesToF, centiPercentToPercent } from '@/src/lib/tempUtils' +import { getOccupancy } from '@/src/lib/occupancy' import { getLatestCapSenseSnapshot } from '@/src/streaming/piezoStream' import { mean, reduceCap } from './capReduce' import type { SignalReader, SignalSnapshot } from './signals' @@ -94,6 +101,13 @@ export class BiometricsSignalReader implements SignalReader { out['ambient.light'] = al.lux } + for (const side of SIDES) { + const occ = getOccupancy(side) + if (!occ.available) continue + out[`${side}.presence`] = occ.classification === 'person' ? 1 : 0 + if (occ.level.zones !== null) out[`${side}.presence.zones`] = occ.level.zones + } + const cap = getLatestCapSenseSnapshot() if (cap && fresh(cap.receivedAtMs, CAP_FRESH_MS)) { for (const side of SIDES) { diff --git a/src/automation/tests/signals.biometrics.test.ts b/src/automation/tests/signals.biometrics.test.ts index 205fa7ab7..8cebc89f8 100644 --- a/src/automation/tests/signals.biometrics.test.ts +++ b/src/automation/tests/signals.biometrics.test.ts @@ -44,6 +44,17 @@ vi.mock('@/src/streaming/piezoStream', () => ({ getLatestCapSenseSnapshot: () => capMock.snapshot, })) +const occupancyMock = vi.hoisted(() => ({ + bySide: {} as Record, +})) + +vi.mock('@/src/lib/occupancy', () => ({ + getOccupancy: (side: 'left' | 'right') => { + const o = occupancyMock.bySide[side] ?? { available: false, classification: 'none', zones: null } + return { available: o.available, classification: o.classification, level: { zones: o.zones } } + }, +})) + import { ambientLight, bedTemp, freezerTemp, movement, vitals } from '@/src/db/biometrics-schema' import { reduceCap } from '../capReduce' import { BiometricsSignalReader } from '../signals.biometrics' @@ -90,6 +101,7 @@ describe('BiometricsSignalReader', () => { dbMock.state.sideCalls = {} dbMock.state.throws = false capMock.snapshot = null + occupancyMock.bySide = {} }) afterEach(() => { @@ -188,6 +200,26 @@ describe('BiometricsSignalReader', () => { expect(new BiometricsSignalReader().read()).toEqual({}) }) + it('exposes the classified presence sensor as 1/0 only while presence is sensable', () => { + occupancyMock.bySide = { + left: { available: true, classification: 'person', zones: 3 }, + right: { available: true, classification: 'light', zones: 1 }, + } + const out = new BiometricsSignalReader().read() + expect(out['left.presence']).toBe(1) + expect(out['left.presence.zones']).toBe(3) + expect(out['right.presence']).toBe(0) + expect(out['right.presence.zones']).toBe(1) + }) + + it('leaves presence undefined when the sensor is unavailable, so rules skip', () => { + occupancyMock.bySide = { left: { available: false, classification: 'person', zones: null } } + const out = new BiometricsSignalReader().read() + expect(out['left.presence']).toBeUndefined() + expect(out['left.presence.zones']).toBeUndefined() + expect(out['right.presence']).toBeUndefined() + }) + it('returns an empty snapshot when there are no rows and no cap snapshot', () => { expect(new BiometricsSignalReader().read()).toEqual({}) }) diff --git a/src/components/Autopilot/builderModel.ts b/src/components/Autopilot/builderModel.ts index 5cbdad052..0759a839f 100644 --- a/src/components/Autopilot/builderModel.ts +++ b/src/components/Autopilot/builderModel.ts @@ -62,6 +62,10 @@ export const SIGNALS: SignalDef[] = [ // Capacitive presence sensing (not temperature): per-zone body-contact load. // Back-testable from the downsampled cap_sense_frames history (see automations // loadSeries); the live zone matrix is replayable too but not a scalar signal. + // Virtual occupancy sensor: 1 = a person is in bed, 0 = empty or a light load + // (pet). Live-only — the classified signal has no history table. + { id: '{side}.presence', label: 'Presence (person)', unit: '', kind: 'num', group: 'Presence', icon: 'Bed', perSide: true, max: 1, liveOnly: true }, + { id: '{side}.presence.zones', label: 'Occupied zones', unit: '', kind: 'num', group: 'Presence', icon: 'Bed', perSide: true, max: 3, liveOnly: true }, { id: '{side}.cap.max', label: 'Bed pressure (peak)', unit: '', kind: 'num', group: 'Presence', icon: 'Activity', perSide: true }, { id: '{side}.cap.mean', label: 'Bed pressure (avg)', unit: '', kind: 'num', group: 'Presence', icon: 'Activity', perSide: true }, { id: '{side}.cap.spread', label: 'Pressure spread', unit: '', kind: 'num', group: 'Presence', icon: 'Activity', perSide: true }, @@ -507,14 +511,16 @@ export function buildSentence(r: BuilderRule, timeFormat: TimeFormat = '12h'): S // --------------------------------------------------------------------------- /** - * Signals the engine reads live today (ADR 0023): per-side temperature/level - * and water.low. Biometric, ambient and enum signals are backtest only, so a - * rule reading them can never fire on the bed yet. + * Signals the engine reads live today (ADR 0023): per-side temperature/level, + * presence and water.low. Other biometric, ambient and enum signals are + * backtest only, so a rule reading them can never fire on the bed yet. */ export const LIVE_SIGNALS: ReadonlySet = new Set([ '{side}.currentTemperature', '{side}.targetTemperature', '{side}.currentLevel', + '{side}.presence', + '{side}.presence.zones', 'water.low', ]) diff --git a/src/components/Autopilot/tests/automationsLogic.test.ts b/src/components/Autopilot/tests/automationsLogic.test.ts index 210aaf582..e6830e96c 100644 --- a/src/components/Autopilot/tests/automationsLogic.test.ts +++ b/src/components/Autopilot/tests/automationsLogic.test.ts @@ -3,7 +3,7 @@ import { backtestLine, currentNightStart, entryTime, matchesFilter, nightHeader, nightStarts, ownerView, reasonText, ruleMode, ruleWindow, scheduleBands, scheduleBlocks, statusLine, type SideTonight, } from '../automationsLogic' -import { missingLiveSignals, templateRule, TEMPLATES } from '../builderModel' +import { blankRule, missingLiveSignals, templateRule, TEMPLATES } from '../builderModel' const t = (d: number, h: number, m = 0) => new Date(2026, 8, d, h, m).getTime() const fmt = (f: number) => `${f}°` @@ -157,6 +157,9 @@ describe('rule rows', () => { expect(missing('restless')).toEqual(['movement']) expect(missing('water-low')).toEqual([]) expect(missing('hold-room')).toEqual([]) + const presenceRule = { ...blankRule(), when: { type: 'agg' as const, agg: 'avg' as const, signal: '{side}.presence', window: 5, op: '>' as const, value: 0 } } + expect(missingLiveSignals(presenceRule)).toEqual([]) + expect(missingLiveSignals({ ...presenceRule, when: { ...presenceRule.when, signal: '{side}.presence.zones' } })).toEqual([]) expect(templateRule('nope')).toBeNull() expect(TEMPLATES.map(x => x.title)).toEqual(['Hold room +3°F', 'Cool when restless', 'Tell me when water is low']) }) diff --git a/src/components/Sensors/PresenceCard.tsx b/src/components/Sensors/PresenceCard.tsx index e85288ca3..dffb98442 100644 --- a/src/components/Sensors/PresenceCard.tsx +++ b/src/components/Sensors/PresenceCard.tsx @@ -44,6 +44,13 @@ export function zoneActivity(variance: number[], zone: number): number { return Math.min(v / ACTIVITY_NORMALIZE, 1) } +/** Second line under the in bed / empty label. A light load is a pet or an object, not a sleeper. */ +export function presenceSub(occ: { available: boolean, classification: 'person' | 'light' | 'none', movement: { active: boolean } }): string { + if (!occ.available) return 'sensor n/a' + if (occ.classification === 'light') return occ.movement.active ? 'light load · moving' : 'light load' + return occ.movement.active ? 'moving' : 'still' +} + export function PresenceCard() { const capSense = useSensorFrame('capSense') const capSense2 = useSensorFrame('capSense2') @@ -115,7 +122,7 @@ export function PresenceCard() { side={side} occupied={occ?.occupied ?? false} known={occ !== undefined} - sub={occ ? (occ.available ? (occ.movement.active ? 'moving' : 'still') : 'sensor n/a') : '--'} + sub={occ ? presenceSub(occ) : '--'} variance={side === 'left' ? variance.leftVariance : variance.rightVariance} hasFrames={!!frame} /> diff --git a/src/components/Sensors/tests/sensorCards.test.tsx b/src/components/Sensors/tests/sensorCards.test.tsx index a8ecdeec9..451e883e8 100644 --- a/src/components/Sensors/tests/sensorCards.test.tsx +++ b/src/components/Sensors/tests/sensorCards.test.tsx @@ -97,7 +97,7 @@ it('shows stored and live freezer readings, water warnings and pump RPM', () => it('uses server occupancy while visualizing frame variance independently', () => { const { rerender } = render() expect(screen.getByTestId('presence-left').textContent).toContain('left · --') - mock.data.occupancy = { left: { occupied: true, available: true, movement: { active: true } }, right: { occupied: false, available: false, movement: { active: false } } } + mock.data.occupancy = { left: { occupied: true, available: true, classification: 'person', movement: { active: true } }, right: { occupied: false, available: false, classification: 'none', movement: { active: false } } } mock.frames.capSense2 = { ts: Date.now() } rerender() emit({ type: 'log' }) @@ -106,6 +106,10 @@ it('uses server occupancy while visualizing frame variance independently', () => expect(screen.getByTestId('presence-left').textContent).toContain('in bedmoving') expect(screen.getByTestId('presence-right').textContent).toContain('emptysensor n/a') expect(screen.getByLabelText('left zone activity').querySelector('[data-zone="head"]')?.style.height).toBe('100%') + mock.data.occupancy = { left: { occupied: true, available: true, classification: 'light', movement: { active: false } }, right: { occupied: true, available: true, classification: 'person', movement: { active: false } } } + rerender() + expect(screen.getByTestId('presence-left').textContent).toContain('in bedlight load') + expect(screen.getByTestId('presence-right').textContent).toContain('in bedstill') }) it('switches flow units and ranges with live readings and downsampled history', () => { diff --git a/src/components/Settings/SideSettingsForm.tsx b/src/components/Settings/SideSettingsForm.tsx index 558217b82..bcb751b6f 100644 --- a/src/components/Settings/SideSettingsForm.tsx +++ b/src/components/Settings/SideSettingsForm.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import { trpc } from '@/src/utils/trpc' import { Card, CardHeader, InlineError, SelectValue, SettingRow, StatusDot, TextField, Toggle } from '@/src/components/ds' -import { SectionColumns, SideTabs } from './SettingsLayout' +import { SectionColumns, SideTabs, TimeField } from './SettingsLayout' interface SideData { side: 'left' | 'right' @@ -14,6 +14,10 @@ interface SideData { alwaysOn: boolean autoOffEnabled: boolean autoOffMinutes: number + autoOnEnabled: boolean + autoOnMinutes: number + autoOnStart: string + autoOnEnd: string } interface SideSettingsFormProps { @@ -31,6 +35,7 @@ interface SideSettingsFormProps { } const AUTO_OFF_DURATION_OPTIONS = [5, 10, 15, 30, 45, 60, 90, 120] as const +const AUTO_ON_DWELL_OPTIONS = [1, 2, 3, 5, 10, 15, 30] as const function localDateTime(iso: string): string { const date = new Date(iso) @@ -43,7 +48,8 @@ function formatMinutes(mins: number): string { } /** - * Per-side settings: profile name, away mode, always on, and auto-off. + * Per-side settings: profile name, away mode, always on, auto-off, and the + * experimental presence auto-on. */ export function SideSettingsForm({ side, sideData, presenceAvailable, onSideChange }: SideSettingsFormProps) { const d = sideData ?? { @@ -53,12 +59,16 @@ export function SideSettingsForm({ side, sideData, presenceAvailable, onSideChan alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, + autoOnEnabled: false, + autoOnMinutes: 5, + autoOnStart: '20:00', + autoOnEnd: '08:00', } // key forces remount when server data changes, replacing the useEffect sync pattern return ( utils.settings.getAll.invalidate(), @@ -89,6 +103,7 @@ function SideCards({ data, presenceAvailable, onSideChange }: { // would block the toggle before the occupancy query resolves. const presenceUnavailable = presenceAvailable === false const autoOffToggleDisabled = isPending || (presenceUnavailable && !autoOffEnabled) + const autoOnToggleDisabled = isPending || (presenceUnavailable && !autoOnEnabled) function handleNameBlur() { const trimmed = name.trim() @@ -144,9 +159,30 @@ function SideCards({ data, presenceAvailable, onSideChange }: { mutation.mutate({ side: data.side, autoOffMinutes: minutes }) } + function handleAutoOnToggle() { + const newVal = !autoOnEnabled + setAutoOnEnabled(newVal) + mutation.mutate({ side: data.side, autoOnEnabled: newVal }) + } + + function handleAutoOnMinutesChange(minutes: number) { + setAutoOnMinutes(minutes) + mutation.mutate({ side: data.side, autoOnMinutes: minutes }) + } + + function handleAutoOnWindowChange(field: 'autoOnStart' | 'autoOnEnd', value: string) { + if (!/^\d{2}:\d{2}$/.test(value)) return // the native picker emits '' mid-edit + if (field === 'autoOnStart') setAutoOnStart(value) + else setAutoOnEnd(value) + mutation.mutate({ side: data.side, [field]: value }) + } + const minuteOptions = (AUTO_OFF_DURATION_OPTIONS as readonly number[]).includes(autoOffMinutes) ? AUTO_OFF_DURATION_OPTIONS : [...AUTO_OFF_DURATION_OPTIONS, autoOffMinutes].sort((a, b) => a - b) + const dwellOptions = (AUTO_ON_DWELL_OPTIONS as readonly number[]).includes(autoOnMinutes) + ? AUTO_ON_DWELL_OPTIONS + : [...AUTO_ON_DWELL_OPTIONS, autoOnMinutes].sort((a, b) => a - b) const presence = presenceAvailable === null ? @@ -246,6 +282,61 @@ function SideCards({ data, presenceAvailable, onSideChange }: { /> + + + {tabs('Auto-on')} + + Experimental + + )} + /> + + ({ value: m, label: formatMinutes(m) }))} + onChange={handleAutoOnMinutesChange} + disabled={isPending || !autoOnEnabled} + /> + + + +
+ handleAutoOnWindowChange('autoOnStart', v)} + disabled={isPending || !autoOnEnabled} + /> + to + handleAutoOnWindowChange('autoOnEnd', v)} + disabled={isPending || !autoOnEnabled} + /> +
+
+ {presenceUnavailable && ( +

+ {autoOnEnabled + ? 'Presence sensing is unavailable, so auto-on is currently inactive. Calibrate the capacitance sensor for this side to restore it.' + : 'Auto-on needs presence sensing. Calibrate the capacitance sensor for this side to enable it.'} +

+ )} +
{mutation.error && {mutation.error.message}} ) diff --git a/src/components/Settings/tests/SettingsSideTabs.test.tsx b/src/components/Settings/tests/SettingsSideTabs.test.tsx index dc55a46c1..b8811887b 100644 --- a/src/components/Settings/tests/SettingsSideTabs.test.tsx +++ b/src/components/Settings/tests/SettingsSideTabs.test.tsx @@ -21,7 +21,7 @@ vi.mock('@/src/utils/trpc', () => { }) const side = (s: 'left' | 'right', name: string, extra: Record) => ({ - side: s, name, awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, ...extra, + side: s, name, awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', ...extra, }) const gesture = (s: 'left' | 'right', extra: Record) => ({ @@ -51,8 +51,8 @@ describe('Settings per-side tabs', () => { render() expect(screen.queryByRole('tablist', { name: 'Person' })).toBeNull() - const names = ['Profile side', 'Away mode side', 'Auto-off side'] - // Bed setup is device-wide: only the three per-side cards get tabs. + const names = ['Profile side', 'Away mode side', 'Auto-off side', 'Auto-on side'] + // Bed setup is device-wide: only the four per-side cards get tabs. expect(screen.getAllByRole('tablist').map(t => t.getAttribute('aria-label'))).toEqual(names) for (const n of names) expect(selected(screen.getByRole('tablist', { name: n }))).toBe('Alex') expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('Alex') diff --git a/src/components/Settings/tests/SideSettingsForm.test.tsx b/src/components/Settings/tests/SideSettingsForm.test.tsx index afa29e364..b824ee69f 100644 --- a/src/components/Settings/tests/SideSettingsForm.test.tsx +++ b/src/components/Settings/tests/SideSettingsForm.test.tsx @@ -1,6 +1,7 @@ /** * SideSettingsForm — always-on / auto-off mutual exclusion, the presence - * gate on enabling auto-off, and name commit-on-blur. + * gate on enabling auto-off and auto-on, the experimental auto-on card, and + * name commit-on-blur. */ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -29,10 +30,59 @@ const base = { alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, + autoOnEnabled: false, + autoOnMinutes: 5, + autoOnStart: '20:00', + autoOnEnd: '08:00', } beforeEach(() => trpcMock.mutate.mockClear()) +describe('SideSettingsForm — auto-on (experimental)', () => { + it('is labelled experimental and toggles independently of always-on and auto-off', () => { + render() + expect(screen.getByText('Experimental')).toBeTruthy() + fireEvent.click(screen.getByLabelText('Toggle auto-on for Left side')) + expect(trpcMock.mutate).toHaveBeenCalledWith({ side: 'left', autoOnEnabled: true }) + expect(screen.getByLabelText('Toggle always on for Left side').getAttribute('aria-checked')).toBe('true') + }) + + it('blocks enabling auto-on when presence cannot be sensed, but allows turning it off', () => { + const { unmount } = render() + expect((screen.getByLabelText('Toggle auto-on for Left side') as HTMLButtonElement).disabled).toBe(true) + expect(screen.getByText(/Auto-on needs presence sensing/)).toBeTruthy() + unmount() + render() + const toggle = screen.getByLabelText('Toggle auto-on for Left side') as HTMLButtonElement + expect(toggle.disabled).toBe(false) + expect(screen.getByText(/auto-on is currently inactive/)).toBeTruthy() + fireEvent.click(toggle) + expect(trpcMock.mutate).toHaveBeenCalledWith({ side: 'left', autoOnEnabled: false }) + }) + + it('disables the dwell and window controls until auto-on is enabled', () => { + render() + expect((screen.getByLabelText('Auto-on after') as HTMLSelectElement).disabled).toBe(true) + expect((screen.getByLabelText('Auto-on window start for Left side') as HTMLInputElement).disabled).toBe(true) + }) + + it('saves the dwell and the window times', () => { + render() + fireEvent.change(screen.getByLabelText('Auto-on after'), { target: { value: '10' } }) + expect(trpcMock.mutate).toHaveBeenCalledWith({ side: 'left', autoOnMinutes: 10 }) + fireEvent.change(screen.getByLabelText('Auto-on window start for Left side'), { target: { value: '21:30' } }) + expect(trpcMock.mutate).toHaveBeenCalledWith({ side: 'left', autoOnStart: '21:30' }) + fireEvent.change(screen.getByLabelText('Auto-on window end for Left side'), { target: { value: '07:15' } }) + expect(trpcMock.mutate).toHaveBeenCalledWith({ side: 'left', autoOnEnd: '07:15' }) + }) + + it('ignores an incomplete time while the picker is being edited', () => { + render() + fireEvent.change(screen.getByLabelText('Auto-on window start for Left side'), { target: { value: '' } }) + expect(trpcMock.mutate).not.toHaveBeenCalled() + }) +}) + describe('SideSettingsForm', () => { it('turning Always on off-switches auto-off in the same mutation', () => { render() diff --git a/src/db/migrations/0027_presence_auto_on.sql b/src/db/migrations/0027_presence_auto_on.sql new file mode 100644 index 000000000..2605b26a4 --- /dev/null +++ b/src/db/migrations/0027_presence_auto_on.sql @@ -0,0 +1,4 @@ +ALTER TABLE `side_settings` ADD `auto_on_enabled` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `side_settings` ADD `auto_on_minutes` integer DEFAULT 5 NOT NULL;--> statement-breakpoint +ALTER TABLE `side_settings` ADD `auto_on_start` text DEFAULT '20:00' NOT NULL;--> statement-breakpoint +ALTER TABLE `side_settings` ADD `auto_on_end` text DEFAULT '08:00' NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/meta/0027_snapshot.json b/src/db/migrations/meta/0027_snapshot.json new file mode 100644 index 000000000..361d1daa1 --- /dev/null +++ b/src/db/migrations/meta/0027_snapshot.json @@ -0,0 +1,1961 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ac3c5d78-3ecd-4eb5-8a65-afba0f623eef", + "prevId": "1fb8f335-59fd-4877-a22f-48b807db782a", + "tables": { + "alarm_schedules": { + "name": "alarm_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vibration_intensity": { + "name": "vibration_intensity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vibration_pattern": { + "name": "vibration_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'rise'" + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alarm_temperature": { + "name": "alarm_temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake_window": { + "name": "wake_window", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_alarm_schedules_side_day": { + "name": "idx_alarm_schedules_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_runs_automation_fired": { + "name": "idx_automation_runs_automation_fired", + "columns": [ + "automation_id", + "fired_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cooldown_min": { + "name": "cooldown_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actions": { + "name": "actions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_automations_enabled": { + "name": "idx_automations_enabled", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "base_schedules": { + "name": "base_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'both'" + }, + "preset_name": { + "name": "preset_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Custom'" + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head": { + "name": "head", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feet": { + "name": "feet", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feed_rate": { + "name": "feed_rate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 50 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "base_schedules_day_time_side": { + "name": "base_schedules_day_time_side", + "columns": [ + "day_of_week", + "time", + "side" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_activation_events": { + "name": "biometric_activation_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_revision_id": { + "name": "previous_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_revision_id": { + "name": "next_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "previous_hash": { + "name": "previous_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_hash": { + "name": "next_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "evaluation_id": { + "name": "evaluation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "evidence": { + "name": "evidence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requester": { + "name": "requester", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_activation_generation": { + "name": "uq_biometric_activation_generation", + "columns": [ + "side", + "generation" + ], + "isUnique": true + }, + "uq_biometric_activation_idempotency": { + "name": "uq_biometric_activation_idempotency", + "columns": [ + "idempotency_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_config_outbox": { + "name": "biometric_config_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_hash": { + "name": "resolved_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at_ms": { + "name": "next_attempt_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at_ms": { + "name": "delivered_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_outbox_generation": { + "name": "uq_biometric_outbox_generation", + "columns": [ + "side", + "generation" + ], + "isUnique": true + }, + "idx_biometric_outbox_pending": { + "name": "idx_biometric_outbox_pending", + "columns": [ + "state", + "next_attempt_at_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_profile_bindings": { + "name": "biometric_profile_bindings", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "desired_revision_id": { + "name": "desired_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_revision_id": { + "name": "previous_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "biometric_profile_revisions": { + "name": "biometric_profile_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_manifest_hash": { + "name": "base_manifest_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_manifest": { + "name": "base_manifest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overrides": { + "name": "overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved": { + "name": "resolved", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_hash": { + "name": "resolved_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dependency_hashes": { + "name": "dependency_hashes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_revision_id": { + "name": "parent_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requester": { + "name": "requester", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_biometric_profile_family_revision": { + "name": "uq_biometric_profile_family_revision", + "columns": [ + "family_id", + "revision" + ], + "isUnique": true + }, + "uq_biometric_profile_idempotency": { + "name": "uq_biometric_profile_idempotency", + "columns": [ + "idempotency_key" + ], + "isUnique": true + }, + "idx_biometric_profile_side_created": { + "name": "idx_biometric_profile_side_created", + "columns": [ + "side", + "created_at_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "curve_templates": { + "name": "curve_templates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bedtime": { + "name": "bedtime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake": { + "name": "wake", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "points": { + "name": "points", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_curve_templates_name": { + "name": "idx_curve_templates_name", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_settings": { + "name": "device_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bed_mode": { + "name": "bed_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'two'" + }, + "unused_zone_mode": { + "name": "unused_zone_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'off'" + }, + "default_schedule_end_action": { + "name": "default_schedule_end_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'turn_off'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'America/Los_Angeles'" + }, + "temperature_unit": { + "name": "temperature_unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'F'" + }, + "time_format": { + "name": "time_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'12h'" + }, + "reboot_daily": { + "name": "reboot_daily", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reboot_time": { + "name": "reboot_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'03:00'" + }, + "prime_pod_daily": { + "name": "prime_pod_daily", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "prime_pod_time": { + "name": "prime_pod_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'14:00'" + }, + "led_night_mode_enabled": { + "name": "led_night_mode_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "led_day_brightness": { + "name": "led_day_brightness", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "led_night_brightness": { + "name": "led_night_brightness", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "led_night_start_time": { + "name": "led_night_start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'22:00'" + }, + "led_night_end_time": { + "name": "led_night_end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'07:00'" + }, + "global_max_on_hours": { + "name": "global_max_on_hours", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_enabled": { + "name": "mqtt_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_url": { + "name": "mqtt_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_username": { + "name": "mqtt_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_password": { + "name": "mqtt_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_topic_prefix": { + "name": "mqtt_topic_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_ha_discovery": { + "name": "mqtt_ha_discovery", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_tls_enabled": { + "name": "mqtt_tls_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mqtt_tls_insecure": { + "name": "mqtt_tls_insecure", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homekit_enabled": { + "name": "homekit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_protection_enabled": { + "name": "pump_stall_protection_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_rpm_threshold": { + "name": "pump_stall_rpm_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 500 + }, + "pump_stall_dwell_samples": { + "name": "pump_stall_dwell_samples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "pump_stall_auto_recovery_enabled": { + "name": "pump_stall_auto_recovery_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pump_stall_recovery_rpm": { + "name": "pump_stall_recovery_rpm", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1500 + }, + "pump_stall_recovery_samples": { + "name": "pump_stall_recovery_samples", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "autopilot_enabled": { + "name": "autopilot_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_state": { + "name": "device_state", + "columns": { + "hardware_deadline": { + "name": "hardware_deadline", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "current_temperature": { + "name": "current_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_temperature": { + "name": "target_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_powered": { + "name": "is_powered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_alarm_vibrating": { + "name": "is_alarm_vibrating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "water_level": { + "name": "water_level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "powered_on_at": { + "name": "powered_on_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "power_schedules": { + "name": "power_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_time": { + "name": "on_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "off_time": { + "name": "off_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_action": { + "name": "end_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'turn_off'" + }, + "on_temperature": { + "name": "on_temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_power_schedules_side_day": { + "name": "idx_power_schedules_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "run_once_sessions": { + "name": "run_once_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_points": { + "name": "set_points", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wake_time": { + "name": "wake_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_run_once_side_status": { + "name": "idx_run_once_side_status", + "columns": [ + "side", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_notes": { + "name": "schedule_notes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_schedule_notes_side_day": { + "name": "idx_schedule_notes_side_day", + "columns": [ + "side", + "day_of_week" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "side_settings": { + "name": "side_settings", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "away_mode": { + "name": "away_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "always_on": { + "name": "always_on", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_off_enabled": { + "name": "auto_off_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_off_minutes": { + "name": "auto_off_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "auto_on_enabled": { + "name": "auto_on_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_on_minutes": { + "name": "auto_on_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "auto_on_start": { + "name": "auto_on_start", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'20:00'" + }, + "auto_on_end": { + "name": "auto_on_end", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'08:00'" + }, + "away_start": { + "name": "away_start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "away_return": { + "name": "away_return", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_classifier": { + "name": "stage_classifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stage_classifier_changes": { + "name": "stage_classifier_changes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_at": { + "name": "changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agreement_before": { + "name": "agreement_before", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agreement_after": { + "name": "agreement_after", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_stage_classifier_changes_side_changed_at": { + "name": "idx_stage_classifier_changes_side_changed_at", + "columns": [ + "side", + "changed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_health": { + "name": "system_health", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "system_health_component_unique": { + "name": "system_health_component_unique", + "columns": [ + "component" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tap_gestures": { + "name": "tap_gestures", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tap_type": { + "name": "tap_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature_change": { + "name": "temperature_change", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "temperature_amount": { + "name": "temperature_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_behavior": { + "name": "alarm_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_snooze_duration": { + "name": "alarm_snooze_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alarm_inactive_behavior": { + "name": "alarm_inactive_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "uq_tap_side_type": { + "name": "uq_tap_side_type", + "columns": [ + "side", + "tap_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "temperature_holds": { + "name": "temperature_holds", + "columns": { + "side": { + "name": "side", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "temperature_schedules": { + "name": "temperature_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_temp_schedules_side_day_time": { + "name": "idx_temp_schedules_side_day_time", + "columns": [ + "side", + "day_of_week", + "time" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 15b115eed..010907aeb 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1791948350716, "tag": "0026_strong_the_order", "breakpoints": true + }, + { + "idx": 27, + "version": "6", + "when": 1791962178346, + "tag": "0027_presence_auto_on", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 0ed08ea01..ab65a6e45 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -93,6 +93,13 @@ export const sideSettings = sqliteTable('side_settings', { alwaysOn: integer('always_on', { mode: 'boolean' }).notNull().default(false), autoOffEnabled: integer('auto_off_enabled', { mode: 'boolean' }).notNull().default(false), autoOffMinutes: integer('auto_off_minutes').notNull().default(30), + // Experimental: power the side on once a person has been in bed for + // auto_on_minutes, inside the auto_on_start..auto_on_end local-time window + // (wraps midnight; equal times mean any time). See services/autoOnWatcher. + autoOnEnabled: integer('auto_on_enabled', { mode: 'boolean' }).notNull().default(false), + autoOnMinutes: integer('auto_on_minutes').notNull().default(5), + autoOnStart: text('auto_on_start').notNull().default('20:00'), + autoOnEnd: text('auto_on_end').notNull().default('08:00'), awayStart: text('away_start'), // ISO datetime when away mode activates awayReturn: text('away_return'), // ISO datetime when away mode deactivates // Sleep stage classifier ({ engine, ruleset }), validated by diff --git a/src/db/tests/__snapshots__/schema.test.ts.snap b/src/db/tests/__snapshots__/schema.test.ts.snap index 7d4bd0ae3..4fa986f74 100644 --- a/src/db/tests/__snapshots__/schema.test.ts.snap +++ b/src/db/tests/__snapshots__/schema.test.ts.snap @@ -1120,6 +1120,10 @@ exports[`complete Drizzle schema contracts > pins every main-database table, col "always_on | sql:integer | logical:boolean/SQLiteBoolean | not-null | default:false | not-primary-key | not-unique | not-autoincrement | enum:none", "auto_off_enabled | sql:integer | logical:boolean/SQLiteBoolean | not-null | default:false | not-primary-key | not-unique | not-autoincrement | enum:none", "auto_off_minutes | sql:integer | logical:number/SQLiteInteger | not-null | default:30 | not-primary-key | not-unique | not-autoincrement | enum:none", + "auto_on_enabled | sql:integer | logical:boolean/SQLiteBoolean | not-null | default:false | not-primary-key | not-unique | not-autoincrement | enum:none", + "auto_on_minutes | sql:integer | logical:number/SQLiteInteger | not-null | default:5 | not-primary-key | not-unique | not-autoincrement | enum:none", + "auto_on_start | sql:text | logical:string/SQLiteText | not-null | default:20:00 | not-primary-key | not-unique | not-autoincrement | enum:none", + "auto_on_end | sql:text | logical:string/SQLiteText | not-null | default:08:00 | not-primary-key | not-unique | not-autoincrement | enum:none", "away_start | sql:text | logical:string/SQLiteText | nullable | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", "away_return | sql:text | logical:string/SQLiteText | nullable | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", "stage_classifier | sql:text | logical:json/SQLiteTextJson | nullable | default:none | not-primary-key | not-unique | not-autoincrement | enum:none", diff --git a/src/db/tests/schema.test.ts b/src/db/tests/schema.test.ts index e962bac3b..b8c24c807 100644 --- a/src/db/tests/schema.test.ts +++ b/src/db/tests/schema.test.ts @@ -235,6 +235,10 @@ const expectedTables: Record = { { name: 'always_on', notNull: true, default: false }, { name: 'auto_off_enabled', notNull: true, default: false }, { name: 'auto_off_minutes', notNull: true, default: 30 }, + { name: 'auto_on_enabled', notNull: true, default: false }, + { name: 'auto_on_minutes', notNull: true, default: 5 }, + { name: 'auto_on_start', notNull: true, default: '20:00' }, + { name: 'auto_on_end', notNull: true, default: '08:00' }, { name: 'away_start', notNull: false, default: undefined }, { name: 'away_return', notNull: false, default: undefined }, { name: 'stage_classifier', notNull: false, default: undefined }, diff --git a/src/demo/handlers/biometrics.ts b/src/demo/handlers/biometrics.ts index 4ba6d4afc..4872bbe7d 100644 --- a/src/demo/handlers/biometrics.ts +++ b/src/demo/handlers/biometrics.ts @@ -213,11 +213,23 @@ export const biometrics: DemoHandlers<'biometrics'> = { const recent = night?.movement.filter(m => m.t >= now - 15 * MINUTE) ?? [] const peakScore = recent.reduce((max, m) => Math.max(max, m.score), 0) const levelActive = night !== null + const occupied = levelActive || peakScore >= RESTLESS_SCORE_MIN return { - occupied: levelActive || peakScore >= RESTLESS_SCORE_MIN, + occupied, available: true, + classification: occupied ? 'person' as const : 'none' as const, movement: { active: peakScore >= RESTLESS_SCORE_MIN, peakScore }, - level: { active: levelActive, deviation: levelActive ? 3.6 : 0.4, threshold: 1.5, ageMs: 420 }, + level: { + active: levelActive, + deviation: levelActive ? 3.6 : 0.4, + threshold: 1.5, + ageMs: 420, + zoneDeviations: levelActive ? [1.3, 1.4, 0.9] : [0.2, 0.1, 0.1], + zones: levelActive ? 3 : 0, + }, + vitals: levelActive + ? { heartRate: 58, breathingRate: 13, humanRange: true } + : { heartRate: null, breathingRate: null, humanRange: null }, } } return { left: occupancy('left'), right: occupancy('right') } diff --git a/src/demo/handlers/settings.ts b/src/demo/handlers/settings.ts index a4f1f0231..fa2d06399 100644 --- a/src/demo/handlers/settings.ts +++ b/src/demo/handlers/settings.ts @@ -40,8 +40,8 @@ const deviceSettings: DeviceSettings = { } const sideSettings: Record = { - left: { side: 'left', name: 'Alex', awayMode: false, alwaysOn: false, autoOffEnabled: true, autoOffMinutes: 30, awayStart: null, awayReturn: null, createdAt, updatedAt: createdAt }, - right: { side: 'right', name: 'Sam', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, awayStart: null, awayReturn: null, createdAt, updatedAt: createdAt }, + left: { side: 'left', name: 'Alex', awayMode: false, alwaysOn: false, autoOffEnabled: true, autoOffMinutes: 30, autoOnEnabled: true, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt, updatedAt: createdAt }, + right: { side: 'right', name: 'Sam', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt, updatedAt: createdAt }, } let nextGestureId = 1 diff --git a/src/homekit/tests/occupancySensor.test.ts b/src/homekit/tests/occupancySensor.test.ts index 3b121cbc9..a85e755ed 100644 --- a/src/homekit/tests/occupancySensor.test.ts +++ b/src/homekit/tests/occupancySensor.test.ts @@ -14,8 +14,10 @@ function result(occupied: boolean): OccupancyResult { return { occupied, available: false, + classification: occupied ? 'person' : 'none', movement: { active: occupied, peakScore: occupied ? 660 : 12 }, - level: { active: false, deviation: null, threshold: null, ageMs: null }, + level: { active: false, deviation: null, threshold: null, ageMs: null, zoneDeviations: null, zones: null }, + vitals: { heartRate: null, breathingRate: null, humanRange: null }, } } diff --git a/src/lib/occupancy.ts b/src/lib/occupancy.ts index be6614740..2be747ae4 100644 --- a/src/lib/occupancy.ts +++ b/src/lib/occupancy.ts @@ -16,11 +16,20 @@ * Movement alone is blind to a person who is lying perfectly still; level * alone is blind to brief presence events that don't sustain a baseline * deviation. The OR closes both gaps. + * + * On top of `occupied`, `classification` separates a person from a light load + * (a pet, a laptop, a folded duvet). A person spans head, torso and legs, so + * at least two of the three capSense2 zones deviate; a pet concentrates on one. + * Human-range vitals from the piezo processor upgrade an ambiguous load to a + * person but never downgrade one — vitals drop out whenever the sleeper moves, + * and a missing reading must not read as "not a person". Consumers that act on + * presence (auto-on) fire only on `person`; consumers that act on absence + * (auto-off) treat `light` as empty. */ -import { and, eq, gte, sql } from 'drizzle-orm' +import { and, desc, eq, gte, sql } from 'drizzle-orm' import { biometricsDb } from '@/src/db/biometrics' -import { calibrationProfiles, movement } from '@/src/db/biometrics-schema' +import { calibrationProfiles, movement, vitals } from '@/src/db/biometrics-schema' import { getLatestCapSenseSnapshot } from '@/src/streaming/piezoStream' import { RESTLESS_SCORE_MIN } from '@/src/lib/movement' import type { Side } from '@/src/hardware/types' @@ -32,6 +41,16 @@ const CAPSENSE_STALE_MS = 30_000 * missing a `ref.mean` field (older profiles). Matches the documented * capSense2 reference baseline. */ const REF_NOMINAL = 1.16 +/** Latest vitals row older than this is treated as absent (matches the + * automation engine's VITALS_FRESH_MS). */ +const VITALS_WINDOW_MS = 5 * 60_000 +/** Resting human ranges. Outside them (or absent) is "no evidence", not "pet". */ +const HUMAN_HR_MIN = 40 +const HUMAN_HR_MAX = 110 +const HUMAN_BR_MIN = 8 +const HUMAN_BR_MAX = 22 +/** Zones a load must light (of head/torso/legs) to classify as a person. */ +export const PERSON_MIN_ZONES = 2 export interface MovementSignal { active: boolean @@ -48,12 +67,38 @@ export interface LevelSignal { /** Milliseconds since the latest capSense2 frame was received, or null * when no frame has ever been seen. */ ageMs: number | null + /** Compensated deviation per zone [head, torso, legs], or null when the + * level signal can't be evaluated. */ + zoneDeviations: number[] | null + /** Zones whose deviation exceeds a third of the calibrated threshold, or + * null when the level signal can't be evaluated. A person lights >= 2. */ + zones: number | null +} + +export interface VitalsSignal { + heartRate: number | null + breathingRate: number | null + /** True when a fresh reading sits in resting human range, false when a fresh + * reading is outside it, null when there is no fresh reading. */ + humanRange: boolean | null } +/** + * 'person' — occupied and the load looks like a person (>= PERSON_MIN_ZONES + * zones, or human-range vitals, or movement with no level signal + * to say otherwise) + * 'light' — occupied, level signal evaluable, but the load sits on a single + * zone with no human vitals (pet, object) + * 'none' — not occupied + */ +export type OccupancyClassification = 'person' | 'light' | 'none' + export interface OccupancyResult { occupied: boolean movement: MovementSignal level: LevelSignal + vitals: VitalsSignal + classification: OccupancyClassification /** * True when presence can be sensed reliably enough to act on its ABSENCE — * i.e. the level signal is evaluable (fresh capSense2 frame + completed @@ -76,14 +121,51 @@ interface CapSenseCalibration { export function getOccupancy(side: Side): OccupancyResult { const movementSignal = readMovementSignal(side) const levelSignal = readLevelSignal(side) + const vitalsSignal = readVitalsSignal(side) + const occupied = movementSignal.active || levelSignal.active return { - occupied: movementSignal.active || levelSignal.active, + occupied, movement: movementSignal, level: levelSignal, + vitals: vitalsSignal, + classification: classify(occupied, levelSignal, vitalsSignal), available: levelSignal.deviation !== null, } } +/** See `OccupancyClassification`. Vitals only ever upgrade to `person`. */ +export function classify(occupied: boolean, level: LevelSignal, vitalsSignal: VitalsSignal): OccupancyClassification { + if (!occupied) return 'none' + if (vitalsSignal.humanRange === true) return 'person' + if (level.zones !== null) return level.zones >= PERSON_MIN_ZONES ? 'person' : 'light' + return 'person' +} + +function readVitalsSignal(side: Side): VitalsSignal { + const absent: VitalsSignal = { heartRate: null, breathingRate: null, humanRange: null } + try { + const [row] = biometricsDb + .select({ timestamp: vitals.timestamp, heartRate: vitals.heartRate, breathingRate: vitals.breathingRate }) + .from(vitals) + .where(eq(vitals.side, side)) + .orderBy(desc(vitals.timestamp)) + .limit(1) + .all() + if (!row) return absent + const ts = row.timestamp instanceof Date ? row.timestamp.getTime() : Number(row.timestamp) * 1000 + if (Date.now() - ts > VITALS_WINDOW_MS) return absent + const heartRate = row.heartRate ?? null + const breathingRate = row.breathingRate ?? null + if (heartRate === null && breathingRate === null) return absent + const hrHuman = heartRate === null ? true : heartRate >= HUMAN_HR_MIN && heartRate <= HUMAN_HR_MAX + const brHuman = breathingRate === null ? true : breathingRate >= HUMAN_BR_MIN && breathingRate <= HUMAN_BR_MAX + return { heartRate, breathingRate, humanRange: hrHuman && brHuman } + } + catch { + return absent + } +} + function readMovementSignal(side: Side): MovementSignal { try { const since = new Date(Date.now() - MOVEMENT_WINDOW_MS) @@ -104,28 +186,28 @@ function readMovementSignal(side: Side): MovementSignal { function readLevelSignal(side: Side): LevelSignal { const snap = getLatestCapSenseSnapshot() if (!snap) { - return { active: false, deviation: null, threshold: null, ageMs: null } + return { active: false, deviation: null, threshold: null, ageMs: null, zoneDeviations: null, zones: null } } const ageMs = Date.now() - snap.receivedAtMs if (ageMs > CAPSENSE_STALE_MS) { - return { active: false, deviation: null, threshold: null, ageMs } + return { active: false, deviation: null, threshold: null, ageMs, zoneDeviations: null, zones: null } } // Pod-3 legacy capSense uses a scalar-per-side and a different calibration // shape; level scoring there is not implemented (movement signal still works). if (snap.type !== 'capSense2') { - return { active: false, deviation: null, threshold: null, ageMs } + return { active: false, deviation: null, threshold: null, ageMs, zoneDeviations: null, zones: null } } const cal = readCapSenseCalibration(side) if (!cal) { - return { active: false, deviation: null, threshold: null, ageMs } + return { active: false, deviation: null, threshold: null, ageMs, zoneDeviations: null, zones: null } } const values = side === 'left' ? snap.left : snap.right if (!Array.isArray(values) || values.length < 6) { - return { active: false, deviation: null, threshold: cal.threshold, ageMs } + return { active: false, deviation: null, threshold: cal.threshold, ageMs, zoneDeviations: null, zones: null } } const avgPair = (x: number, y: number): number => (x + y) / 2 @@ -140,15 +222,24 @@ function readLevelSignal(side: Side): LevelSignal { refDelta = ref - refNominal } - const deviation = (a - refDelta - cal.channels.A.mean) - + (b - refDelta - cal.channels.B.mean) - + (c - refDelta - cal.channels.C.mean) + const zoneDeviations = [ + a - refDelta - cal.channels.A.mean, + b - refDelta - cal.channels.B.mean, + c - refDelta - cal.channels.C.mean, + ] + const deviation = zoneDeviations[0] + zoneDeviations[1] + zoneDeviations[2] + // The calibrated threshold bounds the three-zone sum; a zone counts as lit + // when it carries its share of that sum. + const zoneThreshold = cal.threshold / 3 + const zones = zoneDeviations.filter(d => d > zoneThreshold).length return { active: deviation > cal.threshold, deviation, threshold: cal.threshold, ageMs, + zoneDeviations, + zones, } } diff --git a/src/lib/tests/occupancy.test.ts b/src/lib/tests/occupancy.test.ts index 7db046ae8..3a4bee0f1 100644 --- a/src/lib/tests/occupancy.test.ts +++ b/src/lib/tests/occupancy.test.ts @@ -3,13 +3,16 @@ import type { LatestCapSenseSnapshot } from '@/src/streaming/piezoStream' interface MovementRow { peak: number | null } interface CalRow { parameters: unknown } +interface VitalsRow { timestamp: Date, heartRate: number | null, breathingRate: number | null } let movementRows: MovementRow[] = [] let calRows: CalRow[] = [] +let vitalsRows: VitalsRow[] = [] let snapshot: LatestCapSenseSnapshot | null = null const drizzle = vi.hoisted(() => ({ and: vi.fn((...conditions: unknown[]) => ({ op: 'and', conditions })), + desc: vi.fn((col: unknown) => ({ op: 'desc', col })), eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })), gte: vi.fn((left: unknown, right: unknown) => ({ op: 'gte', left, right })), sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings: [...strings], values })), @@ -19,28 +22,31 @@ vi.mock('drizzle-orm', () => drizzle) const movementAll = vi.fn<() => MovementRow[]>(() => movementRows) const calAll = vi.fn<() => CalRow[]>(() => calRows) +const vitalsAll = vi.fn<() => VitalsRow[]>(() => vitalsRows) vi.mock('@/src/db/biometrics', () => ({ biometricsDb: { - select: (cols?: Record) => ({ - from: () => ({ - where: () => ({ - limit: () => ({ - all: () => { - // Distinguish queries by selected columns: movement uses `peak`, - // calibration uses `parameters`. Cheap heuristic — no SQL coupling. - if (cols && 'peak' in cols) return movementAll() - return calAll() - }, - }), + select: (cols?: Record) => { + // Distinguish queries by selected columns: movement uses `peak`, vitals + // uses `heartRate`, calibration uses `parameters`. No SQL coupling. + const all = () => { + if (cols && 'peak' in cols) return movementAll() + if (cols && 'heartRate' in cols) return vitalsAll() + return calAll() + } + const limited = { limit: () => ({ all }) } + return { + from: () => ({ + where: () => ({ ...limited, orderBy: () => limited }), }), - }), - }), + } + }, }, })) vi.mock('@/src/db/biometrics-schema', () => ({ movement: { side: {}, timestamp: {}, totalMovement: {} }, + vitals: { side: {}, timestamp: {}, heartRate: {}, breathingRate: {} }, calibrationProfiles: { side: {}, sensorType: {}, @@ -53,7 +59,7 @@ vi.mock('@/src/streaming/piezoStream', () => ({ getLatestCapSenseSnapshot: () => snapshot, })) -import { getOccupancy } from '../occupancy' +import { classify, getOccupancy, PERSON_MIN_ZONES } from '../occupancy' const FIXED_NOW = 1_778_910_000_000 const BASELINE_CAL = { @@ -79,9 +85,11 @@ describe('getOccupancy', () => { vi.setSystemTime(new Date(FIXED_NOW)) movementRows = [] calRows = [] + vitalsRows = [] snapshot = null movementAll.mockClear() calAll.mockClear() + vitalsAll.mockClear() drizzle.gte.mockClear() }) afterEach(() => { @@ -335,3 +343,133 @@ describe('getOccupancy', () => { expect(r.level.threshold).toBeNull() }) }) + +describe('getOccupancy — classification', () => { + const PERSON_FRAME = [25.0, 25.0, 23.0, 23.0, 30.0, 30.0, 1.157, 1.157] // all three zones ~+10 + // One zone carries the whole load: head +7, torso/legs at baseline. + const PET_FRAME = [21.45, 21.45, 13.65, 13.65, 19.3, 19.3, 1.157, 1.157] + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(FIXED_NOW)) + calRows = [{ parameters: BASELINE_CAL }] + movementRows = [{ peak: 0 }] + vitalsRows = [] + vitalsAll.mockClear() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('reports per-zone deviations and the lit-zone count', () => { + snapshot = makeFrame('left', PERSON_FRAME) + const r = getOccupancy('left') + expect(r.level.zoneDeviations?.map(d => Math.round(d))).toEqual([11, 9, 11]) + expect(r.level.zones).toBe(3) + }) + + it('classifies a three-zone load as a person', () => { + snapshot = makeFrame('left', PERSON_FRAME) + const r = getOccupancy('left') + expect(r.occupied).toBe(true) + expect(r.classification).toBe('person') + }) + + it('classifies a single-zone load above the sum threshold as light (pet)', () => { + snapshot = makeFrame('left', PET_FRAME) + const r = getOccupancy('left') + expect(r.level.active).toBe(true) + expect(r.level.zones).toBe(1) + expect(r.occupied).toBe(true) + expect(r.classification).toBe('light') + }) + + it('a zone counts as lit only above a third of the calibrated threshold', () => { + // Head +2.5 (> 2.0), torso +1.9 (< 2.0), legs +2.1 (> 2.0) → 2 zones, sum 6.5 > 6. + snapshot = makeFrame('left', [16.95, 16.95, 15.55, 15.55, 21.4, 21.4, 1.157, 1.157]) + const r = getOccupancy('left') + expect(r.level.zones).toBe(PERSON_MIN_ZONES) + expect(r.classification).toBe('person') + }) + + it('upgrades a single-zone load to person on human-range vitals', () => { + snapshot = makeFrame('left', PET_FRAME) + vitalsRows = [{ timestamp: new Date(FIXED_NOW - 60_000), heartRate: 58, breathingRate: 13 }] + const r = getOccupancy('left') + expect(r.vitals).toEqual({ heartRate: 58, breathingRate: 13, humanRange: true }) + expect(r.classification).toBe('person') + }) + + it('never downgrades a multi-zone load on out-of-range vitals', () => { + snapshot = makeFrame('left', PERSON_FRAME) + vitalsRows = [{ timestamp: new Date(FIXED_NOW - 60_000), heartRate: 150, breathingRate: 30 }] + const r = getOccupancy('left') + expect(r.vitals.humanRange).toBe(false) + expect(r.classification).toBe('person') + }) + + it('ignores vitals older than five minutes', () => { + snapshot = makeFrame('left', PET_FRAME) + vitalsRows = [{ timestamp: new Date(FIXED_NOW - 6 * 60_000), heartRate: 58, breathingRate: 13 }] + const r = getOccupancy('left') + expect(r.vitals.humanRange).toBeNull() + expect(r.classification).toBe('light') + }) + + it('treats a vitals row with both readings null as absent', () => { + snapshot = makeFrame('left', PET_FRAME) + vitalsRows = [{ timestamp: new Date(FIXED_NOW), heartRate: null, breathingRate: null }] + expect(getOccupancy('left').vitals.humanRange).toBeNull() + }) + + it('judges human range on whichever reading is present', () => { + snapshot = makeFrame('left', PET_FRAME) + vitalsRows = [{ timestamp: new Date(FIXED_NOW), heartRate: 62, breathingRate: null }] + expect(getOccupancy('left').vitals.humanRange).toBe(true) + vitalsRows = [{ timestamp: new Date(FIXED_NOW), heartRate: null, breathingRate: 40 }] + expect(getOccupancy('left').vitals.humanRange).toBe(false) + }) + + it('classifies movement-only occupancy as a person when the level signal is unavailable', () => { + movementRows = [{ peak: 350 }] + snapshot = null + const r = getOccupancy('left') + expect(r.level.zones).toBeNull() + expect(r.classification).toBe('person') + }) + + it('classifies movement with an evaluable but single-zone level as light', () => { + movementRows = [{ peak: 350 }] + // Quiet level: zones 0 — a pet pacing on the bed edge. + snapshot = makeFrame('left', [14.5, 14.4, 13.7, 13.6, 19.4, 19.2, 1.157, 1.157]) + const r = getOccupancy('left') + expect(r.occupied).toBe(true) + expect(r.classification).toBe('light') + }) + + it('classifies an empty bed as none', () => { + snapshot = makeFrame('left', [14.5, 14.4, 13.7, 13.6, 19.4, 19.2, 1.157, 1.157]) + expect(getOccupancy('left').classification).toBe('none') + }) + + it('tolerates a vitals db error', () => { + snapshot = makeFrame('left', PERSON_FRAME) + vitalsAll.mockImplementationOnce(() => { + throw new Error('db gone') + }) + const r = getOccupancy('left') + expect(r.vitals.humanRange).toBeNull() + expect(r.classification).toBe('person') + }) + + it('classify() is a pure function of its inputs', () => { + const level = (zones: number | null) => ({ active: true, deviation: 9, threshold: 6, ageMs: 1, zoneDeviations: null, zones }) + const noVitals = { heartRate: null, breathingRate: null, humanRange: null } + expect(classify(false, level(3), noVitals)).toBe('none') + expect(classify(true, level(1), noVitals)).toBe('light') + expect(classify(true, level(2), noVitals)).toBe('person') + expect(classify(true, level(null), noVitals)).toBe('person') + expect(classify(true, level(0), { ...noVitals, humanRange: true })).toBe('person') + expect(classify(true, level(3), { ...noVitals, humanRange: false })).toBe('person') + }) +}) diff --git a/src/mcp/tools/settings.ts b/src/mcp/tools/settings.ts index bd7826f54..0ad98c3ab 100644 --- a/src/mcp/tools/settings.ts +++ b/src/mcp/tools/settings.ts @@ -29,7 +29,10 @@ export function registerSettingsTools(server: McpServer) { 'Update one side. awayMode pauses the recurring schedules for that side ("I am travelling until Friday": ' + 'set awayMode with awayReturn). It does not pause automations; use manage_automation for those. ' + 'alwaysOn keeps the side running past the firmware 8-hour timeout. ' - + 'autoOff turns the side off after autoOffMinutes once the bed is empty. Only the provided fields change.', + + 'autoOff turns the side off after autoOffMinutes once the bed is empty. ' + + 'autoOn (experimental) turns the side on once a person has been in bed for autoOnMinutes, between autoOnStart ' + + 'and autoOnEnd local time (HH:MM, wraps midnight; equal times = any time); pets and other light loads never trigger it. ' + + 'Only the provided fields change.', inputSchema: { side, name: z.string().min(1).max(20).optional().describe('Display name, e.g. the sleeper\'s first name.'), @@ -39,6 +42,10 @@ export function registerSettingsTools(server: McpServer) { alwaysOn: z.boolean().optional(), autoOffEnabled: z.boolean().optional(), autoOffMinutes: z.number().int().min(5).max(120).optional(), + autoOnEnabled: z.boolean().optional(), + autoOnMinutes: z.number().int().min(1).max(60).optional().describe('Minutes a person must be in bed before auto-on fires.'), + autoOnStart: timeOfDay.optional().describe('Auto-on window start, HH:MM local time.'), + autoOnEnd: timeOfDay.optional().describe('Auto-on window end, HH:MM local time.'), }, annotations: MUTATING, }, async input => runTool(async () => jsonResult(await getCaller().settings.updateSide(input)))) diff --git a/src/server/instrumentation-node.ts b/src/server/instrumentation-node.ts index 303ffa19d..9bf4e1bf3 100644 --- a/src/server/instrumentation-node.ts +++ b/src/server/instrumentation-node.ts @@ -32,6 +32,7 @@ import { startBonjourAnnouncement, stopBonjourAnnouncement } from '@/src/streami import { startMqttBridge, shutdownMqttBridge } from '@/src/streaming/mqttBridge' import { initializeKeepalives, shutdownKeepalives } from '@/src/services/temperatureKeepalive' import { startAutoOffWatcher, stopAutoOffWatcher } from '@/src/services/autoOffWatcher' +import { startAutoOnWatcher, stopAutoOnWatcher } from '@/src/services/autoOnWatcher' import { startDatabaseIntegrityChecks, stopDatabaseIntegrityChecks } from '@/src/db/integrity' import { startPerformanceMonitoring, stopPerformanceMonitoring, recordStartupPhase } from '@/src/lib/serverPerformance' import { shutdownHomeKit, startHomeKitIfEnabled } from '@/src/homekit' @@ -121,13 +122,19 @@ async function gracefulShutdown(signal: string): Promise { console.error('Error shutting down HomeKit:', error) } - // Step 4: Stop auto-off watcher (await in-flight power-off calls) + // Step 4: Stop auto-off / auto-on watchers (await in-flight power calls) try { await stopAutoOffWatcher() } catch (error) { console.error('Error stopping auto-off watcher:', error) } + try { + await stopAutoOnWatcher() + } + catch (error) { + console.error('Error stopping auto-on watcher:', error) + } // Step 5: Shutdown DAC monitor try { @@ -402,6 +409,9 @@ async function initializeBackgroundServices(): Promise { // Start auto-off watcher (polls biometrics DB for bed-exit events) startAutoOffWatcher() + // Start auto-on watcher (experimental: powers a side on when a person settles in) + startAutoOnWatcher() + // Start Bonjour/mDNS announcement (non-blocking) startBonjourAnnouncement() diff --git a/src/server/routers/biometrics.ts b/src/server/routers/biometrics.ts index a521809a4..e18e61933 100644 --- a/src/server/routers/biometrics.ts +++ b/src/server/routers/biometrics.ts @@ -625,12 +625,21 @@ export const biometricsRouter = router({ // (fresh capSense2 frame + completed calibration). The Settings UI // gates the auto-off toggle on this. available: z.boolean(), + // person / light (pet, object) / none — see lib/occupancy. + classification: z.enum(['person', 'light', 'none']), movement: z.object({ active: z.boolean(), peakScore: z.number() }), level: z.object({ active: z.boolean(), deviation: z.number().nullable(), threshold: z.number().nullable(), ageMs: z.number().nullable(), + zoneDeviations: z.array(z.number()).nullable(), + zones: z.number().nullable(), + }), + vitals: z.object({ + heartRate: z.number().nullable(), + breathingRate: z.number().nullable(), + humanRange: z.boolean().nullable(), }), }), right: z.object({ @@ -639,12 +648,21 @@ export const biometricsRouter = router({ // (fresh capSense2 frame + completed calibration). The Settings UI // gates the auto-off toggle on this. available: z.boolean(), + // person / light (pet, object) / none — see lib/occupancy. + classification: z.enum(['person', 'light', 'none']), movement: z.object({ active: z.boolean(), peakScore: z.number() }), level: z.object({ active: z.boolean(), deviation: z.number().nullable(), threshold: z.number().nullable(), ageMs: z.number().nullable(), + zoneDeviations: z.array(z.number()).nullable(), + zones: z.number().nullable(), + }), + vitals: z.object({ + heartRate: z.number().nullable(), + breathingRate: z.number().nullable(), + humanRange: z.boolean().nullable(), }), }), })) diff --git a/src/server/routers/settings.ts b/src/server/routers/settings.ts index a1e52cf1c..b2f571540 100644 --- a/src/server/routers/settings.ts +++ b/src/server/routers/settings.ts @@ -52,6 +52,10 @@ const sideSettingsSchema = z.object({ alwaysOn: z.boolean(), autoOffEnabled: z.boolean(), autoOffMinutes: z.number(), + autoOnEnabled: z.boolean(), + autoOnMinutes: z.number(), + autoOnStart: z.string(), + autoOnEnd: z.string(), awayStart: z.string().nullable().optional(), awayReturn: z.string().nullable().optional(), createdAt: timestampSchema, @@ -87,6 +91,7 @@ import { getJobManager } from '@/src/scheduler' import { updateAutomationTimezone } from '@/src/automation/instance' import { startKeepalive, stopKeepalive } from '@/src/services/temperatureKeepalive' import { restartAutoOffTimers } from '@/src/services/autoOffWatcher' +import { restartAutoOnWatcher } from '@/src/services/autoOnWatcher' import { invalidateGuardSettingsCache, standDown as standDownPumpStallGuard } from '@/src/hardware/pumpStallGuard' const REBOOT_KEYS = ['rebootDaily', 'rebootTime'] as const @@ -195,8 +200,8 @@ export const settingsRouter = router({ updatedAt: new Date(), }, sides: { - left: sides.find(s => s.side === 'left') ?? { side: 'left' as const, name: 'Left', alwaysOn: false, awayMode: false, autoOffEnabled: false, autoOffMinutes: 30, createdAt: new Date(), updatedAt: new Date() }, - right: sides.find(s => s.side === 'right') ?? { side: 'right' as const, name: 'Right', alwaysOn: false, awayMode: false, autoOffEnabled: false, autoOffMinutes: 30, createdAt: new Date(), updatedAt: new Date() }, + left: sides.find(s => s.side === 'left') ?? { side: 'left' as const, name: 'Left', alwaysOn: false, awayMode: false, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', createdAt: new Date(), updatedAt: new Date() }, + right: sides.find(s => s.side === 'right') ?? { side: 'right' as const, name: 'Right', alwaysOn: false, awayMode: false, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', createdAt: new Date(), updatedAt: new Date() }, }, gestures: { left: gestures.filter(g => g.side === 'left'), @@ -477,6 +482,10 @@ export const settingsRouter = router({ awayMode: z.boolean().optional(), autoOffEnabled: z.boolean().optional(), autoOffMinutes: z.number().int().min(5).max(120).optional(), + autoOnEnabled: z.boolean().optional(), + autoOnMinutes: z.number().int().min(1).max(60).optional(), + autoOnStart: timeStringSchema.optional(), + autoOnEnd: timeStringSchema.optional(), awayStart: isoDatetimeSchema.nullable().optional(), awayReturn: isoDatetimeSchema.nullable().optional(), }) @@ -489,6 +498,10 @@ export const settingsRouter = router({ alwaysOn: z.boolean(), autoOffEnabled: z.boolean(), autoOffMinutes: z.number(), + autoOnEnabled: z.boolean(), + autoOnMinutes: z.number(), + autoOnStart: z.string(), + autoOnEnd: z.string(), awayStart: z.string().nullable(), awayReturn: z.string().nullable(), createdAt: z.date(), @@ -604,6 +617,17 @@ export const settingsRouter = router({ } } + // Re-evaluate auto-on so a changed dwell/window/enable or an away + // toggle takes effect without waiting for the next 30s poll. + if ('autoOnEnabled' in input || 'autoOnMinutes' in input || 'autoOnStart' in input || 'autoOnEnd' in input || 'awayMode' in input) { + try { + restartAutoOnWatcher() + } + catch (e) { + console.error('Auto-on watcher restart failed:', e) + } + } + return updated } catch (error) { diff --git a/src/server/routers/tests/biometrics.test.ts b/src/server/routers/tests/biometrics.test.ts index b1668b923..ff132bada 100644 --- a/src/server/routers/tests/biometrics.test.ts +++ b/src/server/routers/tests/biometrics.test.ts @@ -136,13 +136,17 @@ const occupancyMock = vi.hoisted(() => ({ getOccupancy: vi.fn<(side: 'left' | 'right') => { occupied: boolean available: boolean + classification: 'person' | 'light' | 'none' movement: { active: boolean, peakScore: number } - level: { active: boolean, deviation: number | null, threshold: number | null, ageMs: number | null } + level: { active: boolean, deviation: number | null, threshold: number | null, ageMs: number | null, zoneDeviations: number[] | null, zones: number | null } + vitals: { heartRate: number | null, breathingRate: number | null, humanRange: boolean | null } }>(() => ({ occupied: false, available: false, + classification: 'none', movement: { active: false, peakScore: 0 }, - level: { active: false, deviation: null, threshold: null, ageMs: null }, + level: { active: false, deviation: null, threshold: null, ageMs: null, zoneDeviations: null, zones: null }, + vitals: { heartRate: null, breathingRate: null, humanRange: null }, })), })) vi.mock('@/src/lib/occupancy', () => occupancyMock) @@ -338,8 +342,10 @@ describe('biometrics.getOccupancy', () => { occupancyMock.getOccupancy.mockImplementation((side: 'left' | 'right') => ({ occupied: side === 'left', available: false, + classification: side === 'left' ? 'person' : 'none', movement: { active: side === 'left', peakScore: side === 'left' ? 300 : 10 }, - level: { active: false, deviation: null, threshold: null, ageMs: null }, + level: { active: false, deviation: null, threshold: null, ageMs: null, zoneDeviations: null, zones: null }, + vitals: { heartRate: null, breathingRate: null, humanRange: null }, })) const out = await caller.getOccupancy() expect(out.left.occupied).toBe(true) @@ -353,8 +359,10 @@ describe('biometrics.getOccupancy', () => { occupancyMock.getOccupancy.mockReturnValue({ occupied: true, available: true, + classification: 'light', movement: { active: false, peakScore: 5 }, - level: { active: true, deviation: 12.3, threshold: 6, ageMs: 500 }, + level: { active: true, deviation: 12.3, threshold: 6, ageMs: 500, zoneDeviations: [11, 0.6, 0.7], zones: 1 }, + vitals: { heartRate: null, breathingRate: 30, humanRange: false }, }) const out = await caller.getOccupancy() expect(out.left.available).toBe(true) @@ -362,6 +370,9 @@ describe('biometrics.getOccupancy', () => { expect(out.left.level.threshold).toBe(6) expect(out.left.level.ageMs).toBe(500) expect(out.left.level.active).toBe(true) + expect(out.left.level.zones).toBe(1) + expect(out.left.classification).toBe('light') + expect(out.left.vitals.humanRange).toBe(false) }) }) diff --git a/src/server/routers/tests/settings.test.ts b/src/server/routers/tests/settings.test.ts index ced5717b9..85611b4f3 100644 --- a/src/server/routers/tests/settings.test.ts +++ b/src/server/routers/tests/settings.test.ts @@ -110,6 +110,8 @@ vi.mock('@/src/hardware/pumpStallGuard', () => pumpStallMock) vi.mock('@/src/scheduler', () => ({ getJobManager: schedulerMock.getJobManager })) vi.mock('@/src/services/temperatureKeepalive', () => keepaliveMock) vi.mock('@/src/services/autoOffWatcher', () => autoOffMock) +const autoOnMock = vi.hoisted(() => ({ restartAutoOnWatcher: vi.fn() })) +vi.mock('@/src/services/autoOnWatcher', () => autoOnMock) vi.mock('@/src/homekit', () => homekitMock) vi.mock('@/src/db', () => ({ db: { select: dbMock.select, update: dbMock.update, transaction: dbMock.transaction }, @@ -132,6 +134,7 @@ beforeEach(() => { keepaliveMock.startKeepalive.mockReset() keepaliveMock.stopKeepalive.mockReset() autoOffMock.restartAutoOffTimers.mockReset() + autoOnMock.restartAutoOnWatcher.mockReset() pumpStallMock.invalidateGuardSettingsCache.mockReset() pumpStallMock.standDown.mockReset().mockResolvedValue(undefined) homekitMock.enable.mockReset().mockResolvedValue(undefined) @@ -160,8 +163,8 @@ describe('settings.getAll', () => { createdAt: new Date(0), updatedAt: new Date(0), } const sides = [ - { side: 'left', name: 'L', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0) }, - { side: 'right', name: 'R', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0) }, + { side: 'left', name: 'L', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0) }, + { side: 'right', name: 'R', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0) }, ] const gestures: unknown[] = [] dbState.topRowsQueue.push([device], sides, gestures) @@ -184,8 +187,8 @@ describe('settings.getAll', () => { it('partitions gestures by side', async () => { const device = { ...baseDevice } const sides = [ - { side: 'left', name: 'L', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0) }, - { side: 'right', name: 'R', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0) }, + { side: 'left', name: 'L', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0) }, + { side: 'right', name: 'R', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0) }, ] const gestures = [ { id: 1, side: 'left', tapType: 'doubleTap', actionType: 'temperature', temperatureChange: 'increment', temperatureAmount: 1, createdAt: new Date(0), updatedAt: new Date(0) }, @@ -465,7 +468,7 @@ describe('settings.updateDevice', () => { describe('settings.updateSide', () => { it('rejects alwaysOn + autoOffEnabled set true together', async () => { const current = { - side: 'left', name: 'L', awayMode: false, alwaysOn: true, autoOffEnabled: false, autoOffMinutes: 30, + side: 'left', name: 'L', awayMode: false, alwaysOn: true, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0), } dbState.txRowsQueue.push([current]) @@ -475,7 +478,7 @@ describe('settings.updateSide', () => { it('rejects awayReturn earlier than awayStart', async () => { const current = { - side: 'left', name: 'L', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, + side: 'left', name: 'L', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: '2025-01-10T00:00:00Z', awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0), } dbState.txRowsQueue.push([current]) @@ -486,9 +489,28 @@ describe('settings.updateSide', () => { })).rejects.toThrow(/awayReturn must not be before awayStart/) }) + it('re-evaluates the auto-on watcher when auto-on or away settings change', async () => { + const current = { + side: 'left', name: 'L', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', + awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0), + } + dbState.txRowsQueue.push([current], [{ ...current, autoOnEnabled: true, autoOnStart: '21:30' }]) + const out = await caller.updateSide({ side: 'left', autoOnEnabled: true, autoOnStart: '21:30' }) + expect(out.autoOnEnabled).toBe(true) + expect(out.autoOnStart).toBe('21:30') + expect(autoOnMock.restartAutoOnWatcher).toHaveBeenCalledTimes(1) + expect(autoOffMock.restartAutoOffTimers).not.toHaveBeenCalled() + }) + + it('rejects a malformed auto-on window time and an out-of-range dwell', async () => { + await expect(caller.updateSide({ side: 'left', autoOnStart: '9:00' })).rejects.toThrow() + await expect(caller.updateSide({ side: 'left', autoOnMinutes: 0 })).rejects.toThrow() + await expect(caller.updateSide({ side: 'left', autoOnMinutes: 61 })).rejects.toThrow() + }) + it('starts keepalive when alwaysOn=true', async () => { const current = { - side: 'left', name: 'L', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, + side: 'left', name: 'L', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0), } const updated = { ...current, alwaysOn: true } @@ -500,7 +522,7 @@ describe('settings.updateSide', () => { it('stops keepalive when alwaysOn=false', async () => { const current = { - side: 'left', name: 'L', awayMode: false, alwaysOn: true, autoOffEnabled: false, autoOffMinutes: 30, + side: 'left', name: 'L', awayMode: false, alwaysOn: true, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0), } const updated = { ...current, alwaysOn: false } @@ -519,7 +541,7 @@ describe('settings.updateSide', () => { describe('settings.setAlwaysOn', () => { it('starts keepalive when alwaysOn=true', async () => { const updated = { - side: 'left', name: 'L', awayMode: false, alwaysOn: true, autoOffEnabled: false, autoOffMinutes: 30, + side: 'left', name: 'L', awayMode: false, alwaysOn: true, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0), } dbState.txRowsQueue.push([updated]) @@ -530,7 +552,7 @@ describe('settings.setAlwaysOn', () => { it('stops keepalive when alwaysOn=false', async () => { const updated = { - side: 'right', name: 'R', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, + side: 'right', name: 'R', awayMode: false, alwaysOn: false, autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0), } dbState.txRowsQueue.push([updated]) @@ -611,7 +633,7 @@ const baseDevice = { const baseSide = { side: 'left' as const, name: 'L', awayMode: false, alwaysOn: false, - autoOffEnabled: false, autoOffMinutes: 30, + autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0), } @@ -847,7 +869,7 @@ function throwNonError(): never { const baseRightSide = { side: 'right' as const, name: 'R', awayMode: false, alwaysOn: false, - autoOffEnabled: false, autoOffMinutes: 30, + autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', awayStart: null, awayReturn: null, createdAt: new Date(0), updatedAt: new Date(0), } @@ -888,11 +910,11 @@ describe('settings.getAll — defaults and partitioning', () => { const result = await caller.getAll({}) expect(result.sides.left).toMatchObject({ side: 'left', name: 'Left', alwaysOn: false, awayMode: false, - autoOffEnabled: false, autoOffMinutes: 30, + autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', }) expect(result.sides.right).toMatchObject({ side: 'right', name: 'Right', alwaysOn: false, awayMode: false, - autoOffEnabled: false, autoOffMinutes: 30, + autoOffEnabled: false, autoOffMinutes: 30, autoOnEnabled: false, autoOnMinutes: 5, autoOnStart: '20:00', autoOnEnd: '08:00', }) }) diff --git a/src/services/autoOffWatcher.ts b/src/services/autoOffWatcher.ts index 99fd51341..87ba55f18 100644 --- a/src/services/autoOffWatcher.ts +++ b/src/services/autoOffWatcher.ts @@ -174,16 +174,17 @@ function getPoweredOnAtMs(side: Side): number | null { /** * Live presence for a side. Returns: - * 'occupied' — someone is in bed (movement or calibrated level signal) - * 'empty' — reliably sensed empty (level signal evaluable, not occupied) + * 'occupied' — a person is in bed (movement or calibrated level signal) + * 'empty' — reliably sensed empty (level signal evaluable, not occupied), + * or occupied by a light load only (a pet) — see + * `OccupancyClassification`; a cat must not hold the bed on * 'unsensable' — presence can't be sensed; auto-off must stand down */ function presenceState(side: Side): 'occupied' | 'empty' | 'unsensable' { try { const occ = getOccupancy(side) - if (occ.occupied) return 'occupied' - if (!occ.available) return 'unsensable' - return 'empty' + if (!occ.available) return occ.occupied ? 'occupied' : 'unsensable' + return occ.classification === 'person' ? 'occupied' : 'empty' } catch { // Treat any failure to read presence as unsensable — fail safe. diff --git a/src/services/autoOnWatcher.ts b/src/services/autoOnWatcher.ts new file mode 100644 index 000000000..fa889e35f --- /dev/null +++ b/src/services/autoOnWatcher.ts @@ -0,0 +1,431 @@ +/** + * Auto-On Watcher Service (experimental) + * + * Powers a side on once a PERSON has been in bed for `autoOnMinutes`, using the + * same live occupancy sensor as auto-off (`src/lib/occupancy.ts`). The mirror + * image of `autoOffWatcher`, with the guardrails inverted: auto-off must never + * act on missing data, auto-on must never energize a bed nobody wants on. + * + * - Fires only on `classification === 'person'`. A pet (single-zone load, no + * human vitals) never turns the bed on; see `OccupancyClassification`. + * - Dwell: the person must be present continuously for `autoOnMinutes` + * (default 5) so a sit-down or a passing cat doesn't trip it. + * - Window: only between `autoOnStart` and `autoOnEnd` local time (default + * 20:00–08:00, wraps midnight; equal times mean any time), so an afternoon + * nap doesn't fire unless the user widens it. + * - Suppression: a side that goes OFF while a person is still in it — a + * manual power-off, a scheduled power-off, the global cap — was turned off + * on purpose. Auto-on stands down until the bed has read non-person for + * `autoOnMinutes`, so it can't bounce the bed straight back on. An on→off + * that lands between two polls is caught by the side mutation stamp. + * - Away mode, an active run-once session, and the pump-stall guard each + * block it, as they do the scheduler's power-on paths. + * - Unsensable presence (no fresh capSense2 frame / no calibration) resets + * the dwell and never fires. + * + * Setpoint: `powerOnLocked` resolves the temperature from the controller's + * baseline (an active or upcoming schedule), falling back to the last target + * this watcher saw while the side was powered, then 75°F. Auto-on therefore + * behaves like an early schedule start, not a separate temperature preference. + */ + +import { and, eq } from 'drizzle-orm' +import { db } from '@/src/db' +import { deviceSettings, deviceState, runOnceSessions, sideSettings } from '@/src/db/schema' +import { getSharedHardwareClient } from '@/src/hardware/dacMonitor.instance' +import { markSideMutated } from '@/src/hardware/deviceStateSync' +import { getLastSideMutationAt } from '@/src/hardware/sideMutations' +import { shouldBlock as pumpStallShouldBlock } from '@/src/hardware/pumpStallGuard' +import { withSideLock } from '@/src/hardware/sideLock' +import { getOccupancy } from '@/src/lib/occupancy' +import { getJobManager } from '@/src/scheduler/instance' +import { broadcastMutationStatus } from '@/src/streaming/broadcastMutationStatus' +import { getTemperatureController } from '@/src/temperature/instance' +import { cancelAutoOffTimer } from './autoOffWatcher' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const POLL_INTERVAL_MS = 30_000 +const SIDES = ['left', 'right'] as const +type Side = (typeof SIDES)[number] + +const DEFAULT_FALLBACK_TEMP_F = 75 + +// --------------------------------------------------------------------------- +// Per-side state +// --------------------------------------------------------------------------- + +interface SideState { + /** First poll that saw a person in an OFF side; the dwell countdown anchor. */ + personSince: number | null + /** The side was turned off while a person was in it; hold off until the bed clears. */ + suppressed: boolean + /** First poll that saw non-person while suppressed; clears suppression after the dwell. */ + emptySince: number | null + /** Powered state at the previous poll, to detect ON→OFF transitions. */ + wasPowered: boolean | null + /** Last target seen while powered — the fallback setpoint when no schedule applies. */ + lastTarget: number | null + /** Time of the previous poll, to spot side mutations made since. */ + lastPollAt: number | null + /** Mutation stamp this watcher wrote itself, so its own power-on attempt is not read as an intentional off. */ + ownMutationAt: number +} + +const freshState = (): SideState => ({ + personSince: null, + suppressed: false, + emptySince: null, + wasPowered: null, + lastTarget: null, + lastPollAt: null, + ownMutationAt: 0, +}) + +const state: Record = { left: freshState(), right: freshState() } + +let pollHandle: ReturnType | null = null +const pendingPowerOns = new Set>() +const powerOnInFlight = new Set() +/** Bumped on stop and on settings changes; a queued power-on from an older generation stands down. */ +let generation = 0 + +// --------------------------------------------------------------------------- +// Config / DB reads +// --------------------------------------------------------------------------- + +interface SideConfig { + enabled: boolean + minutes: number + start: string + end: string + awayMode: boolean +} + +function getConfig(): Record { + const defaults: SideConfig = { enabled: false, minutes: 5, start: '20:00', end: '08:00', awayMode: false } + try { + const rows = db.select().from(sideSettings).all() + const pick = (side: Side): SideConfig => { + const r = rows.find(x => x.side === side) + if (!r) return defaults + return { enabled: r.autoOnEnabled, minutes: r.autoOnMinutes, start: r.autoOnStart, end: r.autoOnEnd, awayMode: r.awayMode } + } + return { left: pick('left'), right: pick('right') } + } + catch { + return { left: defaults, right: defaults } + } +} + +function getTimezone(): string { + try { + const [row] = db.select({ timezone: deviceSettings.timezone }).from(deviceSettings).limit(1).all() + return row?.timezone ?? 'America/Los_Angeles' + } + catch { + return 'America/Los_Angeles' + } +} + +function readPower(side: Side): { powered: boolean, target: number | null } { + try { + const [row] = db + .select({ isPowered: deviceState.isPowered, target: deviceState.targetTemperature }) + .from(deviceState) + .where(eq(deviceState.side, side)) + .limit(1) + .all() + return { powered: row?.isPowered ?? false, target: row?.target ?? null } + } + catch { + return { powered: false, target: null } + } +} + +function hasActiveRunOnce(side: Side): boolean { + try { + const [row] = db + .select({ id: runOnceSessions.id }) + .from(runOnceSessions) + .where(and(eq(runOnceSessions.side, side), eq(runOnceSessions.status, 'active'))) + .limit(1) + .all() + return !!row + } + catch { + return false + } +} + +type Presence = 'person' | 'other' | 'unsensable' + +/** 'person' = a person is in bed; 'other' = empty or a light load (pet); 'unsensable' = can't tell. */ +function presence(side: Side): Presence { + try { + const occ = getOccupancy(side) + if (!occ.available) return 'unsensable' + return occ.classification === 'person' ? 'person' : 'other' + } + catch { + return 'unsensable' + } +} + +// --------------------------------------------------------------------------- +// Time window +// --------------------------------------------------------------------------- + +function parseHm(value: string): number | null { + const m = /^(\d{2}):(\d{2})$/.exec(value) + if (!m) return null + const minutes = Number(m[1]) * 60 + Number(m[2]) + return minutes >= 0 && minutes < 24 * 60 ? minutes : null +} + +/** Minutes since local midnight in `timezone`; falls back to UTC on a bad zone. */ +export function localMinutes(now: Date, timezone: string): number { + try { + const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hourCycle: 'h23', hour: '2-digit', minute: '2-digit' }) + .formatToParts(now) + const hour = Number(parts.find(p => p.type === 'hour')?.value) + const minute = Number(parts.find(p => p.type === 'minute')?.value) + if (Number.isFinite(hour) && Number.isFinite(minute)) return hour * 60 + minute + } + catch { + // fall through to UTC + } + return now.getUTCHours() * 60 + now.getUTCMinutes() +} + +/** Half-open [start, end) window in minutes-of-day, wrapping midnight. Equal = always. */ +export function inWindow(nowMinutes: number, start: string, end: string): boolean { + const s = parseHm(start) + const e = parseHm(end) + if (s === null || e === null) return false + if (s === e) return true + return s < e ? nowMinutes >= s && nowMinutes < e : nowMinutes >= s || nowMinutes < e +} + +// --------------------------------------------------------------------------- +// Power on +// --------------------------------------------------------------------------- + +function isStillEligible(side: Side, gen: number): boolean { + if (gen !== generation) return false + const cfg = getConfig()[side] + if (!cfg.enabled || cfg.awayMode) return false + if (readPower(side).powered || hasActiveRunOnce(side)) return false + if (state[side].suppressed) return false + if (!inWindow(localMinutes(new Date(), getTimezone()), cfg.start, cfg.end)) return false + return presence(side) === 'person' +} + +async function powerOnSide(side: Side, gen: number): Promise { + const skip = () => console.log(`[auto-on] ${side}: power-on skipped, no longer eligible`) + try { + await withSideLock(side, async () => { + if (!isStillEligible(side, gen)) { + skip() + return + } + if (pumpStallShouldBlock(side)) { + console.warn(`[auto-on] ${side}: skipped, pump stall guard blocks the side`) + return + } + const client = getSharedHardwareClient() + await client.connect() + let jobManager: Awaited> | null = null + try { + jobManager = await getJobManager() + } + catch (e) { + console.warn(`[auto-on] ${side}: could not release a held power-off:`, e instanceof Error ? e.message : e) + } + // Settings, the watcher's lifetime or the bed may have changed while + // waiting on the hardware and the scheduler. + if (!isStillEligible(side, gen)) { + skip() + return + } + markSideMutated(side) + state[side].ownMutationAt = getLastSideMutationAt(side) + try { + // An explicit power-on after an alarm must drop the alarm's held + // shutdown, exactly as the scheduler's own power-on paths do. + jobManager?.releaseHeldPowerOff(side) + } + catch (e) { + console.warn(`[auto-on] ${side}: could not release a held power-off:`, e instanceof Error ? e.message : e) + } + const fallback = state[side].lastTarget ?? DEFAULT_FALLBACK_TEMP_F + const status = await getTemperatureController().powerOnLocked(side, fallback, () => gen === generation) + if (gen !== generation) { + skip() + return + } + // A quick manual off before the next poll must still read as an + // intentional off, so record the on here rather than waiting to sample it. + state[side].wasPowered = true + cancelAutoOffTimer(side) + broadcastMutationStatus(side, {}) + const minutes = getConfig()[side].minutes + console.log(`[auto-on] Powered on ${side} side (person in bed ${minutes}min) → ${status.targetTemperature ?? fallback}°F`) + }) + } + catch (error) { + console.error(`[auto-on] Failed to power on ${side}:`, error instanceof Error ? error.message : error) + } +} + +function firePowerOn(side: Side): void { + powerOnInFlight.add(side) + const p = powerOnSide(side, generation).finally(() => { + state[side].personSince = null + powerOnInFlight.delete(side) + pendingPowerOns.delete(p) + }) + pendingPowerOns.add(p) +} + +// --------------------------------------------------------------------------- +// Core poll logic +// --------------------------------------------------------------------------- + +function evaluateSide(side: Side, cfg: SideConfig, timezone: string): void { + const st = state[side] + const now = Date.now() + const { powered, target } = readPower(side) + const who = presence(side) + const previousPollAt = st.lastPollAt + st.lastPollAt = now + + // Track ON→OFF transitions regardless of whether the feature is enabled, so + // enabling it right after a manual off doesn't fire into a bed just vacated + // on purpose. + if (powered) { + if (target !== null) st.lastTarget = target + st.personSince = null + st.emptySince = null + if (st.suppressed) { + st.suppressed = false + console.log(`[auto-on] ${side}: side powered, suppression cleared`) + } + st.wasPowered = true + return + } + // A side mutation since the last poll that left the side off — an on→off + // too quick for two polls to sample — counts as an intentional off too. + const mutatedAt = getLastSideMutationAt(side) + const mutatedSinceLastPoll = previousPollAt !== null && mutatedAt > previousPollAt && mutatedAt !== st.ownMutationAt + if ((st.wasPowered === true || mutatedSinceLastPoll) && who === 'person') { + st.suppressed = true + st.emptySince = null + console.log(`[auto-on] ${side}: turned off with a person in bed, standing down until the bed clears`) + } + st.wasPowered = false + + if (!cfg.enabled || cfg.awayMode) { + st.personSince = null + return + } + + if (powerOnInFlight.has(side)) return + + if (who === 'unsensable') { + // Neither countdown may run across a gap: the bed was not known to be + // clear (or occupied) the whole time. + st.personSince = null + st.emptySince = null + return + } + + if (who === 'other') { + st.personSince = null + if (st.suppressed) { + st.emptySince ??= now + if (now - st.emptySince >= cfg.minutes * 60_000) { + st.suppressed = false + st.emptySince = null + console.log(`[auto-on] ${side}: bed clear for ${cfg.minutes}min, re-armed`) + } + } + return + } + + // who === 'person' + st.emptySince = null + if (st.suppressed) return + if (hasActiveRunOnce(side)) { + st.personSince = null + return + } + if (!inWindow(localMinutes(new Date(now), timezone), cfg.start, cfg.end)) { + st.personSince = null + return + } + if (st.personSince === null) { + st.personSince = now + console.log(`[auto-on] ${side}: person in bed, auto-on in ${cfg.minutes}min if still present`) + return + } + if (now - st.personSince >= cfg.minutes * 60_000) { + console.log(`[auto-on] ${side}: person present for ${Math.round((now - st.personSince) / 1000)}s (past ${cfg.minutes}min dwell), powering on`) + firePowerOn(side) + } +} + +function poll(): void { + const config = getConfig() + const timezone = getTimezone() + for (const side of SIDES) { + try { + evaluateSide(side, config[side], timezone) + } + catch (error) { + console.error(`[auto-on] Error evaluating ${side}:`, error instanceof Error ? error.message : error) + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function startAutoOnWatcher(): void { + if (pollHandle) return + console.log('[auto-on] Watcher started (poll every 30s)') + poll() + pollHandle = setInterval(poll, POLL_INTERVAL_MS) +} + +export async function stopAutoOnWatcher(): Promise { + if (pollHandle) { + clearInterval(pollHandle) + pollHandle = null + } + generation++ + for (const side of SIDES) state[side] = freshState() + if (pendingPowerOns.size > 0) { + console.log(`[auto-on] Waiting for ${pendingPowerOns.size} in-flight power-on(s)...`) + await Promise.allSettled([...pendingPowerOns]) + } + console.log('[auto-on] Watcher stopped') +} + +/** + * Re-evaluate after a settings change. Resets the dwell countdown (a changed + * window or dwell starts clean) and drops any queued power-on, but keeps + * suppression and the last target. + */ +export function restartAutoOnWatcher(): void { + if (!pollHandle) return + generation++ + for (const side of SIDES) { + state[side].personSince = null + state[side].emptySince = null + } + poll() +} diff --git a/src/services/tests/autoOffWatcher.test.ts b/src/services/tests/autoOffWatcher.test.ts index 66ab8e24b..a1d6096bb 100644 --- a/src/services/tests/autoOffWatcher.test.ts +++ b/src/services/tests/autoOffWatcher.test.ts @@ -27,17 +27,21 @@ vi.mock('@/src/hardware/deviceStateSync', () => ({ let mockOccupancy: Record<'left' | 'right', OccupancyResult> let occupancyFailure: unknown = null -function occ(occupied: boolean, available: boolean): OccupancyResult { +function occ(occupied: boolean, available: boolean, classification?: OccupancyResult['classification']): OccupancyResult { return { occupied, available, + classification: classification ?? (occupied ? 'person' : 'none'), movement: { active: occupied, peakScore: occupied ? 660 : 0 }, level: { active: occupied, deviation: available ? (occupied ? 999 : 0) : null, threshold: available ? 500 : null, ageMs: available ? 100 : null, + zoneDeviations: available ? (occupied ? [333, 333, 333] : [0, 0, 0]) : null, + zones: available ? (occupied ? 3 : 0) : null, }, + vitals: { heartRate: null, breathingRate: null, humanRange: null }, } } @@ -248,6 +252,31 @@ describe('autoOffWatcher — live presence', () => { }) }) +describe('autoOffWatcher — light loads (pets) read as empty', () => { + it('powers off when only a light load remains in a sensable bed', async () => { + mockOccupancy.left = occ(true, true, 'light') + startAutoOffWatcher() + await vi.advanceTimersByTimeAsync(31 * 60_000) + expect(setPower).toHaveBeenCalledWith('left', false) + }) + + it('keeps counting down when a person leaves and a pet takes their place', async () => { + mockOccupancy.left = occ(true, true) + startAutoOffWatcher() + await vi.advanceTimersByTimeAsync(10 * 60_000) + mockOccupancy.left = occ(true, true, 'light') + await vi.advanceTimersByTimeAsync(31 * 60_000) + expect(setPower).toHaveBeenCalledWith('left', false) + }) + + it('still treats movement-only occupancy as occupied when the level signal is unsensable', async () => { + mockOccupancy.left = occ(true, false) + startAutoOffWatcher() + await vi.advanceTimersByTimeAsync(120 * 60_000) + expect(setPower).not.toHaveBeenCalled() + }) +}) + describe('autoOffWatcher — fail-safe on unsensable presence', () => { it('does NOT power off when presence sensing is unavailable', async () => { mockOccupancy.left = occ(false, false) // empty-looking but unsensable diff --git a/src/services/tests/autoOnWatcher.test.ts b/src/services/tests/autoOnWatcher.test.ts new file mode 100644 index 000000000..90b04a0db --- /dev/null +++ b/src/services/tests/autoOnWatcher.test.ts @@ -0,0 +1,612 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { resetControlDatabase } from '@/src/temperature/tests/databaseFixture' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type BetterSqlite3 from 'better-sqlite3' +import type { OccupancyResult } from '@/src/lib/occupancy' + +// ── Shared mocks ────────────────────────────────────────────────────────── +const connect = vi.fn(async () => {}) +interface PowerOnStatus { source: null, requestId: null, targetTemperature: number, holdUntil: null, blocked: null } +const powerOnLocked = vi.fn<(side: 'left' | 'right', fallback?: number) => Promise>(async () => ({ + source: null, requestId: null, targetTemperature: 72, holdUntil: null, blocked: null, +})) +const broadcastMutationStatus = vi.fn() +const markSideMutated = vi.fn() +const cancelAutoOffTimer = vi.fn() +const releaseHeldPowerOff = vi.fn() +const shouldBlock = vi.fn<(side: 'left' | 'right') => boolean>(() => false) + +vi.mock('@/src/hardware/dacMonitor.instance', () => ({ + getSharedHardwareClient: () => ({ connect }), +})) +vi.mock('@/src/temperature/instance', () => ({ + getTemperatureController: () => ({ powerOnLocked }), +})) +vi.mock('@/src/streaming/broadcastMutationStatus', () => ({ + broadcastMutationStatus: (...args: unknown[]) => broadcastMutationStatus(...args), +})) +vi.mock('@/src/hardware/deviceStateSync', () => ({ + markSideMutated: (...args: unknown[]) => markSideMutated(...args), +})) +vi.mock('@/src/hardware/pumpStallGuard', () => ({ + shouldBlock: (side: 'left' | 'right') => shouldBlock(side), +})) +vi.mock('@/src/scheduler/instance', () => ({ + getJobManager: async () => ({ releaseHeldPowerOff }), +})) +vi.mock('./../autoOffWatcher', () => ({ + cancelAutoOffTimer: (...args: unknown[]) => cancelAutoOffTimer(...args), +})) + +let mockOccupancy: Record<'left' | 'right', OccupancyResult> +let occupancyFailure: unknown = null +/** Per-read overrides for the left side, consumed in order before falling back to mockOccupancy. */ +const leftReadQueue: OccupancyResult[] = [] + +function occ(classification: OccupancyResult['classification'], available = true): OccupancyResult { + const occupied = classification !== 'none' + return { + occupied, + available, + classification, + movement: { active: false, peakScore: 0 }, + level: { + active: occupied, + deviation: available ? (occupied ? 12 : 0) : null, + threshold: available ? 6 : null, + ageMs: available ? 100 : null, + zoneDeviations: available ? [4, 4, 4] : null, + zones: available ? (classification === 'person' ? 3 : classification === 'light' ? 1 : 0) : null, + }, + vitals: { heartRate: null, breathingRate: null, humanRange: null }, + } +} + +vi.mock('@/src/lib/occupancy', () => ({ + getOccupancy: (side: 'left' | 'right') => { + if (occupancyFailure !== null) throw occupancyFailure + const queued = side === 'left' ? leftReadQueue.shift() : undefined + if (queued) return queued + return mockOccupancy[side] + }, +})) + +vi.mock('@/src/db', async () => { + const BetterSqlite3 = (await import('better-sqlite3')).default + const { drizzle } = await import('drizzle-orm/better-sqlite3') + const schema = await import('@/src/db/schema') + const primary = new BetterSqlite3(':memory:') + primary.pragma('foreign_keys = ON') + return { + db: drizzle(primary, { schema }), + sqlite: primary, + closeDatabase: vi.fn(), + } +}) + +import * as dbModule from '@/src/db' +import { withSideLock } from '@/src/hardware/sideLock' +import { _resetMutationStamps, markSideMutated as stampSideMutation } from '@/src/hardware/sideMutations' +import { + inWindow, + localMinutes, + restartAutoOnWatcher, + startAutoOnWatcher, + stopAutoOnWatcher, +} from '@/src/services/autoOnWatcher' + +const { sqlite } = dbModule as typeof dbModule & { sqlite: BetterSqlite3.Database } + +const POLL_MS = 30_000 +const MIN = 60_000 +// 2026-06-01T03:00Z — 03:00 in the UTC device timezone, inside the default 20:00–08:00 window. +const NIGHT = new Date('2026-06-01T03:00:00Z') + +function resetSchema(): void { + resetControlDatabase(sqlite) + sqlite.exec(`INSERT INTO device_settings (id, timezone) VALUES (1, 'UTC'); + INSERT INTO side_settings (side, name, auto_on_enabled, auto_on_minutes) + VALUES ('left', 'Left', 1, 5), ('right', 'Right', 0, 5); + INSERT INTO device_state (side, is_powered) VALUES ('left', 0), ('right', 0);`) +} + +function setPowered(side: 'left' | 'right', powered: boolean, target: number | null = null): void { + ;(sqlite as any) + .prepare('UPDATE device_state SET is_powered=?, target_temperature=? WHERE side=?') + .run(powered ? 1 : 0, target, side) +} + +function setSideSettings(side: 'left' | 'right', patch: Partial<{ + autoOnEnabled: number + autoOnMinutes: number + autoOnStart: string + autoOnEnd: string + awayMode: number +}>): void { + const current = (sqlite as any).prepare('SELECT * FROM side_settings WHERE side=?').get(side) + ;(sqlite as any) + .prepare('UPDATE side_settings SET auto_on_enabled=?, auto_on_minutes=?, auto_on_start=?, auto_on_end=?, away_mode=? WHERE side=?') + .run( + patch.autoOnEnabled ?? current.auto_on_enabled, + patch.autoOnMinutes ?? current.auto_on_minutes, + patch.autoOnStart ?? current.auto_on_start, + patch.autoOnEnd ?? current.auto_on_end, + patch.awayMode ?? current.away_mode, + side, + ) +} + +function insertActiveRunOnce(side: 'left' | 'right'): void { + ;(sqlite as any) + .prepare(`INSERT INTO run_once_sessions (side, set_points, wake_time, expires_at, status) + VALUES (?, '[]', '07:00', ?, 'active')`) + .run(side, Math.floor(Date.now() / 1000) + 3600) +} + +beforeEach(() => { + vi.restoreAllMocks() + vi.useFakeTimers() + vi.setSystemTime(NIGHT) + connect.mockClear() + powerOnLocked.mockClear() + broadcastMutationStatus.mockClear() + markSideMutated.mockClear() + cancelAutoOffTimer.mockClear() + releaseHeldPowerOff.mockClear() + shouldBlock.mockReset() + shouldBlock.mockImplementation(() => false) + occupancyFailure = null + leftReadQueue.length = 0 + _resetMutationStamps() + resetSchema() + mockOccupancy = { left: occ('none'), right: occ('none') } +}) + +afterEach(async () => { + await stopAutoOnWatcher() + vi.useRealTimers() +}) + +describe('autoOnWatcher — dwell and power-on', () => { + it('powers a side on once a person has been in bed for the dwell', async () => { + mockOccupancy.left = occ('person') + startAutoOnWatcher() // poll@0 stamps personSince + expect(powerOnLocked).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(5 * MIN) + expect(powerOnLocked).toHaveBeenCalledWith('left', 75, expect.any(Function)) + expect(powerOnLocked).toHaveBeenCalledTimes(1) + }) + + it('does not fire before the dwell elapses', async () => { + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(4 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('runs the full scheduler-style power-on sequence', async () => { + mockOccupancy.left = occ('person') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(5 * MIN) + expect(markSideMutated).toHaveBeenCalledWith('left') + expect(releaseHeldPowerOff).toHaveBeenCalledWith('left') + expect(connect).toHaveBeenCalledOnce() + expect(cancelAutoOffTimer).toHaveBeenCalledWith('left') + expect(broadcastMutationStatus).toHaveBeenCalledWith('left', {}) + expect(log).toHaveBeenCalledWith('[auto-on] left: person in bed, auto-on in 5min if still present') + expect(log).toHaveBeenCalledWith('[auto-on] Powered on left side (person in bed 5min) → 72°F') + }) + + it('uses the last target seen while powered as the fallback setpoint', async () => { + setPowered('left', true, 68) + startAutoOnWatcher() // observes powered + target 68 + setPowered('left', false) // off with nobody in bed → no suppression + await vi.advanceTimersByTimeAsync(POLL_MS) + mockOccupancy.left = occ('person') + await vi.advanceTimersByTimeAsync(6 * MIN) + expect(powerOnLocked).toHaveBeenCalledWith('left', 68, expect.any(Function)) + }) + + it('resets the dwell when the person leaves mid-countdown', async () => { + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(3 * MIN) + mockOccupancy.left = occ('none') + await vi.advanceTimersByTimeAsync(POLL_MS) + mockOccupancy.left = occ('person') + await vi.advanceTimersByTimeAsync(3 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(3 * MIN) + expect(powerOnLocked).toHaveBeenCalledTimes(1) + }) + + it('honours a custom dwell', async () => { + setSideSettings('left', { autoOnMinutes: 1 }) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(MIN) + expect(powerOnLocked).toHaveBeenCalledTimes(1) + }) + + it('does nothing for a side that is already powered', async () => { + setPowered('left', true, 70) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(30 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('does not re-fire while a power-on is in flight', async () => { + let release: () => void = () => {} + powerOnLocked.mockImplementationOnce(() => new Promise((resolve) => { + release = () => resolve({ source: null, requestId: null, targetTemperature: 72, holdUntil: null, blocked: null }) + })) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(5 * MIN) + await vi.advanceTimersByTimeAsync(5 * MIN) + expect(powerOnLocked).toHaveBeenCalledTimes(1) + release() + await vi.advanceTimersByTimeAsync(0) + }) + + it('logs and survives a hardware failure', async () => { + connect.mockRejectedValueOnce(new Error('offline')) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(5 * MIN) + expect(error).toHaveBeenCalledWith('[auto-on] Failed to power on left:', 'offline') + expect(broadcastMutationStatus).not.toHaveBeenCalled() + }) +}) + +describe('autoOnWatcher — pets and unsensable presence', () => { + it('never fires on a light load (pet)', async () => { + mockOccupancy.left = occ('light') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(60 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('a pet interrupting the dwell resets it', async () => { + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(4 * MIN) + mockOccupancy.left = occ('light') + await vi.advanceTimersByTimeAsync(POLL_MS) + mockOccupancy.left = occ('person') + await vi.advanceTimersByTimeAsync(2 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('never fires when presence cannot be sensed', async () => { + mockOccupancy.left = occ('person', false) + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(60 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('treats a thrown occupancy read as unsensable', async () => { + occupancyFailure = new Error('sensor read failed') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(60 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) +}) + +describe('autoOnWatcher — suppression after an intentional off', () => { + it('stands down when the side is turned off with a person still in bed', async () => { + setPowered('left', true, 70) + mockOccupancy.left = occ('person') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + startAutoOnWatcher() + setPowered('left', false) // manual / scheduled off while occupied + await vi.advanceTimersByTimeAsync(60 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledWith('[auto-on] left: turned off with a person in bed, standing down until the bed clears') + }) + + it('re-arms once the bed has been clear for the dwell', async () => { + setPowered('left', true, 70) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + setPowered('left', false) + await vi.advanceTimersByTimeAsync(10 * MIN) // suppressed + mockOccupancy.left = occ('none') + await vi.advanceTimersByTimeAsync(5 * MIN + POLL_MS) // clear for the dwell → re-armed + mockOccupancy.left = occ('person') + await vi.advanceTimersByTimeAsync(5 * MIN + POLL_MS) + expect(powerOnLocked).toHaveBeenCalledTimes(1) + }) + + it('a brief absence shorter than the dwell does not re-arm', async () => { + setPowered('left', true, 70) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + setPowered('left', false) + await vi.advanceTimersByTimeAsync(POLL_MS) + mockOccupancy.left = occ('none') + await vi.advanceTimersByTimeAsync(2 * MIN) // bathroom trip + mockOccupancy.left = occ('person') + await vi.advanceTimersByTimeAsync(30 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('tracks the off transition even while the feature is disabled', async () => { + setSideSettings('left', { autoOnEnabled: 0 }) + setPowered('left', true, 70) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + setPowered('left', false) + await vi.advanceTimersByTimeAsync(POLL_MS) + setSideSettings('left', { autoOnEnabled: 1 }) + restartAutoOnWatcher() + await vi.advanceTimersByTimeAsync(30 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('stands down after an on→off that lands between two polls', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(POLL_MS) + await vi.advanceTimersByTimeAsync(1000) + mockOccupancy.left = occ('person') + stampSideMutation('left') // switched on and straight back off before the next poll + await vi.advanceTimersByTimeAsync(30 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledWith('[auto-on] left: turned off with a person in bed, standing down until the bed clears') + }) + + it('stands down when its own power-on is switched off before the next poll', async () => { + // powerOnLocked is mocked, so device_state never reads powered: exactly + // what a manual off inside the poll interval looks like. + mockOccupancy.left = occ('person') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(30 * MIN) + expect(powerOnLocked).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledWith('[auto-on] left: turned off with a person in bed, standing down until the bed clears') + }) + + it('an unsensable gap restarts the bed-clear countdown', async () => { + setPowered('left', true, 70) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + setPowered('left', false) + await vi.advanceTimersByTimeAsync(POLL_MS) // suppressed + mockOccupancy.left = occ('none') + await vi.advanceTimersByTimeAsync(3 * MIN) + mockOccupancy.left = occ('none', false) + await vi.advanceTimersByTimeAsync(POLL_MS) + mockOccupancy.left = occ('none') + await vi.advanceTimersByTimeAsync(3 * MIN) // 6.5 min since the first clear reading, 3 since the gap + mockOccupancy.left = occ('person') + await vi.advanceTimersByTimeAsync(30 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('an off while the bed is empty does not suppress', async () => { + setPowered('left', true, 70) + startAutoOnWatcher() + setPowered('left', false) + await vi.advanceTimersByTimeAsync(POLL_MS) + mockOccupancy.left = occ('person') + await vi.advanceTimersByTimeAsync(6 * MIN) + expect(powerOnLocked).toHaveBeenCalledTimes(1) + }) +}) + +describe('autoOnWatcher — gates', () => { + it('is off by default for a side with autoOnEnabled=false', async () => { + mockOccupancy.right = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(30 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('leaves a side with no settings row disabled', async () => { + ;(sqlite as any).exec('DELETE FROM side_settings') + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(30 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('skips a side in away mode', async () => { + setSideSettings('left', { awayMode: 1 }) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(30 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('skips a side with an active run-once session', async () => { + insertActiveRunOnce('left') + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(30 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('skips when the pump stall guard blocks the side', async () => { + shouldBlock.mockImplementation(side => side === 'left') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(5 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith('[auto-on] left: skipped, pump stall guard blocks the side') + }) + + it('re-checks eligibility under the side lock', async () => { + mockOccupancy.left = occ('person') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(5 * MIN - POLL_MS) + // The firing poll reads presence once to decide, then once more under the + // lock. The person leaves in between. + leftReadQueue.push(occ('person'), occ('none')) + await vi.advanceTimersByTimeAsync(POLL_MS) + expect(powerOnLocked).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledWith('[auto-on] left: power-on skipped, no longer eligible') + }) + + it('re-checks the window under the side lock', async () => { + setSideSettings('left', { autoOnStart: '20:00', autoOnEnd: '03:10' }) // now is 03:00 UTC + mockOccupancy.left = occ('person') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + let releaseLock!: () => void + void withSideLock('left', () => new Promise((resolve) => { + releaseLock = resolve + })) + startAutoOnWatcher() + // The dwell elapses inside the window, but the power-on waits behind the + // held lock until the window has closed. + await vi.advanceTimersByTimeAsync(11 * MIN) + releaseLock() + await vi.advanceTimersByTimeAsync(0) + expect(powerOnLocked).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledWith('[auto-on] left: power-on skipped, no longer eligible') + }) + + it('re-reads the settings under the side lock', async () => { + mockOccupancy.left = occ('person') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + let releaseLock!: () => void + void withSideLock('left', () => new Promise((resolve) => { + releaseLock = resolve + })) + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(5 * MIN + POLL_MS) + setSideSettings('left', { autoOnEnabled: 0 }) // disabled while the power-on waits + releaseLock() + await vi.advanceTimersByTimeAsync(0) + expect(powerOnLocked).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledWith('[auto-on] left: power-on skipped, no longer eligible') + }) + + it('a settings change drops a queued power-on', async () => { + mockOccupancy.left = occ('person') + let releaseLock!: () => void + void withSideLock('left', () => new Promise((resolve) => { + releaseLock = resolve + })) + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(5 * MIN + POLL_MS) + restartAutoOnWatcher() + releaseLock() + await vi.advanceTimersByTimeAsync(0) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('only fires inside the configured window', async () => { + setSideSettings('left', { autoOnStart: '21:00', autoOnEnd: '23:00' }) // now is 03:00 UTC + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(30 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('fires at any time when start and end are equal', async () => { + setSideSettings('left', { autoOnStart: '12:00', autoOnEnd: '12:00' }) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(5 * MIN) + expect(powerOnLocked).toHaveBeenCalledTimes(1) + }) + + it('evaluates the window in the device timezone', async () => { + // 03:00 UTC is 20:00 the previous evening in Los Angeles (PDT): inside a 19:00–21:00 window. + ;(sqlite as any).exec(`UPDATE device_settings SET timezone='America/Los_Angeles' WHERE id=1`) + setSideSettings('left', { autoOnStart: '19:00', autoOnEnd: '21:00' }) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(5 * MIN) + expect(powerOnLocked).toHaveBeenCalledTimes(1) + }) +}) + +describe('autoOnWatcher — lifecycle', () => { + it('start is idempotent and stop awaits in-flight power-ons', async () => { + let release: () => void = () => {} + powerOnLocked.mockImplementationOnce(() => new Promise((resolve) => { + release = () => resolve({ source: null, requestId: null, targetTemperature: 72, holdUntil: null, blocked: null }) + })) + mockOccupancy.left = occ('person') + startAutoOnWatcher() + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(5 * MIN) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const stopping = stopAutoOnWatcher() + release() + await stopping + expect(log).toHaveBeenCalledWith('[auto-on] Waiting for 1 in-flight power-on(s)...') + expect(log).toHaveBeenCalledWith('[auto-on] Watcher stopped') + }) + + it('stop drops a power-on still waiting for the side lock', async () => { + mockOccupancy.left = occ('person') + let releaseLock!: () => void + void withSideLock('left', () => new Promise((resolve) => { + releaseLock = resolve + })) + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(5 * MIN + POLL_MS) + const stopping = stopAutoOnWatcher() + releaseLock() + await stopping + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('restart is a no-op when the watcher is not running', () => { + expect(() => restartAutoOnWatcher()).not.toThrow() + expect(powerOnLocked).not.toHaveBeenCalled() + }) + + it('restart resets the dwell countdown', async () => { + mockOccupancy.left = occ('person') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(4 * MIN) + restartAutoOnWatcher() + await vi.advanceTimersByTimeAsync(2 * MIN) + expect(powerOnLocked).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(3 * MIN + POLL_MS) + expect(powerOnLocked).toHaveBeenCalledTimes(1) + }) + + it('logs and continues when a side evaluation throws', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + ;(sqlite as any).exec('DROP TABLE device_state') + startAutoOnWatcher() + await vi.advanceTimersByTimeAsync(POLL_MS) + // readPower swallows its own errors; nothing should surface as an evaluation error. + expect(error).not.toHaveBeenCalled() + expect(powerOnLocked).not.toHaveBeenCalled() + }) +}) + +describe('inWindow / localMinutes', () => { + it('handles same-day, midnight-wrapping and always windows', () => { + expect(inWindow(10 * 60, '09:00', '11:00')).toBe(true) + expect(inWindow(11 * 60, '09:00', '11:00')).toBe(false) // half-open end + expect(inWindow(9 * 60, '09:00', '11:00')).toBe(true) // inclusive start + expect(inWindow(23 * 60, '20:00', '08:00')).toBe(true) + expect(inWindow(3 * 60, '20:00', '08:00')).toBe(true) + expect(inWindow(12 * 60, '20:00', '08:00')).toBe(false) + expect(inWindow(12 * 60, '08:00', '08:00')).toBe(true) + }) + + it('rejects malformed times', () => { + expect(inWindow(600, '9:00', '11:00')).toBe(false) + expect(inWindow(600, '09:00', '24:00')).toBe(false) + }) + + it('converts to the device timezone and falls back to UTC on a bad zone', () => { + const t = new Date('2026-06-01T03:00:00Z') + expect(localMinutes(t, 'UTC')).toBe(180) + expect(localMinutes(t, 'America/Los_Angeles')).toBe(20 * 60) + expect(localMinutes(t, 'Not/AZone')).toBe(180) + }) +})