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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/adr/0023-autopilot-reactive-automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
99 changes: 99 additions & 0 deletions docs/adr/0025-presence-auto-on.md
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 14 additions & 0 deletions src/automation/signals.biometrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions src/automation/tests/signals.biometrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ vi.mock('@/src/streaming/piezoStream', () => ({
getLatestCapSenseSnapshot: () => capMock.snapshot,
}))

const occupancyMock = vi.hoisted(() => ({
bySide: {} as Record<string, { available: boolean, classification: 'person' | 'light' | 'none', zones: number | null }>,
}))

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'
Expand Down Expand Up @@ -90,6 +101,7 @@ describe('BiometricsSignalReader', () => {
dbMock.state.sideCalls = {}
dbMock.state.throws = false
capMock.snapshot = null
occupancyMock.bySide = {}
})

afterEach(() => {
Expand Down Expand Up @@ -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({})
})
Expand Down
12 changes: 9 additions & 3 deletions src/components/Autopilot/builderModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{ 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 },
Expand Down Expand Up @@ -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<string> = new Set([
'{side}.currentTemperature',
'{side}.targetTemperature',
'{side}.currentLevel',
'{side}.presence',
'{side}.presence.zones',
'water.low',
])

Expand Down
5 changes: 4 additions & 1 deletion src/components/Autopilot/tests/automationsLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}°`
Expand Down Expand Up @@ -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'])
})
Expand Down
9 changes: 8 additions & 1 deletion src/components/Sensors/PresenceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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}
/>
Expand Down
6 changes: 5 additions & 1 deletion src/components/Sensors/tests/sensorCards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PresenceCard />)
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(<PresenceCard />)
emit({ type: 'log' })
Expand All @@ -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<HTMLElement>('[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(<PresenceCard />)
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', () => {
Expand Down
Loading
Loading