From 02da363a31477a8e6cb10101a3831a60fd7ea6a8 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sat, 6 Jun 2026 14:57:01 -0700 Subject: [PATCH 01/61] Pin mqttBridge payload contents to kill surviving mutants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weekly mutation baseline (#591) flagged 220 surviving mutants in src/streaming/mqttBridge.ts — the largest single contributor. The existing tests asserted topic suffixes and a few fields but never the full published payloads, so emptying any string literal or flipping a guard survived undetected. Add mutation-coverage suites that pin the exact contents the bridge emits: the full HA discovery config set (deep-equal over all 19 homeassistant/* topics), per-side publishState payloads, the connect-option/LWT/clientId wiring, the testConnection TLS matrix, and the error-path warn messages. No source changes. Scoped Stryker: 220 -> 32 surviving on this file (61.6% baseline file score -> 92.18%). The remaining 32 are equivalent mutants (slugify's own empty-string fallback, JSON.stringify dropping undefined, safePublish's connected-guard, setTimeout timing values, info-level console.log strings). --- src/streaming/tests/mqttBridge.test.ts | 535 +++++++++++++++++++++++++ 1 file changed, 535 insertions(+) diff --git a/src/streaming/tests/mqttBridge.test.ts b/src/streaming/tests/mqttBridge.test.ts index e898a1aa..6624c1b4 100644 --- a/src/streaming/tests/mqttBridge.test.ts +++ b/src/streaming/tests/mqttBridge.test.ts @@ -1465,3 +1465,538 @@ describe('mqttBridge — testConnection', () => { expect(opts.reconnectPeriod).toBe(0) }) }) + +// --------------------------------------------------------------------------- +// Mutation-coverage suites +// +// The blocks below pin the *contents* of the bridge's published payloads, not +// just their topics. They exist to kill the string/array/number-literal and +// conditional mutants Stryker reported surviving in mqttBridge.ts — every +// asserted constant is written out independently of the source so emptying or +// flipping it in mqttBridge.ts fails a test here. +// --------------------------------------------------------------------------- + +describe('mqttBridge — resolveConfig mutation coverage', () => { + it('warns with the device_settings fallback message when the row read throws', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + dbMock.state.throwOnSelect = true + + await resolveConfig() + + expect(warn).toHaveBeenCalledWith( + '[mqtt] failed to read device_settings — falling back to env:', + expect.anything(), + ) + warn.mockRestore() + }) + + it('reports DB-true haDiscovery with source "db" (pins the ?? boolean coalesce)', async () => { + // The "db sources" suite above uses mqttHaDiscovery=false, which is + // indistinguishable under the `?? → &&` mutant. A true value separates + // them: `true ?? null` → true (db) vs `true && null` → null (default). + dbMock.state.row = { mqttHaDiscovery: true } + + const { config, sources } = await resolveConfig() + + expect(sources.haDiscovery).toBe('db') + expect(config.haDiscovery).toBe(true) + }) +}) + +describe('mqttBridge — safePublish error logging', () => { + async function startWithPublish(publishImpl: FakeClient['publish']): Promise { + const fake = createFakeClient() + fake.publish = publishImpl + mqttMock.state.nextClient = fake + bridgeState.client = null + bridgeState.runState = 'stopped' + bridgeState.resolved = null + bridgeState.messagesPublished = 0 + dbMock.state.row = { mqttEnabled: true, mqttUrl: 'mqtt://x' } + await startMqttBridge() + fake.connected = true + fake.emit('connect') + return fake + } + + it('warns "publish … failed" with the broker error when the publish callback errors', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await startWithPublish(vi.fn((_t: string, _p: any, _o: any, cb?: (err?: Error | null) => void) => { + cb?.(new Error('broker said no')) + }) as any) + + const matched = (warn.mock.calls as unknown[][]).some(args => + String(args[0] ?? '').includes('[mqtt] publish ') + && String(args[0] ?? '').includes(' failed:') + && args[1] === 'broker said no', + ) + expect(matched).toBe(true) + warn.mockRestore() + await shutdownMqttBridge() + }) + + it('warns "publish … threw" when publish throws synchronously', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await startWithPublish(vi.fn(() => { + throw new Error('publish kaboom') + }) as any) + + const matched = (warn.mock.calls as unknown[][]).some(args => + String(args[0] ?? '').includes('[mqtt] publish ') + && String(args[0] ?? '').includes(' threw:') + && args[1] === 'publish kaboom', + ) + expect(matched).toBe(true) + warn.mockRestore() + await shutdownMqttBridge() + }) +}) + +describe('mqttBridge — HA discovery payload contents (mutation coverage)', () => { + const ID = 'pod-test' + const AVAIL = `sleepypod/${ID}/availability` + const DEVICE = { identifiers: [ID], name: `Sleepypod ${ID}`, manufacturer: 'Sleepypod', model: 'Pod' } + + function sensorCfg(o: { name: string, unique_id: string, state_topic: string, value_template: string, unit?: string, device_class?: string }) { + const cfg: Record = { + name: o.name, + unique_id: o.unique_id, + availability_topic: AVAIL, + payload_available: 'online', + payload_not_available: 'offline', + state_topic: o.state_topic, + value_template: o.value_template, + state_class: 'measurement', + device: DEVICE, + } + if (o.unit) cfg.unit_of_measurement = o.unit + if (o.device_class) cfg.device_class = o.device_class + return cfg + } + + function binaryCfg(o: { name: string, unique_id: string, state_topic: string }) { + return { + name: o.name, + unique_id: o.unique_id, + availability_topic: AVAIL, + payload_available: 'online', + payload_not_available: 'offline', + state_topic: o.state_topic, + payload_on: 'on', + payload_off: 'off', + device_class: 'problem', + device: DEVICE, + } + } + + function climateCfg(side: 'left' | 'right') { + const Side = side === 'left' ? 'Left' : 'Right' + const climateTopic = `sleepypod/${ID}/state/${side}/climate` + return { + name: `${Side} side`, + unique_id: `${ID}_${side}_climate`, + availability_topic: AVAIL, + payload_available: 'online', + payload_not_available: 'offline', + current_temperature_topic: climateTopic, + current_temperature_template: '{{ value_json.currentTemperature }}', + temperature_state_topic: climateTopic, + temperature_state_template: '{{ value_json.targetTemperature }}', + mode_state_topic: climateTopic, + mode_state_template: '{{ value_json.mode }}', + temperature_command_topic: `sleepypod/${ID}/cmd/set-temperature`, + temperature_command_template: `{ "side": "${side}", "temperature": {{ value | int }} }`, + mode_command_topic: `sleepypod/${ID}/cmd/set-power`, + mode_command_template: `{ "side": "${side}", "powered": {{ "true" if value == "heat" else "false" }} }`, + modes: ['off', 'heat'], + min_temp: 55, + max_temp: 110, + temp_step: 1, + temperature_unit: 'F', + device: DEVICE, + } + } + + it('publishes the full HA discovery config set with exact payloads', async () => { + process.env.MQTT_DEVICE_ID = ID + const fake = await startBridgeWithFake({ config: { haDiscovery: true, topicPrefix: 'sleepypod' } }) + fake.connected = true + fake.emit('connect') + await new Promise(r => setTimeout(r, 0)) + + const got: Record = {} + for (const [t, payload] of fake.publish.mock.calls as [string, string][]) { + if (typeof t === 'string' && t.startsWith('homeassistant/')) got[t] = JSON.parse(payload) + } + + const expected: Record = { + [`homeassistant/climate/${ID}/left/config`]: climateCfg('left'), + [`homeassistant/climate/${ID}/right/config`]: climateCfg('right'), + [`homeassistant/sensor/${ID}/water_level/config`]: sensorCfg({ + name: 'Water level', + unique_id: `${ID}_water_level`, + state_topic: `sleepypod/${ID}/state/water-level`, + value_template: '{{ value_json.level }}', + }), + [`homeassistant/sensor/${ID}/ambient_temperature/config`]: sensorCfg({ + name: 'Ambient temperature', + unique_id: `${ID}_ambient_temperature`, + state_topic: `sleepypod/${ID}/state/environment/ambient`, + value_template: '{{ value_json.temperature }}', + unit: '°C', + device_class: 'temperature', + }), + [`homeassistant/sensor/${ID}/ambient_humidity/config`]: sensorCfg({ + name: 'Ambient humidity', + unique_id: `${ID}_ambient_humidity`, + state_topic: `sleepypod/${ID}/state/environment/ambient`, + value_template: '{{ value_json.humidity }}', + unit: '%', + device_class: 'humidity', + }), + } + + for (const side of ['left', 'right'] as const) { + const Side = side === 'left' ? 'Left' : 'Right' + expected[`homeassistant/sensor/${ID}/pump_${side}_rpm/config`] = sensorCfg({ + name: `${Side} pump RPM`, + unique_id: `${ID}_pump_${side}_rpm`, + state_topic: `sleepypod/${ID}/pump/${side}/rpm`, + value_template: '{{ value_json.rpm }}', + unit: 'rpm', + }) + expected[`homeassistant/sensor/${ID}/pump_${side}_loop_temp/config`] = sensorCfg({ + name: `${Side} pump loop temp`, + unique_id: `${ID}_pump_${side}_loop_temp`, + state_topic: `sleepypod/${ID}/pump/${side}/loop_temp_c`, + value_template: '{{ value_json.temperature }}', + unit: '°C', + device_class: 'temperature', + }) + expected[`homeassistant/binary_sensor/${ID}/pump_${side}_stall/config`] = binaryCfg({ + name: `${Side} pump stall`, + unique_id: `${ID}_pump_${side}_stall`, + state_topic: `sleepypod/${ID}/pump/${side}/stall`, + }) + expected[`homeassistant/binary_sensor/${ID}/pump_${side}_clog/config`] = binaryCfg({ + name: `${Side} pump clog detected`, + unique_id: `${ID}_pump_${side}_clog`, + state_topic: `sleepypod/${ID}/pump/${side}/clog_detected`, + }) + expected[`homeassistant/sensor/${ID}/${side}_heart_rate/config`] = sensorCfg({ + name: `${Side} heart rate`, + unique_id: `${ID}_${side}_heart_rate`, + state_topic: `sleepypod/${ID}/state/biometrics/${side}`, + value_template: '{{ value_json.heartRate }}', + unit: 'bpm', + }) + expected[`homeassistant/sensor/${ID}/${side}_breathing_rate/config`] = sensorCfg({ + name: `${Side} breathing rate`, + unique_id: `${ID}_${side}_breathing_rate`, + state_topic: `sleepypod/${ID}/state/biometrics/${side}`, + value_template: '{{ value_json.breathingRate }}', + unit: 'br/min', + }) + expected[`homeassistant/sensor/${ID}/${side}_hrv/config`] = sensorCfg({ + name: `${Side} HRV`, + unique_id: `${ID}_${side}_hrv`, + state_topic: `sleepypod/${ID}/state/biometrics/${side}`, + value_template: '{{ value_json.hrv }}', + unit: 'ms', + }) + } + + expect(got).toEqual(expected) + await shutdownMqttBridge() + }) +}) + +describe('mqttBridge — publishState payload contents (mutation coverage)', () => { + const ID = 'pod-test' + + function setupData() { + dacMock.getDacMonitorIfRunning.mockReturnValue({ + getLastStatus: () => ({ + leftSide: { temp: 80 }, + rightSide: { temp: 82 }, + waterLevel: 'ok', + isPriming: false, + podVersion: '1.2.3', + }), + }) + dbMock.state.deviceStateRows = [ + { side: 'left', currentTemperature: 70, targetTemperature: 72, isPowered: true, isAlarmVibrating: false, waterLevel: 'ok', lastUpdated: new Date('2026-01-01T00:00:00Z') }, + { side: 'right', currentTemperature: 68, targetTemperature: 66, isPowered: false, isAlarmVibrating: true, waterLevel: 'low', lastUpdated: new Date('2026-01-02T00:00:00Z') }, + ] + dbMock.state.biometricsRow = { side: 'left', timestamp: new Date('2026-04-04T00:00:00Z'), heartRate: 60, hrv: 50, breathingRate: 14 } + dbMock.state.bedTempRow = { timestamp: new Date('2026-03-03T00:00:00Z'), ambientTemp: 2000, humidity: 5000, leftPumpRpm: 1800, rightPumpRpm: 1900, leftFlowrateCd: 2050, rightFlowrateCd: 2150 } + } + + async function capture(): Promise<{ fake: FakeClient, map: Record }> { + process.env.MQTT_DEVICE_ID = ID + setupData() + const fake = await startBridgeWithFake({ config: { haDiscovery: false, topicPrefix: 'sleepypod' } }) + fake.connected = true + fake.emit('connect') + await new Promise(r => setTimeout(r, 0)) + await new Promise(r => setTimeout(r, 0)) + const map: Record = {} + for (const [t, payload] of fake.publish.mock.calls as [string, string][]) { + if (typeof t === 'string') map[t] = payload + } + return { fake, map } + } + + it('device-status mirrors the DAC monitor status fields', async () => { + const { map } = await capture() + const payload = JSON.parse(map[`sleepypod/${ID}/state/device-status`]) + expect(payload).toMatchObject({ + leftSide: { temp: 80 }, + rightSide: { temp: 82 }, + waterLevel: 'ok', + isPriming: false, + podVersion: '1.2.3', + }) + expect(typeof payload.ts).toBe('number') + await shutdownMqttBridge() + }) + + it('water-level carries the DAC waterLevel', async () => { + const { map } = await capture() + const payload = JSON.parse(map[`sleepypod/${ID}/state/water-level`]) + expect(payload.level).toBe('ok') + await shutdownMqttBridge() + }) + + it('per-side climate payload maps isPowered → mode heat/off', async () => { + const { map } = await capture() + expect(JSON.parse(map[`sleepypod/${ID}/state/left/climate`])).toEqual({ + ts: new Date('2026-01-01T00:00:00Z').getTime(), + currentTemperature: 70, + targetTemperature: 72, + isPowered: true, + isAlarmVibrating: false, + mode: 'heat', + waterLevel: 'ok', + }) + expect(JSON.parse(map[`sleepypod/${ID}/state/right/climate`])).toEqual({ + ts: new Date('2026-01-02T00:00:00Z').getTime(), + currentTemperature: 68, + targetTemperature: 66, + isPowered: false, + isAlarmVibrating: true, + mode: 'off', + waterLevel: 'low', + }) + await shutdownMqttBridge() + }) + + it('biometrics payload carries heartRate / hrv / breathingRate', async () => { + const { map } = await capture() + expect(JSON.parse(map[`sleepypod/${ID}/state/biometrics/left`])).toEqual({ + ts: new Date('2026-04-04T00:00:00Z').getTime(), + heartRate: 60, + hrv: 50, + breathingRate: 14, + }) + await shutdownMqttBridge() + }) + + it('pump rpm payloads carry per-side rpm; loop_temp_c is scaled by /100', async () => { + const { map } = await capture() + expect(JSON.parse(map[`sleepypod/${ID}/pump/left/rpm`])).toEqual({ + ts: new Date('2026-03-03T00:00:00Z').getTime(), + rpm: 1800, + }) + expect(JSON.parse(map[`sleepypod/${ID}/pump/right/rpm`])).toEqual({ + ts: new Date('2026-03-03T00:00:00Z').getTime(), + rpm: 1900, + }) + expect(JSON.parse(map[`sleepypod/${ID}/pump/left/loop_temp_c`]).temperature).toBeCloseTo(20.5, 2) + expect(JSON.parse(map[`sleepypod/${ID}/pump/right/loop_temp_c`]).temperature).toBeCloseTo(21.5, 2) + await shutdownMqttBridge() + }) + + it('pump stall + clog publish "off" when no stall notice is active', async () => { + const { resetPumpStallNotifications } = await import('@/src/hardware/pumpStallNotification') + resetPumpStallNotifications() + const { map } = await capture() + expect(map[`sleepypod/${ID}/pump/left/stall`]).toBe('off') + expect(map[`sleepypod/${ID}/pump/right/stall`]).toBe('off') + expect(map[`sleepypod/${ID}/pump/left/clog_detected`]).toBe('off') + expect(map[`sleepypod/${ID}/pump/right/clog_detected`]).toBe('off') + resetPumpStallNotifications() + await shutdownMqttBridge() + }) +}) + +describe('mqttBridge — connect-option + lifecycle mutation coverage', () => { + it('builds a clientId prefixed with the device id and an LWT offline retained will', async () => { + process.env.MQTT_DEVICE_ID = 'pod-test' + await startBridgeWithFake({ config: { topicPrefix: 'sleepypod' } }) + + const [, opts] = mqttMock.connect.mock.calls.at(-1) as unknown as [string, any] + expect(String(opts.clientId)).toContain('sleepypod-pod-test-') + expect(opts.will?.payload).toEqual(Buffer.from('offline')) + expect(opts.will?.retain).toBe(true) + + await shutdownMqttBridge() + }) + + it('no-ops when already connected even with a valid enabled config', async () => { + bridgeState.client = null + bridgeState.runState = 'connected' + bridgeState.resolved = null + dbMock.state.row = { mqttEnabled: true, mqttUrl: 'mqtt://x' } + mqttMock.state.nextClient = createFakeClient() + + await startMqttBridge() + + expect(mqttMock.connect).not.toHaveBeenCalled() + + bridgeState.runState = 'stopped' + bridgeState.client = null + }) + + it('warns with the no-URL message when enabled but URL is missing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + bridgeState.runState = 'stopped' + bridgeState.client = null + dbMock.state.row = { mqttEnabled: true, mqttUrl: null } + + await startMqttBridge() + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('enabled but no URL configured')) + warn.mockRestore() + bridgeState.runState = 'stopped' + bridgeState.lastError = null + }) + + it('warns with the client-error message and records lastError', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const fake = await startBridgeWithFake() + fake.connected = true + fake.emit('connect') + + fake.emit('error', new Error('socket gone')) + + expect(warn).toHaveBeenCalledWith('[mqtt] client error:', 'socket gone') + expect(bridgeState.lastError).toBe('socket gone') + warn.mockRestore() + await shutdownMqttBridge() + }) + + it('logs the subscribe-failure message when subscribe errors', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const fake = createFakeClient() + fake.subscribe = vi.fn((_t: string, _o: any, cb?: (err: Error | null) => void) => { + cb?.(new Error('sub failed')) + return fake + }) as any + mqttMock.state.nextClient = fake + bridgeState.client = null + bridgeState.runState = 'stopped' + bridgeState.resolved = null + dbMock.state.row = { mqttEnabled: true, mqttUrl: 'mqtt://x' } + + await startMqttBridge() + fake.connected = true + fake.emit('connect') + + expect(warn).toHaveBeenCalledWith('[mqtt] subscribe cmd/* failed:', 'sub failed') + warn.mockRestore() + await shutdownMqttBridge() + }) + + it('ends the client with force=false on shutdown', async () => { + const fake = await startBridgeWithFake() + fake.connected = true + fake.emit('connect') + + await shutdownMqttBridge() + + const endCall = fake.end.mock.calls.at(-1) as unknown[] | undefined + expect(endCall?.[0]).toBe(false) + }) +}) + +describe('mqttBridge — command-dispatch + error-log mutation coverage', () => { + it('warns with the unknown-verb message for an unrecognised command', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const fake = await startBridgeWithFake() + fake.connected = true + fake.emit('connect') + + fake.emit('message', `sleepypod/${deviceId()}/cmd/bogus`, Buffer.from('{}')) + await new Promise(r => setTimeout(r, 0)) + + expect(warn).toHaveBeenCalledWith('[mqtt] unknown command verb: bogus') + warn.mockRestore() + await shutdownMqttBridge() + }) + + it('warns with the command-failed message when a handler rejects', async () => { + deviceMock.setTemperature.mockRejectedValueOnce(new Error('zod nope')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const fake = await startBridgeWithFake() + fake.connected = true + fake.emit('connect') + + fake.emit('message', `sleepypod/${deviceId()}/cmd/set-temperature`, Buffer.from('{}')) + await new Promise(r => setTimeout(r, 0)) + await new Promise(r => setTimeout(r, 0)) + + expect(warn).toHaveBeenCalledWith('[mqtt] command set-temperature failed:', 'zod nope') + warn.mockRestore() + await shutdownMqttBridge() + }) + + it('warns with the pump-rpm-failure message when the flow query throws', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + dbMock.state.throwOnBedTemp = true + + const fake = await startBridgeWithFake({ config: { haDiscovery: false } }) + fake.connected = true + fake.emit('connect') + await new Promise(r => setTimeout(r, 0)) + await new Promise(r => setTimeout(r, 0)) + + const matched = (warn.mock.calls as unknown[][]).some(args => + String(args[0] ?? '').includes('[mqtt] pump rpm publish failed') + && String(args[1] ?? '').includes('bed_temp boom'), + ) + expect(matched).toBe(true) + warn.mockRestore() + await shutdownMqttBridge() + }) +}) + +describe('mqttBridge — testConnection TLS option matrix (mutation coverage)', () => { + it('omits rejectUnauthorized when tlsEnabled but MQTT_TLS_INSECURE is unset', async () => { + const fake = createFakeClient() + mqttMock.state.nextClient = fake + + const promise = testConnection({ url: 'mqtts://x', tlsEnabled: true }) + await new Promise(r => setTimeout(r, 0)) + fake.emit('connect') + await promise + + const [, opts] = mqttMock.connect.mock.calls.at(-1) as unknown as [string, any] + expect(opts.rejectUnauthorized).toBeUndefined() + expect(String(opts.clientId)).toContain('sleepypod-test-') + }) + + it('omits rejectUnauthorized when MQTT_TLS_INSECURE is set but tlsEnabled is false', async () => { + process.env.MQTT_TLS_INSECURE = 'true' + const fake = createFakeClient() + mqttMock.state.nextClient = fake + + const promise = testConnection({ url: 'mqtt://x', tlsEnabled: false }) + await new Promise(r => setTimeout(r, 0)) + fake.emit('connect') + await promise + + const [, opts] = mqttMock.connect.mock.calls.at(-1) as unknown as [string, any] + expect(opts.rejectUnauthorized).toBeUndefined() + }) +}) From d31df6ddb7ee9f6a50826d1353ee9f3a50c26ce6 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sun, 7 Jun 2026 16:41:36 -0700 Subject: [PATCH 02/61] feat(autopilot): reactive rules engine + builder UI (nav hidden until P2) (#637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds **Autopilot** — a reactive WHEN/IF/THEN rules engine that sits beside the scheduler and responds to live pod signals, plus the full builder UI from the design bundle. Merged to `dev` now with the nav entry **hidden** so the half-wired signal catalog isn't user-facing until P2 lands the biometric/ambient/enum signal sources (sleepypod-core-69). ### Server - `automations.*` tRPC router — CRUD, `setEnabled`/`setDryRun`, `runs` (audit log), `status` (live state), kill-switch get/set, `nights`, `backtest`. Writes hot-reload the running engine (mirrors `schedules.ts` → JobManager). - Real-history backtest (`src/automation/backtest.ts`) — pure replay over recorded series (movement, ambient, vitals, water) for a chosen past night, reusing the engine's own `WindowStore`/`evaluateCondition`/`evaluateExpr` so the preview matches real behavior. Edge + policy modes. - Global kill-switch — persisted `autopilotEnabled` setting (migration 0014, additive `ALTER TABLE`) that short-circuits engine ticks and is restored at boot. ### Builder - `builderModel.ts` — bidirectional mapper between the friendly WHEN/IF/THEN UI and the engine AST ("lower 2°F" → `setTemperature(current − 2)`; "ambient + 3" → `setTemperature(ambient + 3)`). Round-trip unit-tested. ### UI - `/autopilot` desktop console (full-bleed, like `/debug`): automations list as plain-English sentences, two-pane rule editor with live sentence preview + live backtest, diagnostics panel (per-rule state, run-log, kill-switch, dry-run). - Compact Autopilot panel inside the `/debug` console (kept — this is the dogfooding entry point while nav is hidden). - **BottomNav entry removed** — `/autopilot` is deep-link-only until P2. ### Safety Engine is inert with zero rules (fresh DB no-ops each tick). Even an enabled rule today can only act on the live-wired signals (per-side temp/level, water.low); biometric/ambient/enum read `undefined`→skip by design. Every action is gated by manual-override hold, dry-run, and the kill-switch. ### Deferred to P2 (sleepypod-core-69) Engine handles numeric signals only, so the builder catalog is the numeric/backtestable subset. Enum/bool signals (sleep stage, occupancy) wait on their source wiring. ## Test plan - `pnpm tsc` clean, `pnpm lint` clean, `pnpm next build` clean. - Vitest: 1922 pass (+22 new); round-trip builder mapper, backtest replay, engine tick/safety covered. - Manual: deploy to pod 192.168.1.88; confirm BottomNav shows no Autopilot tab; confirm `/debug` Autopilot panel renders live engine state; deep-link `/autopilot` loads the builder. Closes sleepypod-core-66 (P0+P1). P2 tracked in sleepypod-core-69.
🛏️ Beam this PR onto a sleepypod Pick the snippet that matches your pod. **Already-installed pods can't use the curl bootstraps below** — sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use `sp-update` instead; it's on disk and opens WAN as its first step. 🔄 **Already on a sleepypod** (most common — review a PR on a running pod): ```bash sudo sp-update worktree-diy-autopilot ``` 🚀 **Fresh pod / first install** — bootstraps from GitHub, rebuilds every push: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/worktree-diy-autopilot/scripts/install \ | sudo bash -s -- --branch worktree-diy-autopilot ``` 🎯 **Pin to this exact build** ([run 27108297477](https://github.com/sleepypod/core/actions/runs/27108297477) · `c48a38d`) — fresh-pod path, locked to one CI artifact for reproducible review: ```bash curl -fsSL https://raw.githubusercontent.com/sleepypod/core/c48a38d28dbffffa14c1db146261e44cf7775675/scripts/install \ | sudo bash -s -- \ --branch worktree-diy-autopilot \ --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/27108297477/sleepypod-core.zip' ``` 🧊 Artifact: [`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/27108297477/artifacts/7469107386) · self-destructs in 30 days 💥
## Summary by CodeRabbit ## New Features * **Autopilot Console**: Introduced a reactive automation engine allowing users to create, manage, and test automation rules with a structured WHEN/IF/THEN interface. * **Rule Backtesting**: Added interactive backtest preview showing how automation rules would behave against historical sensor data. * **Automation Monitoring**: Added status panel displaying active rules, execution history, fires-today counts, and recent run audit logs. * **Safety Controls**: Implemented global kill-switch, dry-run mode for testing, cooldown gating, and anti-thrash protections. --- app/[lang]/autopilot/page.tsx | 5 + .../0023-autopilot-reactive-automations.md | 275 +++++ instrumentation.ts | 18 + src/automation/backtest.ts | 451 +++++++ src/automation/engine.ts | 461 +++++++ src/automation/evaluator.ts | 94 ++ src/automation/expressions.ts | 66 + src/automation/index.ts | 29 + src/automation/instance.ts | 152 +++ src/automation/signals.ts | 144 +++ src/automation/tests/backtest.test.ts | 345 ++++++ src/automation/tests/engine.test.ts | 647 ++++++++++ src/automation/tests/evaluator.test.ts | 121 ++ src/automation/tests/expressions.test.ts | 116 ++ src/automation/tests/instance.test.ts | 275 +++++ src/automation/tests/signals.test.ts | 161 +++ src/automation/tests/windows.test.ts | 57 + src/automation/types.ts | 111 ++ src/automation/windows.ts | 67 + src/components/Autopilot/AutomationsList.tsx | 172 +++ src/components/Autopilot/AutopilotConsole.tsx | 174 +++ src/components/Autopilot/BacktestPanel.tsx | 301 +++++ src/components/Autopilot/RuleEditor.tsx | 377 ++++++ src/components/Autopilot/StatusPanel.tsx | 213 ++++ src/components/Autopilot/builderModel.test.ts | 406 ++++++ src/components/Autopilot/builderModel.ts | 488 ++++++++ src/components/Autopilot/icons.tsx | 146 +++ src/components/Autopilot/primitives.tsx | 217 ++++ src/components/BottomNav/BottomNav.tsx | 5 + .../diagnostics/DiagnosticsConsole.tsx | 125 ++ .../migrations/0013_autopilot_automations.sql | 26 + src/db/migrations/0014_optimal_marvex.sql | 1 + src/db/migrations/meta/0013_snapshot.json | 1085 ++++++++++++++++ src/db/migrations/meta/0014_snapshot.json | 1093 +++++++++++++++++ src/db/migrations/meta/_journal.json | 14 + src/db/schema.ts | 52 + src/hardware/sideLock.ts | 36 + src/hardware/tests/pumpStallGuard.test.ts | 1 + src/scheduler/jobManager.ts | 44 +- src/scheduler/tests/jobOrdering.test.ts | 1 + src/server/routers/app.ts | 2 + src/server/routers/automations.ts | 464 +++++++ .../validation-schemas.automation.test.ts | 166 +++ src/server/validation-schemas.ts | 206 ++++ 44 files changed, 9373 insertions(+), 37 deletions(-) create mode 100644 app/[lang]/autopilot/page.tsx create mode 100644 docs/adr/0023-autopilot-reactive-automations.md create mode 100644 src/automation/backtest.ts create mode 100644 src/automation/engine.ts create mode 100644 src/automation/evaluator.ts create mode 100644 src/automation/expressions.ts create mode 100644 src/automation/index.ts create mode 100644 src/automation/instance.ts create mode 100644 src/automation/signals.ts create mode 100644 src/automation/tests/backtest.test.ts create mode 100644 src/automation/tests/engine.test.ts create mode 100644 src/automation/tests/evaluator.test.ts create mode 100644 src/automation/tests/expressions.test.ts create mode 100644 src/automation/tests/instance.test.ts create mode 100644 src/automation/tests/signals.test.ts create mode 100644 src/automation/tests/windows.test.ts create mode 100644 src/automation/types.ts create mode 100644 src/automation/windows.ts create mode 100644 src/components/Autopilot/AutomationsList.tsx create mode 100644 src/components/Autopilot/AutopilotConsole.tsx create mode 100644 src/components/Autopilot/BacktestPanel.tsx create mode 100644 src/components/Autopilot/RuleEditor.tsx create mode 100644 src/components/Autopilot/StatusPanel.tsx create mode 100644 src/components/Autopilot/builderModel.test.ts create mode 100644 src/components/Autopilot/builderModel.ts create mode 100644 src/components/Autopilot/icons.tsx create mode 100644 src/components/Autopilot/primitives.tsx create mode 100644 src/db/migrations/0013_autopilot_automations.sql create mode 100644 src/db/migrations/0014_optimal_marvex.sql create mode 100644 src/db/migrations/meta/0013_snapshot.json create mode 100644 src/db/migrations/meta/0014_snapshot.json create mode 100644 src/hardware/sideLock.ts create mode 100644 src/server/routers/automations.ts create mode 100644 src/server/tests/validation-schemas.automation.test.ts diff --git a/app/[lang]/autopilot/page.tsx b/app/[lang]/autopilot/page.tsx new file mode 100644 index 00000000..ba343c09 --- /dev/null +++ b/app/[lang]/autopilot/page.tsx @@ -0,0 +1,5 @@ +import { AutopilotConsole } from '@/src/components/Autopilot/AutopilotConsole' + +export default function AutopilotPage() { + return +} diff --git a/docs/adr/0023-autopilot-reactive-automations.md b/docs/adr/0023-autopilot-reactive-automations.md new file mode 100644 index 00000000..1b372477 --- /dev/null +++ b/docs/adr/0023-autopilot-reactive-automations.md @@ -0,0 +1,275 @@ +# ADR 0023: Reactive automations engine (Autopilot) + +**Status:** Accepted +**Date:** 2026-06-07 + +## Context + +Everything the Pod does on a timer today is **time-triggered**: a cron +expression feeds `node-schedule`, which feeds `JobManager`, which writes to +the hardware through `getSharedHardwareClient()`. Temperature curves, power +schedules, and alarms are all variations on "at time T, do X." There is no way +for the device to react to a *live signal* — to lower a setpoint because the +sleeper started moving, or to hold a temperature relative to room ambient as +the room warms through the night. + +Eight Sleep ships exactly this feature ("autopilot") as an opaque black box: +the bed changes temperature and the user has no way to see why. For a DIY +system whose whole reason to exist is that the owner controls and understands +their hardware, that opacity is the thing to beat. The product wedge is +**transparency** — show *why* a rule fired (an audit log) and *what it would +have done* on a past night (a backtest), so the user can trust the engine +before handing it the thermostat. + +The infrastructure a reactive engine needs already exists and is load-bearing +elsewhere: a shared FIFO hardware socket, a per-side mutex (`withSideLock`), +a liveness heartbeat with reboot recovery (the `JobManager` pattern), the +mutation-broadcast event bus (ADR 0015), and a typed live-signal surface from +the DAC monitor and biometrics modules. What is missing is an *evaluation +layer* that reads those signals and decides when to write — not new plumbing. + +Two motivating examples bound the design; the engine must handle both: + +1. **Continuous policy** — "During 23:00–06:00, hold my temp at `ambient + 3°F`." + The setpoint is a function of a live signal, re-evaluated as ambient moves. + Level-triggered, idempotent re-assertion while in-window. +2. **Edge-triggered rule** — "If movement averages > 200 over the last 10 min, + lower temp by 2°F." A windowed aggregate crossing a threshold fires a + one-shot action with a cooldown. + +The user never picks "continuous" vs "edge" — the distinction is implied by +whether the action references a live signal. The engine handles both through a +per-rule `idle → active → cooldown` state machine. + +## Decision + +Build an `AutomationEngine` that sits **beside** `JobManager`, reads the same +signal buses, and writes through the same hardware path. No new hardware code. + +### The rule model — WHEN / IF / THEN + +A small, three-primitive model rather than a full visual programming language: + +```text +Automation { + id, name, enabled, side?, priority, cooldownMin? + WHEN trigger // signal-change | tick | time-of-day + IF condition[] // AND/OR/NOT guard tree + THEN action[] // params may be expressions, e.g. ambient + 3 +} +``` + +- Example 1 → WHEN `ambient.temperature changes` · IF `time 23:00–06:00` + · THEN `set left.target = clamp(ambient + 3, min, max)` +- Example 2 → WHEN `tick (1m)` · IF `avg(left.movement, 10m) > 200` + · THEN `set left.target -= 2` (cooldown 30m) + +Conditions: numeric comparators, windowed aggregates +(`avg|max|min|sum|count(signal, last N min)`), time-of-day / day-of-week, +state/enum, `AND/OR/NOT`, plus stability primitives (`sustained for N min` +debounce and **hysteresis** with separate on/off thresholds so rules don't +chatter at a boundary). Actions are the existing device verbs +(`setTemperature`, `setPower`, `setLedBrightness`, alarm verbs, `setAwayMode`, +`startPriming`) plus a non-hardware `notify` action that is the safe default +for testing. Action params may be expressions with a visible clamp. + +### Data model + +Two tables, following `src/db/schema.ts` conventions, with the rule "AST" held +in JSON columns validated by zod at the tRPC boundary: + +```text +automations( + id, name, enabled, side? (left|right|null=both), + priority, cooldownMin?, trigger(json), conditions(json), actions(json), + createdAt, updatedAt ) + +automation_runs( + id, automationId, firedAt, + outcome(fired|skipped|clamped|dry_run|error), detail(json) ) +``` + +`automation_runs` is the transparency surface — it is what answers "why did my +bed warm up at 3am." It is not optional telemetry; it is the product. + +A tRPC `automations.*` router (CRUD + enable/dry-run + backtest), modelled on +`schedules.*`, hot-reloads the running engine on every write so changes take +effect without a restart (the `schedules.ts → JobManager` pattern). + +### Safety stack (non-negotiable — this drives a device someone sleeps on) + +- **Two-layer clamp** — every temperature expression is clamped first to the + action's own `[min,max]` band, then unconditionally to the hardware bound + 55–110°F. +- **Anti-thrash** — a setpoint is only re-asserted when it moves ≥0.5°F. Bed + temp slews 1–2°F/min, so spamming the pump with sub-threshold moves is + pointless and harmful. +- **Runaway guard** — a per-rule actions/hour cap; tripping it auto-disables + the rule and surfaces it in `/debug`. +- **Manual override wins** — touching the dial suspends autopilot on that side + for a hold window; the engine logs `skipped` rather than fighting the user. +- **Kill switch & away mode** — a global, persisted off-switch + (`device_settings.autopilotEnabled`) that short-circuits the tick and is + restored at boot; away mode already disables a side. +- **Dry-run** — a rule can run notify-only for several nights, logging + *would-fire* events without touching hardware, so the user builds trust + before the engine controls the bed. + +### Precedence stack (highest wins) + +1. Manual override (timed hold) +2. Active run-once session *(existing)* +3. **Autopilot automations** (by `priority`, then most recent) +4. Recurring temperature/power schedules *(existing)* +5. Neutral default + +Autopilot sits **above recurring schedules, below run-once and manual.** It is +realized without an explicit coordinator: the engine refuses to write a side +while `hasActiveRunOnceSession(side)` is true or a manual-override hold is +active (both log `skipped`); against recurring schedules it is last-writer-wins +on the shared `withSideLock(side)`, and because a continuous-policy rule +re-asserts its setpoint every tick, it wins the steady state. Constant-policy +rules are therefore the supported way to override a schedule. + +### Resolved tunables (P0, 2026-06-07) + +- **Manual-override hold** — `AUTOMATION_MANUAL_OVERRIDE_MS = 30 min`, in-memory + per side. Cleared on reboot (safe default: autopilot resumes). +- **Evaluator cadence** — a single global 60s tick (`AUTOMATION_TICK_MS`) that + also samples signals into the windowed-aggregate ring buffers. 60s matches + the ambient/movement cadence and sits well under the thermal slew, so a + coarser tick loses nothing while avoiding pump spam. Per-signal cadence is a + later refinement. +- **Clamp band** — the per-action `clamp {min,max}` (default + `AUTOMATION_DEFAULT_USER_MIN/MAX = 60/100°F`) is layer 1; the hardware bound + 55–110°F is layer 2, always applied. No new global per-user setting in P0 — + the per-action band is strictly inside the hardware range and is the smallest + surface that satisfies the two-layer requirement. + +### UI — structured forms, not a node graph + +`@xyflow/react` is already a dependency but read-only (`DataPipeline.tsx`); +`dnd-kit` is not installed. v1 is **structured WHEN/IF/THEN forms** with a live +plain-English sentence preview, not a node-graph canvas. The builder uses the +typed signal catalog to offer only valid operators per signal. The backtest +panel — pick a past night, overlay the signal trace with fire markers and the +resulting setpoint line — is the centerpiece, because it is the trust-builder +that a black-box competitor cannot offer. A `/debug` status panel shows live +per-rule state, the run log, the kill switch, and per-rule dry-run. + +## Alternatives Considered + +### 1. Extend the existing scheduler instead of a parallel engine + +Bolt reactive triggers onto `JobManager`. + +**Rejected.** `JobManager` is built around cron → fire-once semantics with a +liveness model tuned to that. Reactive rules need a continuous evaluator, +windowed buffers, and a per-rule state machine — a different lifecycle. Forcing +both into one component couples two concerns that fail differently. A parallel +engine that *shares the hardware path and locks* gets the reuse without the +coupling. + +### 2. A full visual programming language + +Let users compose arbitrary logic blocks. + +**Rejected for v1.** WHEN/IF/THEN with expression params and AND/OR/NOT covers +both motivating examples and the foreseeable ones. A general language is more +surface to validate, secure, and explain — against a transparency-first product +goal it is a net negative until a concrete need exists. + +### 3. Node-graph builder (drag-and-drop canvas) + +`@xyflow/react` is already in the tree. + +**Deferred, not rejected.** A node canvas is a plausible future for advanced +multi-condition logic, but it needs `dnd-kit` (a new dependency) and is a much +larger UI surface. Structured forms + a sentence preview ship the value now; +the node graph can come later without changing the engine or data model. + +### 4. New dedicated hardware write path for autopilot + +Give the engine its own client to avoid contending on the shared socket. + +**Rejected.** The shared FIFO socket, per-side mutex, and mutation broadcast +exist precisely to serialize all writers. A second path would reintroduce the +race conditions those primitives were built to remove and would bypass the +mutation broadcast the UI depends on. The engine is just another writer on the +existing lock. + +### 5. Explicit coordination with recurring schedules + +Have the engine read the schedule table and negotiate who owns a side. + +**Rejected for P0.** Last-writer-wins on the shared side lock already produces +the desired "autopilot above schedules" behavior, because a continuous-policy +rule re-asserts every tick. Explicit negotiation adds a stateful coupling +between two subsystems for a case the lock already resolves. Revisit only if a +concrete conflict appears that last-writer-wins gets wrong. + +### 6. A global per-user temperature band setting in P0 + +Add a `device_settings` min/max consulted by every clamp. + +**Deferred.** The per-action clamp band is strictly inside the hardware range +and satisfies the two-layer safety requirement today. A global band is a +strictly-additive change later — it can replace the default without touching +the engine — so shipping it in P0 is premature surface. + +## Consequences + +### Positive + +- Adds reactive automation as an evaluation layer over existing, battle-tested + plumbing — shared client, side mutex, mutation broadcast, liveness recovery — + rather than new hardware code. +- The `automation_runs` audit log plus the backtest panel deliver the + transparency wedge the black-box competitor structurally cannot match. +- The safety stack (two-layer clamp, anti-thrash, runaway guard, manual + override, kill switch, dry-run) is defense-in-depth on a device someone + sleeps on, with the conservative default at every layer. +- z-score conditions ("HR > baseline + 2σ") are nearly free — + `getVitalsBaseline` already returns mean and SD — giving an anomaly-detection + capability for little extra cost once vitals signals are wired. + +### Negative / costs + +- A second long-lived background component beside `JobManager` to operate, + observe, and reason about during incidents. Mitigated by sharing the locks + and the `/debug` status panel. +- Both motivating examples depend on expression evaluation and windowed + 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. + +### Open questions + +- **Live signal coverage.** P0/P1 wire only the reliably-available device + signals (per-side temperature/level, `water.low`). Biometric, ambient, and + enum/bool signals read `undefined`→skip until their readers land. Active + rules can only fire on what is wired; backtests already replay the full + recorded series. Tracked as the next phase. +- **Per-signal cadence.** A single 60s tick is correct for the current signal + set; faster-moving signals may later justify per-signal sampling. +- **Per-side target history is not persisted**, so edge-mode backtests compute + setpoints relative to a nominal baseline rather than the true historical + target. Called out at the backtest boundary; revisit if it proves misleading. + +## References + +- Beside `JobManager`: `src/scheduler/jobManager.ts`, + `src/scheduler/instance.ts`. +- Engine: `src/automation/` (`engine.ts`, `evaluator.ts`, `expressions.ts`, + `windows.ts`, `signals.ts`, `backtest.ts`, `types.ts`, `instance.ts`). +- Router + validation: `src/server/routers/automations.ts`, + `src/server/validation-schemas.ts`. +- Kill-switch column: `device_settings.autopilotEnabled` (migration 0014). +- Mutation-broadcast event bus: ADR 0015. +- Vitals baseline (z-score source): `getVitalsBaseline`. +- UI: `app/[lang]/autopilot/`, `src/components/Autopilot/`, + `src/components/diagnostics/DiagnosticsConsole.tsx`. diff --git a/instrumentation.ts b/instrumentation.ts index 6cdc7e27..87adc627 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -16,6 +16,7 @@ */ import { getJobManager, shutdownJobManager } from '@/src/scheduler' +import { getAutomationEngine, shutdownAutomationEngine } from '@/src/automation' import { closeDatabase, closeBiometricsDatabase } from '@/src/db' import { startBiometricsRetention, stopBiometricsRetention } from '@/src/db/retention' import { getDacMonitor, shutdownDacMonitor } from '@/src/hardware/dacMonitor.instance' @@ -63,6 +64,14 @@ async function gracefulShutdown(signal: string): Promise { console.error('Error shutting down scheduler:', error) } + // Step 1a: Stop the Autopilot rules engine tick + try { + await shutdownAutomationEngine() + } + catch (error) { + console.error('Error shutting down automation engine:', error) + } + // Step 2: Shutdown piezo stream server try { await shutdownPiezoStreamServer() @@ -338,6 +347,15 @@ export async function initializeScheduler(): Promise { // Start biometrics time-series retention loop (non-blocking) startBiometricsRetention() + + // Boot the Autopilot rules engine beside the scheduler (non-blocking). + // Shares the same hardware path; no-op until automations are created. + getAutomationEngine().catch((error) => { + console.warn( + '[automation] engine failed to start:', + error instanceof Error ? error.message : error, + ) + }) } catch (error) { console.error('Failed to initialize job scheduler:', error) diff --git a/src/automation/backtest.ts b/src/automation/backtest.ts new file mode 100644 index 00000000..935be2ad --- /dev/null +++ b/src/automation/backtest.ts @@ -0,0 +1,451 @@ +/** + * Backtest — replays an automation rule against REAL recorded signal history for + * a chosen past night and reports what it would have done. This is the + * transparency wedge: users see why a rule fires and what setpoint it produces + * before it ever touches the bed. + * + * The replay reuses the engine's pure evaluation core (WindowStore for windowed + * aggregates, evaluateCondition for the IF tree, evaluateExpr for action params) + * over snapshots reconstructed at a fixed step. It never touches hardware or the + * live engine — it is a deterministic function of (rule, history). + * + * Two render modes, matching how the two motivating examples differ: + * - 'policy' — the setpoint is a continuous function of a live signal + * (e.g. ambient + 3), re-asserted each step within a time window. Ambient is + * persisted, so the setpoint line is computed from real data and clamped. + * - 'edge' — a threshold/aggregate crossing fires a one-shot delta with a + * revert window and cooldown. The compared signal (e.g. movement) is real; + * the setpoint is shown relative to a NOMINAL baseline because per-side + * target-temperature history is not persisted (device_state is live-only). + */ + +import { MAX_TEMP, MIN_TEMP } from '@/src/hardware/types' +import { clockInTimezone } from './signals' +import { evaluateCondition } from './evaluator' +import { evaluateExpr, type EvalContext } from './expressions' +import { WindowStore } from './windows' +import { + AUTOMATION_DEFAULT_USER_MAX, + AUTOMATION_DEFAULT_USER_MIN, + type Action, + type Condition, + type Expr, + type Trigger, +} from './types' + +/** A timestamped numeric sample (epoch ms). */ +export interface Sample { t: number, v: number } + +/** Rule shape the backtest replays (the engine AST, side already resolved). */ +export interface BacktestRule { + side: 'left' | 'right' | null + cooldownMin: number | null + trigger: Trigger + conditions: Condition + actions: Action[] +} + +export interface BacktestInput { + rule: BacktestRule + timezone: string + /** Inclusive window the replay walks, epoch ms. */ + startMs: number + endMs: number + /** Step size in minutes (defaults to 1, matching the engine's resolution). */ + stepMin?: number + /** Historical series keyed by concrete signal key (e.g. `left.movement`). */ + series: Record +} + +export interface BacktestSeries { + key: string + label: string + /** One value per replay step; null where no fresh sample was available. */ + values: (number | null)[] +} + +export interface BacktestResult { + mode: 'policy' | 'edge' + stepMin: number + /** Minutes-since-local-midnight for each step (for axis ticks). */ + clockMin: number[] + /** Raw trace of the primary compared signal. */ + primary: BacktestSeries | null + /** Windowed-aggregate trace (the value actually compared), edge mode. */ + avg: BacktestSeries | null + threshold: number | null + /** Resulting setpoint after the two-layer clamp, per step. */ + setpoint: (number | null)[] + /** Pre-clamp setpoint (policy mode ghost line). */ + setpointRaw: (number | null)[] | null + clamp: { min: number, max: number } | null + /** Time-of-day window for the shaded region, minutes since midnight. */ + timeWindow: { startMin: number, endMin: number } | null + fires: number[] + suppressed: number[] + primaryAxis: { min: number, max: number, unit: string } | null + tempAxis: { min: number, max: number } | null + summary: { + wouldFire: number + suppressed: number + clampHits: number + label: string + netEffect: string | null + setpointRange: [number, number] | null + } +} + +/** Last sample at or before `atMs`, within a staleness bound. undefined if none. */ +function sampleAt(buf: Sample[] | undefined, atMs: number, maxAgeMs: number): number | undefined { + if (!buf || buf.length === 0) return undefined + let best: Sample | undefined + for (const s of buf) { + if (s.t <= atMs && (!best || s.t > best.t)) best = s + } + if (!best) return undefined + return atMs - best.t <= maxAgeMs ? best.v : undefined +} + +/** First signal key referenced anywhere in an expression. */ +function firstSignalInExpr(e: Expr): { key: string, windowed: boolean, fn?: string, lastMin?: number } | null { + switch (e.kind) { + case 'signal': + return { key: e.signal, windowed: false } + case 'window': + return { key: e.signal, windowed: true, fn: e.fn, lastMin: e.lastMin } + case 'binary': + return firstSignalInExpr(e.left) ?? firstSignalInExpr(e.right) + case 'clamp': + return firstSignalInExpr(e.value) ?? firstSignalInExpr(e.min) ?? firstSignalInExpr(e.max) + default: + return null + } +} + +/** Find the first comparison that drives the rule (for the chart's primary trace). */ +function findPrimaryCompare(cond: Condition): + { left: Expr, op: string, threshold: number | null } | null { + switch (cond.kind) { + case 'and': + case 'or': { + for (const c of cond.conditions) { + const found = findPrimaryCompare(c) + if (found) return found + } + return null + } + case 'not': + return findPrimaryCompare(cond.condition) + case 'compare': { + // Prefer a comparison whose left side references a signal. + const sig = firstSignalInExpr(cond.left) + if (!sig) return null + const threshold = cond.right.kind === 'literal' ? cond.right.value : null + return { left: cond.left, op: cond.op, threshold } + } + default: + return null + } +} + +/** Extract the time-of-day window from the trigger/condition, if any. */ +function findTimeWindow(trigger: Trigger, cond: Condition): { startMin: number, endMin: number } | null { + const toMin = (hhmm: string): number => { + const [h, m] = hhmm.split(':').map(Number) + return h * 60 + m + } + const walk = (c: Condition): { startMin: number, endMin: number } | null => { + switch (c.kind) { + case 'and': + case 'or': + for (const x of c.conditions) { + const r = walk(x) + if (r) return r + } + return null + case 'not': + return walk(c.condition) + case 'timeBetween': + return { startMin: toMin(c.start), endMin: toMin(c.end) } + default: + return null + } + } + return walk(cond) +} + +const SIGNAL_LABELS: Record = { + 'ambient.temperature': 'Ambient temp', + 'ambient.humidity': 'Ambient humidity', + 'left.movement': 'Movement (L)', + 'right.movement': 'Movement (R)', + 'left.heartRate': 'Heart rate (L)', + 'right.heartRate': 'Heart rate (R)', + 'left.hrv': 'HRV (L)', + 'right.hrv': 'HRV (R)', + 'left.breathingRate': 'Breathing (L)', + 'right.breathingRate': 'Breathing (R)', +} +function signalLabel(key: string): string { + return SIGNAL_LABELS[key] ?? key +} + +/** + * Replay a rule over recorded history. Pure: deterministic in its inputs, no + * hardware, DB, or clock access beyond the injected timezone. + */ +export function runBacktest(input: BacktestInput): BacktestResult { + const stepMin = input.stepMin ?? 1 + const stepMs = stepMin * 60_000 + const { rule, series, timezone } = input + + // ---- introspect the rule for charting -------------------------------- + const setTemp = rule.actions.find(a => a.kind === 'setTemperature') as + Extract | undefined + const primaryCompare = findPrimaryCompare(rule.conditions) + const timeWindow = findTimeWindow(rule.trigger, rule.conditions) + const clampBand = setTemp?.clamp ?? { min: AUTOMATION_DEFAULT_USER_MIN, max: AUTOMATION_DEFAULT_USER_MAX } + + // Policy mode: a setTemperature whose expression tracks a non-target signal + // continuously, with no driving threshold comparison (time-window only). + const tempSignal = setTemp ? firstSignalInExpr(setTemp.temp) : null + const tempTracksLiveSignal = !!tempSignal + && !tempSignal.key.endsWith('.currentTemperature') + && !tempSignal.key.endsWith('.targetTemperature') + const mode: 'policy' | 'edge' = tempTracksLiveSignal && !primaryCompare ? 'policy' : 'edge' + + // ---- build the step grid -------------------------------------------- + const steps: number[] = [] + for (let t = input.startMs; t <= input.endMs; t += stepMs) steps.push(t) + const clockMin = steps.map(t => clockInTimezone(timezone, new Date(t)).nowMinutes) + + // Which signals are windowed (must be fed to the WindowStore each step). + const windows = new WindowStore() + const maxAgeMs = 15 * 60_000 // a sample older than 15 min is "stale" + + const fires: number[] = [] + const suppressed: number[] = [] + const setpoint: (number | null)[] = [] + const setpointRaw: (number | null)[] = [] + const primaryValues: (number | null)[] = [] + const avgValues: (number | null)[] = [] + + let lastFireMs: number | null = null + let lastTrigVal: number | undefined + let lastTrigSeen = false + let lastTimeKey: string | undefined + + // Edge-mode nominal baseline (per-side target history is not persisted). + const nominalBase = Math.round((clampBand.min + clampBand.max) / 2) + const revertMin = setTemp?.durationSec ? Math.round(setTemp.durationSec / 60) : 0 + // active revert windows: list of [startStep, endStep) + const revertUntil: number[] = [] + + for (let i = 0; i < steps.length; i++) { + const nowMs = steps[i] + const { nowMinutes, dayOfWeek } = clockInTimezone(timezone, new Date(nowMs)) + + const snap = (key: string): number | undefined => sampleAt(series[key], nowMs, maxAgeMs) + + // Feed window buffers for any windowed signal referenced by the rule. + if (primaryCompare) { + const sig = firstSignalInExpr(primaryCompare.left) + if (sig?.windowed) { + const v = snap(sig.key) + if (typeof v === 'number') windows.record(sig.key, v, nowMs) + } + } + + const ctx: EvalContext = { + signal: snap, + windows, + nowMs, + nowMinutes, + dayOfWeek, + } + + // primary + avg traces + if (primaryCompare) { + const sig = firstSignalInExpr(primaryCompare.left) + primaryValues.push(sig ? snap(sig.key) ?? null : null) + const v = evaluateExpr(primaryCompare.left, ctx) + avgValues.push(v ?? null) + } + else if (mode === 'policy' && tempSignal) { + primaryValues.push(snap(tempSignal.key) ?? null) + avgValues.push(null) + } + else { + primaryValues.push(null) + avgValues.push(null) + } + + // ---- replay the trigger + condition + gates ---------------------- + // Policy mode re-asserts a setpoint continuously; it has no discrete + // fires, so only edge mode records fire/suppress markers. + let fired = false + if (mode === 'edge') { + const trig = rule.trigger + let triggerActive = false + if (trig.kind === 'tick') { + triggerActive = true // every step is a tick at this resolution + } + else if (trig.kind === 'signalChange') { + const v = ctx.signal(trig.signal) + if (v !== undefined) { + triggerActive = lastTrigSeen && v !== lastTrigVal + lastTrigVal = v + lastTrigSeen = true + } + } + else { + const [h, m] = trig.at.split(':').map(Number) + const atMin = h * 60 + m + if (nowMinutes === atMin && (!trig.days || trig.days.includes(dayOfWeek))) { + const key = `${dayOfWeek}:${atMin}` + if (lastTimeKey !== key) { + triggerActive = true + lastTimeKey = key + } + } + } + + if (triggerActive) { + const cond = evaluateCondition(rule.conditions, ctx) + if (cond === true) { + const cool = rule.cooldownMin != null && lastFireMs != null + && nowMs - lastFireMs < rule.cooldownMin * 60_000 + if (cool) { + suppressed.push(i) + } + else { + fires.push(i) + fired = true + lastFireMs = nowMs + if (revertMin > 0) revertUntil.push(i + Math.max(1, Math.round(revertMin / stepMin))) + } + } + } + } + + // ---- resulting setpoint ------------------------------------------ + if (!setTemp) { + setpoint.push(null) + setpointRaw.push(null) + continue + } + + if (mode === 'policy') { + // Continuous: setpoint = clamp(expr(signals)) whenever in the time window. + const inWindow = timeWindow ? isInWindow(nowMinutes, timeWindow) : true + const raw = inWindow ? evaluateExpr(setTemp.temp, ctx) : undefined + if (raw === undefined) { + setpoint.push(null) + setpointRaw.push(null) + } + else { + setpointRaw.push(round1(raw)) + setpoint.push(round1(twoLayerClamp(raw, clampBand))) + } + } + else { + // Edge: nominal baseline, shifted by the action delta during revert windows. + const active = revertMin > 0 + ? revertUntil.some(end => i < end && i >= end - Math.max(1, Math.round(revertMin / stepMin))) + : fired + const delta = edgeDelta(setTemp.temp, nominalBase) + setpoint.push(active ? round1(twoLayerClamp(nominalBase + delta, clampBand)) : nominalBase) + setpointRaw.push(null) + } + } + + // ---- axes + summary -------------------------------------------------- + const compareSig = primaryCompare ? firstSignalInExpr(primaryCompare.left) : null + const primarySig = primaryCompare + ? compareSig + : (mode === 'policy' && tempSignal ? tempSignal : null) + const primaryUnit = primarySig?.key.includes('movement') ? '' : primarySig?.key.includes('temperature') ? '°F' : '' + const primaryNums = primaryValues.filter((v): v is number => v != null) + const avgNums = avgValues.filter((v): v is number => v != null) + const allPrimary = [...primaryNums, ...avgNums, ...(primaryCompare?.threshold != null ? [primaryCompare.threshold] : [])] + const primaryAxis = primarySig && allPrimary.length + ? { min: Math.min(0, ...allPrimary), max: niceMax(Math.max(...allPrimary)), unit: primaryUnit } + : null + + const setNums = [...setpoint, ...(setpointRaw ?? [])].filter((v): v is number => v != null) + const tempAxis = setNums.length + ? { min: Math.floor(Math.min(...setNums) - 1), max: Math.ceil(Math.max(...setNums) + 1) } + : null + + let clampHits = 0 + for (let i = 0; i < setpointRaw.length; i++) { + const raw = setpointRaw[i] + const c = setpoint[i] + if (raw != null && c != null && Math.abs(raw - c) > 0.05) clampHits++ + } + + const setRange = setNums.length ? [Math.min(...setNums), Math.max(...setNums)] as [number, number] : null + + let netEffect: string | null = null + if (setTemp && mode === 'edge') { + const delta = edgeDelta(setTemp.temp, nominalBase) + netEffect = `${delta >= 0 ? '+' : ''}${delta}°F · ${revertMin || 0}m` + } + + return { + mode, + stepMin, + clockMin, + primary: primarySig ? { key: primarySig.key, label: signalLabel(primarySig.key), values: primaryValues } : null, + avg: compareSig?.windowed + ? { key: compareSig.key, label: `avg ${signalLabel(compareSig.key)}`, values: avgValues } + : null, + threshold: primaryCompare?.threshold ?? null, + setpoint, + setpointRaw: mode === 'policy' ? setpointRaw : null, + clamp: clampBand, + timeWindow, + fires, + suppressed, + primaryAxis, + tempAxis, + summary: { + wouldFire: fires.length, + suppressed: suppressed.length, + clampHits, + label: mode === 'policy' ? 'Continuous' : 'Edge-triggered', + netEffect, + setpointRange: setRange, + }, + } +} + +/** Approximate the constant delta of an edge action's temp expr (e.g. current − 2 → −2). */ +function edgeDelta(temp: Expr, nominalBase: number): number { + if (temp.kind === 'binary' && (temp.op === '+' || temp.op === '-')) { + const lit = temp.right.kind === 'literal' + ? temp.right.value + : temp.left.kind === 'literal' ? temp.left.value : null + if (lit != null) return temp.op === '-' ? -lit : lit + } + if (temp.kind === 'literal') return temp.value - nominalBase + return 0 +} + +function twoLayerClamp(v: number, band: { min: number, max: number }): number { + const l1 = Math.min(Math.max(v, band.min), band.max) + return Math.min(Math.max(l1, MIN_TEMP), MAX_TEMP) +} + +function isInWindow(nowMin: number, w: { startMin: number, endMin: number }): boolean { + if (w.startMin === w.endMin) return false + if (w.startMin < w.endMin) return nowMin >= w.startMin && nowMin < w.endMin + return nowMin >= w.startMin || nowMin < w.endMin +} + +function round1(v: number): number { + return Math.round(v * 10) / 10 +} +function niceMax(v: number): number { + return Math.ceil(v / 100) * 100 || Math.ceil(v) +} diff --git a/src/automation/engine.ts b/src/automation/engine.ts new file mode 100644 index 00000000..56906b8d --- /dev/null +++ b/src/automation/engine.ts @@ -0,0 +1,461 @@ +/** + * AutomationEngine — the reactive rules engine that sits beside the scheduler's + * JobManager. It evaluates user-built WHEN/IF/THEN automations on a periodic + * tick and writes through the SAME hardware path the scheduler uses + * (getSharedHardwareClient + per-side mutex + broadcastMutationStatus + + * markSideMutated). It introduces no new hardware path. + * + * Safety properties (see docs/adr/0023-autopilot-reactive-automations.md "Safety stack"): + * - Two-layer temp clamp: per-action user band, then hardware 55–110°F. + * - Anti-thrash: a setpoint is only re-sent to hardware when it moves ≥0.5°F. + * - Runaway guard: a rule exceeding N hardware actions/hour auto-disables. + * - Manual-override hold: touching the dial suspends autopilot on that side. + * - Run-once gate: never writes a side with an active run-once session. + * - Dry-run: notify-only; logs would-fire events without touching hardware. + * + * Every evaluation (i.e. every tick where a rule's trigger is active) writes one + * row to automation_runs with outcome fired/skipped/clamped/dry_run/error — the + * audit log that powers the transparency wedge. + * + * The engine is fully dependency-injected so it unit-tests without hardware, + * the DB, or timers. `src/automation/instance.ts` wires the production deps. + */ + +import { MAX_TEMP, MIN_TEMP, fahrenheitToLevel } from '@/src/hardware/types' +import { evaluateCondition } from './evaluator' +import type { EvalContext } from './expressions' +import { evaluateExpr } from './expressions' +import { collectWindowSignals, type SignalReader } from './signals' +import { + AUTOMATION_ANTI_THRASH_F, + AUTOMATION_DEFAULT_USER_MAX, + AUTOMATION_DEFAULT_USER_MIN, + AUTOMATION_MANUAL_OVERRIDE_MS, + AUTOMATION_MAX_ACTIONS_PER_HOUR, + AUTOMATION_TICK_MS, + type Action, + type AutomationRule, + type DayOfWeek, + type Expr, + type RunOutcome, + type Side, +} from './types' +import { WindowStore } from './windows' + +/** Minimal hardware surface the engine writes through (shared client shape). */ +export interface HardwareWriter { + connect: () => Promise + setTemperature: (side: Side, temperature: number, duration?: number) => Promise + setPower: (side: Side, powered: boolean, temperature?: number) => Promise +} + +export interface AutomationEngineDeps { + signals: SignalReader + /** Epoch ms — injectable for deterministic tests. */ + now: () => number + /** Timezone-aware wall clock. */ + clock: () => { nowMinutes: number, dayOfWeek: DayOfWeek } + getHardware: () => HardwareWriter + withSideLock: (side: Side, fn: () => Promise) => Promise + broadcast: (side: Side, overlay: Record) => void + markMutated: (side: Side) => void + loadRules: () => Promise + recordRun: (automationId: number, outcome: RunOutcome, detail: unknown) => Promise + /** Persist enabled=false when the runaway guard trips. */ + disableRule: (automationId: number) => Promise + hasActiveRunOnceSession: (side: Side) => Promise + /** Side-effect for notify actions (e.g. push/log); never touches hardware. */ + notify: (automationId: number, message: string) => void + log?: (msg: string) => void +} + +interface RuleRuntime { + lastFiredMs: number | null + /** Timestamps of real hardware actions in the trailing hour (runaway guard). */ + actionTimes: number[] + /** Last observed value of a signalChange trigger's signal. */ + lastTriggerValue?: number + lastTriggerSeen: boolean + /** Last tick a `tick` trigger evaluated. */ + lastTickEvalMs: number + /** Day+minute key the last time a `timeOfDay` trigger fired (dedupe). */ + lastTimeKey?: string +} + +export class AutomationEngine { + private deps: AutomationEngineDeps + private rules: AutomationRule[] = [] + private runtime = new Map() + private windows = new WindowStore() + private windowSignals = new Set() + private timer: ReturnType | null = null + private ticking = false + + // Global kill-switch. When false, ticks short-circuit and no rule is + // evaluated or commanded — per-rule enabled/dryRun state is left untouched, so + // re-enabling resumes every rule exactly as it was. Persisted in + // deviceSettings.autopilotEnabled; the instance restores it at boot. + private globalEnabled = true + + // Per-side runtime state shared across rules. + private lastAsserted: Record = { left: undefined, right: undefined } + private manualOverrideUntil: Record = { left: 0, right: 0 } + + constructor(deps: AutomationEngineDeps) { + this.deps = deps + } + + /** Load rules and start the periodic tick. */ + async start(): Promise { + await this.reload() + if (!this.timer) { + this.timer = setInterval(() => { + void this.tick() + }, AUTOMATION_TICK_MS) + // Don't keep the process alive solely for the engine tick. + if (typeof this.timer.unref === 'function') this.timer.unref() + } + this.deps.log?.(`AutomationEngine started with ${this.rules.length} automations`) + } + + /** Reload automations from the source (call after CRUD mutations). */ + async reload(): Promise { + this.rules = await this.deps.loadRules() + this.windowSignals = collectWindowSignals(this.rules) + // Drop runtime for rules that no longer exist. + const ids = new Set(this.rules.map(r => r.id)) + for (const id of [...this.runtime.keys()]) { + if (!ids.has(id)) this.runtime.delete(id) + } + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + /** + * Suspend autopilot on a side for the manual-override hold window. A router or + * gesture handler calls this when the user changes the dial directly. + */ + registerManualOverride(side: Side): void { + this.manualOverrideUntil[side] = this.deps.now() + AUTOMATION_MANUAL_OVERRIDE_MS + } + + /** Flip the global kill-switch. `false` suspends all evaluation immediately. */ + setGlobalEnabled(on: boolean): void { + this.globalEnabled = on + this.deps.log?.(`AutomationEngine global kill-switch ${on ? 'ON (running)' : 'OFF (halted)'}`) + } + + /** Whether autopilot is globally enabled (kill-switch not engaged). */ + isGloballyEnabled(): boolean { + return this.globalEnabled + } + + private getRuntime(id: number): RuleRuntime { + let rt = this.runtime.get(id) + if (!rt) { + rt = { lastFiredMs: null, actionTimes: [], lastTriggerSeen: false, lastTickEvalMs: 0 } + this.runtime.set(id, rt) + } + return rt + } + + /** One evaluation pass over all enabled rules. */ + async tick(): Promise { + if (!this.globalEnabled) return // kill-switch engaged — evaluate nothing + if (this.ticking) return // never overlap ticks + this.ticking = true + try { + const now = this.deps.now() + const snapshot = this.deps.signals.read() + const { nowMinutes, dayOfWeek } = this.deps.clock() + + // Feed windowed-aggregate buffers, then prune to the largest window asked. + for (const key of this.windowSignals) { + const v = snapshot[key] + if (typeof v === 'number') this.windows.record(key, v, now) + } + this.windows.prune(now, this.maxWindowMinutes()) + + const ctx: EvalContext = { + signal: key => snapshot[key], + windows: this.windows, + nowMs: now, + nowMinutes, + dayOfWeek, + } + + for (const rule of this.rules) { + if (!rule.enabled) continue + try { + await this.evaluateRule(rule, ctx, now) + } + catch (err) { + await this.deps.recordRun(rule.id, 'error', { + reason: 'eval-threw', + message: err instanceof Error ? err.message : String(err), + }) + } + } + } + finally { + this.ticking = false + } + } + + private maxWindowMinutes(): number { + let max = 10 + for (const rule of this.rules) { + if (!rule.enabled) continue + max = Math.max(max, windowMinsInCondition(rule.conditions)) + for (const a of rule.actions) { + if (a.kind === 'setTemperature') max = Math.max(max, windowMinsInExpr(a.temp)) + if (a.kind === 'setPower' && a.temp) max = Math.max(max, windowMinsInExpr(a.temp)) + } + } + return max + } + + /** Is this rule's trigger active on this tick? Mutates trigger runtime. */ + private triggerActive(rule: AutomationRule, ctx: EvalContext, now: number, rt: RuleRuntime): boolean { + const t = rule.trigger + switch (t.kind) { + case 'tick': { + const due = now - rt.lastTickEvalMs >= t.everyMin * 60_000 + if (due) rt.lastTickEvalMs = now + return due + } + case 'signalChange': { + const v = ctx.signal(t.signal) + if (v === undefined) return false + const changed = rt.lastTriggerSeen && v !== rt.lastTriggerValue + rt.lastTriggerValue = v + rt.lastTriggerSeen = true + return changed + } + case 'timeOfDay': { + const [h, m] = t.at.split(':').map(Number) + const atMin = h * 60 + m + if (ctx.nowMinutes !== atMin) return false + if (t.days && !t.days.includes(ctx.dayOfWeek)) return false + const key = `${ctx.dayOfWeek}:${atMin}` + if (rt.lastTimeKey === key) return false // already fired this minute + rt.lastTimeKey = key + return true + } + } + } + + private async evaluateRule(rule: AutomationRule, ctx: EvalContext, now: number): Promise { + const rt = this.getRuntime(rule.id) + if (!this.triggerActive(rule, ctx, now, rt)) return // not an eval; no audit row + + // IF — three-valued. unknown/false both skip (never fire on missing data). + const cond = evaluateCondition(rule.conditions, ctx) + if (cond !== true) { + await this.deps.recordRun(rule.id, 'skipped', { + reason: cond === undefined ? 'condition-unknown' : 'condition-false', + }) + return + } + + // Cooldown gate. + if (rule.cooldownMin != null && rt.lastFiredMs != null + && now - rt.lastFiredMs < rule.cooldownMin * 60_000) { + await this.deps.recordRun(rule.id, 'skipped', { reason: 'cooldown' }) + return + } + + // Runaway guard — prune the trailing hour and trip if over budget. + rt.actionTimes = rt.actionTimes.filter(ts => now - ts < 3_600_000) + if (rt.actionTimes.length >= AUTOMATION_MAX_ACTIONS_PER_HOUR) { + await this.deps.disableRule(rule.id) + rule.enabled = false + await this.deps.recordRun(rule.id, 'error', { + reason: 'runaway-disabled', + actionsLastHour: rt.actionTimes.length, + }) + this.deps.log?.(`AutomationEngine disabled rule ${rule.id} (${rule.name}): runaway guard`) + return + } + + // THEN — run actions, tracking the aggregate outcome. + const results: ActionResult[] = [] + for (const action of rule.actions) { + results.push(...(await this.runAction(rule, action, ctx, now, rt))) + } + + const outcome = aggregateOutcome(rule.dryRun, results) + if (outcome === 'fired' || outcome === 'clamped' || outcome === 'dry_run') { + rt.lastFiredMs = now + } + await this.deps.recordRun(rule.id, outcome, { actions: results }) + } + + private async runAction( + rule: AutomationRule, + action: Action, + ctx: EvalContext, + now: number, + rt: RuleRuntime, + ): Promise { + if (action.kind === 'notify') { + this.deps.notify(rule.id, action.message) + return [{ kind: 'notify', notified: true }] + } + + // A null rule side (the builder's "both") fans a hardware action out to both + // sides. The temp expression's signal keys are already side-resolved at build + // time — a "both" rule reads the left side (builderModel.toAST) — so the + // resolved setpoint is shared; only the write target differs per side. + const sides: Side[] = action.side ? [action.side] : (rule.side ? [rule.side] : ['left', 'right']) + + // Resolve the target temperature (setPower may have none → hardware default). + let raw: number | undefined + if (action.kind === 'setTemperature') raw = evaluateExpr(action.temp, ctx) + else if (action.temp) raw = evaluateExpr(action.temp, ctx) + + if (action.kind === 'setTemperature' && raw === undefined) { + return sides.map(side => ({ kind: action.kind, side, skipped: 'temp-unknown' })) + } + + // Two-layer clamp (only when a temperature is involved). + let temp: number | undefined + let clamped = false + if (raw !== undefined) { + const userMin = action.kind === 'setTemperature' ? action.clamp?.min ?? AUTOMATION_DEFAULT_USER_MIN : AUTOMATION_DEFAULT_USER_MIN + const userMax = action.kind === 'setTemperature' ? action.clamp?.max ?? AUTOMATION_DEFAULT_USER_MAX : AUTOMATION_DEFAULT_USER_MAX + const layer1 = Math.min(Math.max(raw, userMin), userMax) + const layer2 = Math.min(Math.max(layer1, MIN_TEMP), MAX_TEMP) + temp = layer2 + clamped = layer2 !== raw + } + + const out: ActionResult[] = [] + for (const side of sides) { + out.push(await this.writeSide(rule, action, side, now, rt, raw, temp, clamped)) + } + return out + } + + /** Apply one resolved hardware action to a single side (gates + write). */ + private async writeSide( + rule: AutomationRule, + action: Exclude, + side: Side, + now: number, + rt: RuleRuntime, + raw: number | undefined, + temp: number | undefined, + clamped: boolean, + ): Promise { + // Side gates apply only to real hardware writes. + if (this.manualOverrideUntil[side] > now) { + return { kind: action.kind, side, skipped: 'manual-override', raw, temp, clamped } + } + if (await this.deps.hasActiveRunOnceSession(side)) { + return { kind: action.kind, side, skipped: 'run-once', raw, temp, clamped } + } + + // Anti-thrash: skip a sub-threshold re-assertion of the same setpoint. + if (action.kind === 'setTemperature' && temp !== undefined) { + const last = this.lastAsserted[side] + if (last !== undefined && Math.abs(temp - last) < AUTOMATION_ANTI_THRASH_F) { + return { kind: action.kind, side, antiThrash: true, raw, temp, clamped } + } + } + + // Dry-run: log the would-be command but never touch hardware. + if (rule.dryRun) { + return { kind: action.kind, side, dryRun: true, raw, temp, clamped, on: action.kind === 'setPower' ? action.on : undefined } + } + + // Real write through the shared, serialized hardware path. + await this.deps.withSideLock(side, async () => { + const hw = this.deps.getHardware() + await hw.connect() + if (action.kind === 'setTemperature') { + if (temp === undefined) return // unreachable: setTemperature always resolves a temp + await hw.setTemperature(side, temp, action.durationSec) + this.deps.markMutated(side) + this.deps.broadcast(side, { targetTemperature: temp, targetLevel: fahrenheitToLevel(temp) }) + this.lastAsserted[side] = temp + } + else { + await hw.setPower(side, action.on, temp) + this.deps.markMutated(side) + this.deps.broadcast(side, action.on + ? { targetTemperature: temp ?? 75, targetLevel: fahrenheitToLevel(temp ?? 75) } + : { targetLevel: 0 }) + if (action.on && temp !== undefined) this.lastAsserted[side] = temp + else if (!action.on) this.lastAsserted[side] = undefined + } + }) + rt.actionTimes.push(now) + return { kind: action.kind, side, sent: true, raw, temp, clamped, on: action.kind === 'setPower' ? action.on : undefined } + } +} + +interface ActionResult { + kind: Action['kind'] + side?: Side + notified?: boolean + sent?: boolean + dryRun?: boolean + antiThrash?: boolean + clamped?: boolean + skipped?: string + error?: string + raw?: number + temp?: number + on?: boolean +} + +/** Largest window (minutes) referenced anywhere in an expression tree. */ +function windowMinsInExpr(expr: Expr): number { + switch (expr.kind) { + case 'window': + return expr.lastMin + case 'binary': + return Math.max(windowMinsInExpr(expr.left), windowMinsInExpr(expr.right)) + case 'clamp': + return Math.max(windowMinsInExpr(expr.value), windowMinsInExpr(expr.min), windowMinsInExpr(expr.max)) + default: + return 0 + } +} + +/** Largest window (minutes) referenced anywhere in a condition tree. */ +function windowMinsInCondition(cond: AutomationRule['conditions']): number { + switch (cond.kind) { + case 'and': + case 'or': + return cond.conditions.reduce((m, c) => Math.max(m, windowMinsInCondition(c)), 0) + case 'not': + return windowMinsInCondition(cond.condition) + case 'compare': + return Math.max(windowMinsInExpr(cond.left), windowMinsInExpr(cond.right)) + case 'between': + return Math.max( + windowMinsInExpr(cond.subject), + windowMinsInExpr(cond.min), + windowMinsInExpr(cond.max), + ) + default: + return 0 + } +} + +/** Fold per-action results into the single run outcome (worst-meaningful). */ +function aggregateOutcome(dryRun: boolean, results: ActionResult[]): RunOutcome { + if (results.some(r => r.error)) return 'error' + const acted = results.filter(r => r.notified || r.sent || r.dryRun || r.antiThrash) + if (acted.length === 0) return 'skipped' // every action gated out + if (dryRun && results.some(r => r.dryRun)) return 'dry_run' + if (results.some(r => r.clamped && (r.sent || r.antiThrash))) return 'clamped' + return 'fired' +} diff --git a/src/automation/evaluator.ts b/src/automation/evaluator.ts new file mode 100644 index 00000000..9c72f51c --- /dev/null +++ b/src/automation/evaluator.ts @@ -0,0 +1,94 @@ +/** + * Condition (IF) evaluation with three-valued logic: true / false / undefined. + * + * `undefined` means "unknown" — an operand referenced a signal with no current + * value. The engine treats unknown as a skip (never a fire), so a rule never + * acts on missing data. AND short-circuits on a definite false; OR on a + * definite true; an unknown child only matters when nothing else decides it. + */ + +import type { CompareOp, Condition } from './types' +import { evaluateExpr, type EvalContext } from './expressions' + +function compare(op: CompareOp, l: number, r: number): boolean { + switch (op) { + case '>': + return l > r + case '>=': + return l >= r + case '<': + return l < r + case '<=': + return l <= r + case '==': + return l === r + case '!=': + return l !== r + } +} + +/** Parse "HH:MM" to minutes since midnight. */ +function toMinutes(hhmm: string): number { + const [h, m] = hhmm.split(':').map(Number) + return h * 60 + m +} + +/** Inclusive-start, exclusive-end window that may wrap past midnight. */ +function inTimeWindow(nowMin: number, start: string, end: string): boolean { + const s = toMinutes(start) + const e = toMinutes(end) + if (s === e) return false + if (s < e) return nowMin >= s && nowMin < e + // wraps midnight, e.g. 23:00–06:00 + return nowMin >= s || nowMin < e +} + +export function evaluateCondition(cond: Condition, ctx: EvalContext): boolean | undefined { + switch (cond.kind) { + case 'and': { + let sawUnknown = false + for (const c of cond.conditions) { + const r = evaluateCondition(c, ctx) + if (r === false) return false + if (r === undefined) sawUnknown = true + } + return sawUnknown ? undefined : true + } + + case 'or': { + let sawUnknown = false + for (const c of cond.conditions) { + const r = evaluateCondition(c, ctx) + if (r === true) return true + if (r === undefined) sawUnknown = true + } + return sawUnknown ? undefined : false + } + + case 'not': { + const r = evaluateCondition(cond.condition, ctx) + return r === undefined ? undefined : !r + } + + case 'compare': { + const l = evaluateExpr(cond.left, ctx) + const r = evaluateExpr(cond.right, ctx) + if (l === undefined || r === undefined) return undefined + return compare(cond.op, l, r) + } + + case 'between': { + const v = evaluateExpr(cond.subject, ctx) + const lo = evaluateExpr(cond.min, ctx) + const hi = evaluateExpr(cond.max, ctx) + if (v === undefined || lo === undefined || hi === undefined) return undefined + return v >= lo && v <= hi + } + + case 'timeBetween': + return inTimeWindow(ctx.nowMinutes, cond.start, cond.end) + + case 'onDays': + return cond.days.includes(ctx.dayOfWeek) + } +} diff --git a/src/automation/expressions.ts b/src/automation/expressions.ts new file mode 100644 index 00000000..c192b4a3 --- /dev/null +++ b/src/automation/expressions.ts @@ -0,0 +1,66 @@ +/** + * Expression evaluation. Every expression resolves to a number, or `undefined` + * when a referenced signal/window has no value — undefined propagates through + * arithmetic so a condition built on missing data evaluates to "unknown" and + * the rule skips rather than firing blind. + */ + +import type { DayOfWeek, Expr } from './types' +import type { WindowStore } from './windows' + +export interface EvalContext { + /** Resolve a scalar signal key (e.g. `left.currentTemperature`) to a number. */ + signal: (key: string) => number | undefined + /** Windowed-aggregate store, queried at `nowMs`. */ + windows: WindowStore + nowMs: number + /** Minutes since local midnight, timezone-aware. */ + nowMinutes: number + dayOfWeek: DayOfWeek +} + +/** Clamp `value` to `[min, max]`. Bounds may be undefined (then ignored). */ +export function clamp(value: number, min: number | undefined, max: number | undefined): number { + let out = value + if (min !== undefined && out < min) out = min + if (max !== undefined && out > max) out = max + return out +} + +export function evaluateExpr(expr: Expr, ctx: EvalContext): number | undefined { + switch (expr.kind) { + case 'literal': + return expr.value + + case 'signal': + return ctx.signal(expr.signal) + + case 'window': + return ctx.windows.aggregate(expr.fn, expr.signal, expr.lastMin, ctx.nowMs) + + case 'binary': { + const l = evaluateExpr(expr.left, ctx) + const r = evaluateExpr(expr.right, ctx) + if (l === undefined || r === undefined) return undefined + switch (expr.op) { + case '+': + return l + r + case '-': + return l - r + case '*': + return l * r + case '/': + return r === 0 ? undefined : l / r + } + return undefined + } + + case 'clamp': { + const v = evaluateExpr(expr.value, ctx) + if (v === undefined) return undefined + const lo = evaluateExpr(expr.min, ctx) + const hi = evaluateExpr(expr.max, ctx) + return clamp(v, lo, hi) + } + } +} diff --git a/src/automation/index.ts b/src/automation/index.ts new file mode 100644 index 00000000..def1f77c --- /dev/null +++ b/src/automation/index.ts @@ -0,0 +1,29 @@ +/** + * Autopilot rules engine — a reactive WHEN/IF/THEN evaluator that sits beside + * the scheduler's JobManager and writes through the same shared hardware path. + * + * See docs/adr/0023-autopilot-reactive-automations.md for the model, signal catalog, and safety design. + */ + +export { AutomationEngine } from './engine' +export type { AutomationEngineDeps, HardwareWriter } from './engine' +export { + getAutomationEngine, + getAutomationEngineIfRunning, + shutdownAutomationEngine, +} from './instance' +export { clockInTimezone, collectWindowSignals, DeviceSignalReader } from './signals' +export type { SignalReader, SignalSnapshot } from './signals' +export { evaluateCondition } from './evaluator' +export { clamp, evaluateExpr } from './expressions' +export type { EvalContext } from './expressions' +export { WindowStore } from './windows' +export type { + Action, + AutomationRule, + Condition, + Expr, + RunOutcome, + Side, + Trigger, +} from './types' diff --git a/src/automation/instance.ts b/src/automation/instance.ts new file mode 100644 index 00000000..bebe0c1d --- /dev/null +++ b/src/automation/instance.ts @@ -0,0 +1,152 @@ +/** + * Global AutomationEngine singleton — mirrors `src/scheduler/instance.ts`. + * + * Wires the production dependencies (live signal reader, shared hardware client, + * shared per-side lock, mutation broadcast, DB-backed rule load + audit log) and + * ensures a single engine runs across the application. Booted beside the + * JobManager from instrumentation.ts. + */ + +import { and, eq, gt } from 'drizzle-orm' +import { db } from '@/src/db' +import { automationRuns, automations, deviceSettings, runOnceSessions } from '@/src/db/schema' +import { getSharedHardwareClient } from '@/src/hardware/dacMonitor.instance' +import { markSideMutated } from '@/src/hardware/deviceStateSync' +import { withSideLock } from '@/src/hardware/sideLock' +import { broadcastMutationStatus } from '@/src/streaming/broadcastMutationStatus' +import { AutomationEngine } from './engine' +import { DeviceSignalReader, clockInTimezone } from './signals' +import type { + Action, + AutomationRule, + Condition, + RunOutcome, + Side, + Trigger, +} from './types' + +const DEFAULT_TIMEZONE = 'America/Los_Angeles' + +let engineInstance: AutomationEngine | null = null +let engineInitPromise: Promise | null = null +let cachedTimezone: string | null = null + +async function loadTimezone(): Promise { + if (cachedTimezone) return cachedTimezone + try { + const [settings] = await db.select().from(deviceSettings).limit(1) + cachedTimezone = settings?.timezone || DEFAULT_TIMEZONE + return cachedTimezone + } + catch { + return DEFAULT_TIMEZONE + } +} + +async function loadRules(): Promise { + const rows = await db.select().from(automations) + // JSON columns come back parsed; the router validates them with zod on write. + return rows.map(r => ({ + id: r.id, + name: r.name, + enabled: r.enabled, + side: r.side, + priority: r.priority, + dryRun: r.dryRun, + cooldownMin: r.cooldownMin, + trigger: r.trigger as Trigger, + conditions: r.conditions as Condition, + actions: r.actions as Action[], + })) +} + +async function recordRun(automationId: number, outcome: RunOutcome, detail: unknown): Promise { + try { + await db.insert(automationRuns).values({ + automationId, + outcome, + detail: detail as object, + }) + } + catch (e) { + console.warn('[automation] failed to record run:', e instanceof Error ? e.message : e) + } +} + +async function disableRule(automationId: number): Promise { + await db + .update(automations) + .set({ enabled: false, updatedAt: new Date() }) + .where(eq(automations.id, automationId)) +} + +async function hasActiveRunOnceSession(side: Side): Promise { + const [session] = await db + .select({ id: runOnceSessions.id }) + .from(runOnceSessions) + .where(and( + eq(runOnceSessions.side, side), + eq(runOnceSessions.status, 'active'), + gt(runOnceSessions.expiresAt, new Date()), + )) + .limit(1) + return !!session +} + +export async function getAutomationEngine(): Promise { + if (engineInstance) return engineInstance + if (engineInitPromise) return engineInitPromise + + engineInitPromise = (async () => { + try { + const timezone = await loadTimezone() + const reader = new DeviceSignalReader() + const engine = new AutomationEngine({ + signals: reader, + now: () => Date.now(), + clock: () => clockInTimezone(timezone, new Date()), + getHardware: () => getSharedHardwareClient(), + withSideLock, + broadcast: (side, overlay) => broadcastMutationStatus(side, overlay), + markMutated: markSideMutated, + loadRules, + recordRun, + disableRule, + hasActiveRunOnceSession, + notify: (id, message) => console.log(`[automation notify] rule ${id}: ${message}`), + log: msg => console.log(`[automation] ${msg}`), + }) + await engine.start() + // Restore the global kill-switch from persisted settings (default on). + try { + const [settings] = await db.select({ on: deviceSettings.autopilotEnabled }).from(deviceSettings).limit(1) + if (settings && settings.on === false) engine.setGlobalEnabled(false) + } + catch { + // Settings unreadable (e.g. fresh DB) — leave autopilot enabled. + } + engineInstance = engine + console.log('AutomationEngine initialized with timezone:', timezone) + return engine + } + finally { + engineInitPromise = null + } + })() + + return engineInitPromise +} + +export function getAutomationEngineIfRunning(): AutomationEngine | null { + return engineInstance +} + +export async function shutdownAutomationEngine(): Promise { + if (engineInstance) { + engineInstance.stop() + engineInstance = null + engineInitPromise = null + cachedTimezone = null + console.log('AutomationEngine shut down') + } +} diff --git a/src/automation/signals.ts b/src/automation/signals.ts new file mode 100644 index 00000000..8d8cde03 --- /dev/null +++ b/src/automation/signals.ts @@ -0,0 +1,144 @@ +/** + * Signal sources for the automation engine. + * + * A `SignalReader` returns a synchronous snapshot of currently-available + * numeric signals keyed by the catalog names in docs/adr/0023-autopilot-reactive-automations.md. Any + * signal not present resolves to `undefined`, which makes dependent conditions + * skip rather than fire on stale/missing data. + * + * P0 wires the device-status signals that are reliably available now (per-side + * temperature/level from the live DAC monitor). Ambient and biometric signals + * are part of the catalog but read `undefined` until their sources are wired in + * a later phase — the engine degrades to "skip", which is the safe default. + */ + +import { getDacMonitorIfRunning } from '@/src/hardware/dacMonitor.instance' +import type { AutomationRule, DayOfWeek, Expr } from './types' + +export type SignalSnapshot = Record + +export interface SignalReader { + /** Snapshot the numeric signals available this instant. */ + read: () => SignalSnapshot +} + +const DAYS: DayOfWeek[] = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +] + +/** Timezone-aware wall clock: minutes since local midnight + day of week. */ +export function clockInTimezone( + timezone: string, + now: Date, +): { nowMinutes: number, dayOfWeek: DayOfWeek } { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hourCycle: 'h23', + hour: '2-digit', + minute: '2-digit', + weekday: 'short', + }).formatToParts(now) + const get = (type: string): string => { + const part = parts.find(p => p.type === type) + if (!part) throw new Error(`Invalid timezone: ${timezone}`) + return part.value + } + const hour = Number(get('hour')) + const minute = Number(get('minute')) + const weekday = get('weekday') // e.g. "Mon" + const dayIndex = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekday) + if (dayIndex < 0) throw new Error(`Unexpected weekday token: ${weekday}`) + return { nowMinutes: hour * 60 + minute, dayOfWeek: DAYS[dayIndex] } +} + +/** + * Production reader backed by the live DAC monitor's last status frame. Reads + * are lazy-imported so the engine module stays decoupled from hardware in tests. + */ +export class DeviceSignalReader implements SignalReader { + read(): SignalSnapshot { + const snapshot: SignalSnapshot = {} + try { + const status = getDacMonitorIfRunning()?.getLastStatus() + if (!status) return snapshot + for (const side of ['left', 'right'] as const) { + const s = side === 'left' ? status.leftSide : status.rightSide + if (!s) continue + snapshot[`${side}.currentTemperature`] = s.currentTemperature + snapshot[`${side}.targetTemperature`] = s.targetTemperature + snapshot[`${side}.currentLevel`] = s.currentLevel + } + if (status.waterLevel === 'low' || status.waterLevel === 'ok') { + snapshot['water.low'] = status.waterLevel === 'low' ? 1 : 0 + } + } + catch (err) { + // A genuine read failure (the "monitor not running" case returns early + // above). Surface it rather than swallowing; the empty snapshot makes + // dependent rules skip, which we don't want to do silently. + console.warn('[automation] DeviceSignalReader.read failed:', err) + } + return snapshot + } +} + +/** Walk an expression tree collecting the signal keys referenced by windows. */ +function collectWindowKeysFromExpr(expr: Expr, out: Set): void { + switch (expr.kind) { + case 'window': + out.add(expr.signal) + break + case 'binary': + collectWindowKeysFromExpr(expr.left, out) + collectWindowKeysFromExpr(expr.right, out) + break + case 'clamp': + collectWindowKeysFromExpr(expr.value, out) + collectWindowKeysFromExpr(expr.min, out) + collectWindowKeysFromExpr(expr.max, out) + break + } +} + +/** + * The set of signal keys any enabled rule aggregates over a window. The engine + * samples exactly these each tick to feed the `WindowStore`. + */ +export function collectWindowSignals(rules: AutomationRule[]): Set { + const out = new Set() + const walkCond = (c: AutomationRule['conditions']): void => { + switch (c.kind) { + case 'and': + case 'or': + c.conditions.forEach(walkCond) + break + case 'not': + walkCond(c.condition) + break + case 'compare': + collectWindowKeysFromExpr(c.left, out) + collectWindowKeysFromExpr(c.right, out) + break + case 'between': + collectWindowKeysFromExpr(c.subject, out) + collectWindowKeysFromExpr(c.min, out) + collectWindowKeysFromExpr(c.max, out) + break + } + } + for (const rule of rules) { + if (!rule.enabled) continue + walkCond(rule.conditions) + for (const action of rule.actions) { + if (action.kind === 'setTemperature') collectWindowKeysFromExpr(action.temp, out) + if (action.kind === 'setPower' && action.temp) collectWindowKeysFromExpr(action.temp, out) + } + } + return out +} diff --git a/src/automation/tests/backtest.test.ts b/src/automation/tests/backtest.test.ts new file mode 100644 index 00000000..9a2151df --- /dev/null +++ b/src/automation/tests/backtest.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it } from 'vitest' +import { type BacktestRule, runBacktest, type Sample } from '../backtest' +import type { Action, Condition, Trigger } from '../types' + +const HOUR = 60 * 60_000 + +/** Per-minute movement samples: low, then a sustained burst, then low. */ +function movementSeries(): Sample[] { + const out: Sample[] = [] + for (let i = 0; i < 60; i++) { + const v = i >= 10 && i <= 40 ? 300 : 100 + out.push({ t: i * 60_000, v }) + } + return out +} + +function ambientSeries(value: number): Sample[] { + const out: Sample[] = [] + for (let i = 0; i < 60; i++) out.push({ t: i * 60_000, v: value }) + return out +} + +describe('runBacktest — edge-triggered (movement avg > 200 → lower 2°F)', () => { + const rule: BacktestRule = { + side: 'left', + cooldownMin: 30, + trigger: { kind: 'tick', everyMin: 1 } as Trigger, + conditions: { + kind: 'and', + conditions: [{ + kind: 'compare', + op: '>', + left: { kind: 'window', fn: 'avg', signal: 'left.movement', lastMin: 10 }, + right: { kind: 'literal', value: 200 }, + }], + } as Condition, + actions: [{ + kind: 'setTemperature', + temp: { kind: 'binary', op: '-', left: { kind: 'signal', signal: 'left.currentTemperature' }, right: { kind: 'literal', value: 2 } }, + clamp: { min: 60, max: 75 }, + durationSec: 1200, + }] as Action[], + } + + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'left.movement': movementSeries() } }) + + it('classifies as edge mode with the right threshold + primary trace', () => { + expect(r.mode).toBe('edge') + expect(r.threshold).toBe(200) + expect(r.primary?.key).toBe('left.movement') + expect(r.avg?.key).toBe('left.movement') + }) + it('fires once then suppresses repeats during the cooldown window', () => { + expect(r.fires.length).toBeGreaterThanOrEqual(1) + expect(r.fires.length).toBeLessThanOrEqual(2) + expect(r.suppressed.length).toBeGreaterThan(0) + }) + it('reports the net effect of the action', () => { + expect(r.summary.netEffect).toContain('-2°F') + }) +}) + +describe('runBacktest — continuous policy (ambient + 3, clamped)', () => { + const policyRule = (): BacktestRule => ({ + side: null, + cooldownMin: null, + trigger: { kind: 'tick', everyMin: 1 } as Trigger, + conditions: { kind: 'and', conditions: [] } as Condition, + actions: [{ + kind: 'setTemperature', + temp: { kind: 'binary', op: '+', left: { kind: 'signal', signal: 'ambient.temperature' }, right: { kind: 'literal', value: 3 } }, + clamp: { min: 60, max: 75 }, + }] as Action[], + }) + + it('tracks ambient + 3 and never fires discrete events', () => { + const r = runBacktest({ rule: policyRule(), timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'ambient.temperature': ambientSeries(70) } }) + expect(r.mode).toBe('policy') + expect(r.fires.length).toBe(0) + const sample = r.setpoint.find(v => v != null) + expect(sample).toBe(73) + expect(r.summary.clampHits).toBe(0) + }) + + it('clamps the setpoint and counts the clamp hits when the expr exceeds the band', () => { + const r = runBacktest({ rule: policyRule(), timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'ambient.temperature': ambientSeries(74) } }) + // 74 + 3 = 77, clamped to the 75 ceiling. + const sample = r.setpoint.find(v => v != null) + expect(sample).toBe(75) + expect(r.summary.clampHits).toBeGreaterThan(0) + }) +}) + +const lit = (value: number) => ({ kind: 'literal' as const, value }) +const sig = (signal: string) => ({ kind: 'signal' as const, signal }) +const tick: Trigger = { kind: 'tick', everyMin: 1 } +const vacuous: Condition = { kind: 'and', conditions: [] } + +describe('runBacktest — no setTemperature action', () => { + it('leaves the setpoint series null when the rule only notifies', () => { + const rule: BacktestRule = { + side: 'left', + cooldownMin: null, + trigger: tick, + conditions: { kind: 'and', conditions: [{ kind: 'compare', op: '>', left: sig('left.movement'), right: lit(50) }] } as Condition, + actions: [{ kind: 'notify', message: 'restless' }] as Action[], + } + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'left.movement': movementSeries() } }) + expect(r.mode).toBe('edge') + expect(r.setpoint.every(v => v === null)).toBe(true) + expect(r.fires.length).toBeGreaterThan(0) + }) +}) + +describe('runBacktest — trigger variants', () => { + it('fires on a signalChange trigger when the value moves', () => { + const rule: BacktestRule = { + side: 'left', + cooldownMin: null, + trigger: { kind: 'signalChange', signal: 'left.movement' }, + conditions: vacuous, + actions: [{ kind: 'notify', message: 'moved' }] as Action[], + } + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'left.movement': movementSeries() } }) + // movementSeries steps between 100 and 300 twice → at least two changes. + expect(r.fires.length).toBeGreaterThanOrEqual(2) + expect(r.primary).toBeNull() + }) + + it('fires once on a timeOfDay trigger at the matching minute', () => { + const rule: BacktestRule = { + side: 'left', + cooldownMin: null, + trigger: { kind: 'timeOfDay', at: '00:05', days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] }, + conditions: vacuous, + actions: [{ kind: 'notify', message: 'bedtime' }] as Action[], + } + // 1970-01-01T00:00Z is a Thursday; the window spans 00:00–01:00 UTC. + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: {} }) + expect(r.fires).toEqual([5]) + }) +}) + +describe('runBacktest — edge-mode setpoint deltas', () => { + const edgeRule = (temp: Action): BacktestRule => ({ + side: 'left', + cooldownMin: null, + trigger: tick, + conditions: vacuous, + actions: [temp] as Action[], + }) + + it('derives the delta from a literal setpoint relative to the nominal baseline', () => { + const rule = edgeRule({ kind: 'setTemperature', temp: lit(80), clamp: { min: 60, max: 75 } } as Action) + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: {} }) + expect(r.mode).toBe('edge') + // nominal = round((60+75)/2) = 68; literal 80 → +12 → clamped to 75. + expect(r.setpoint.some(v => v === 75)).toBe(true) + }) + + it('treats a bare currentTemperature setpoint as a zero delta', () => { + const rule = edgeRule({ kind: 'setTemperature', temp: sig('left.currentTemperature'), clamp: { min: 60, max: 75 } } as Action) + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: {} }) + expect(r.mode).toBe('edge') + expect(r.summary.netEffect).toContain('0°F') + }) + + it('reads the delta from a literal on the left of the binary', () => { + // current + 2 written as 2 + current → the literal operand is on the left. + const rule = edgeRule({ kind: 'setTemperature', temp: { kind: 'binary', op: '+', left: lit(2), right: sig('left.currentTemperature') }, clamp: { min: 60, max: 75 } } as Action) + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: {} }) + expect(r.summary.netEffect).toContain('+2°F') + }) +}) + +describe('runBacktest — policy time windows', () => { + const policyWindowRule = (start: string, end: string): BacktestRule => ({ + side: null, + cooldownMin: null, + trigger: tick, + conditions: { kind: 'and', conditions: [{ kind: 'timeBetween', start, end }] } as Condition, + actions: [{ kind: 'setTemperature', temp: { kind: 'binary', op: '+', left: sig('ambient.temperature'), right: lit(3) }, clamp: { min: 60, max: 75 } }] as Action[], + }) + + it('only emits a setpoint inside a same-day window', () => { + const r = runBacktest({ rule: policyWindowRule('00:10', '00:20'), timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'ambient.temperature': ambientSeries(68) } }) + expect(r.mode).toBe('policy') + expect(r.setpoint[0]).toBeNull() // 00:00 is outside the window + expect(r.setpoint[12]).toBe(71) // 00:12 is inside → 68 + 3 + expect(r.setpoint[30]).toBeNull() // 00:30 is outside again + }) + + it('handles a window that wraps past midnight', () => { + const r = runBacktest({ rule: policyWindowRule('00:50', '00:10'), timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'ambient.temperature': ambientSeries(68) } }) + expect(r.setpoint[5]).toBe(71) // 00:05 is inside the wrapped window + expect(r.setpoint[30]).toBeNull() // 00:30 is outside + }) + + it('treats a zero-width window (start === end) as always outside', () => { + const r = runBacktest({ rule: policyWindowRule('01:00', '01:00'), timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'ambient.temperature': ambientSeries(68) } }) + expect(r.setpoint.every(v => v === null)).toBe(true) + }) +}) + +describe('runBacktest — condition-tree introspection', () => { + it('finds a comparison nested inside a NOT and a time window inside a NOT', () => { + const rule: BacktestRule = { + side: 'left', + cooldownMin: null, + trigger: tick, + conditions: { + kind: 'and', + conditions: [ + { kind: 'not', condition: { kind: 'compare', op: '>', left: sig('left.movement'), right: lit(200) } }, + { kind: 'timeBetween', start: '23:00', end: '06:00' }, + ], + } as Condition, + actions: [{ kind: 'notify', message: 'x' }] as Action[], + } + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'left.movement': movementSeries() } }) + expect(r.primary?.key).toBe('left.movement') + expect(r.timeWindow).toEqual({ startMin: 23 * 60, endMin: 6 * 60 }) + }) + + it('skips literal-left comparisons and reads through a clamp to the driving signal', () => { + const rule: BacktestRule = { + side: 'left', + cooldownMin: null, + trigger: tick, + conditions: { + kind: 'and', + conditions: [ + { kind: 'compare', op: '>', left: lit(5), right: lit(1) }, // literal left → no signal + { kind: 'compare', op: '>', left: { kind: 'clamp', value: sig('left.movement'), min: lit(0), max: lit(1000) }, right: lit(200) }, + ], + } as Condition, + actions: [{ kind: 'notify', message: 'x' }] as Action[], + } + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'left.movement': movementSeries() } }) + expect(r.primary?.key).toBe('left.movement') + }) +}) + +describe('runBacktest — sampling & introspection corners', () => { + const notifyOn = (conditions: Condition): BacktestRule => ({ + side: 'left', cooldownMin: null, trigger: tick, conditions, actions: [{ kind: 'notify', message: 'x' }] as Action[], + }) + + it('defaults the step size to 1 minute when none is given', () => { + const r = runBacktest({ rule: notifyOn(vacuous), timezone: 'UTC', startMs: 0, endMs: HOUR, series: {} }) + expect(r.stepMin).toBe(1) + }) + + it('reports a null threshold for a compare whose right side is not a literal', () => { + const cond: Condition = { kind: 'and', conditions: [{ kind: 'compare', op: '>', left: sig('left.movement'), right: sig('left.heartRate') }] } + const r = runBacktest({ rule: notifyOn(cond), timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'left.movement': movementSeries() } }) + expect(r.threshold).toBeNull() + expect(r.primary?.key).toBe('left.movement') + }) + + it('labels a primary signal that has no friendly name with its raw key', () => { + const cond: Condition = { kind: 'and', conditions: [{ kind: 'compare', op: '>', left: sig('left.targetTemperature'), right: lit(70) }] } + const series = { 'left.targetTemperature': ambientSeries(72) } + const r = runBacktest({ rule: notifyOn(cond), timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series }) + expect(r.primary?.label).toBe('left.targetTemperature') + }) + + it('treats an empty sample buffer as no data', () => { + const cond: Condition = { kind: 'and', conditions: [{ kind: 'compare', op: '>', left: sig('left.movement'), right: lit(50) }] } + const r = runBacktest({ rule: notifyOn(cond), timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'left.movement': [] } }) + expect(r.primary?.values.every(v => v === null)).toBe(true) + expect(r.fires.length).toBe(0) + }) + + it('ignores samples that lie entirely in the future', () => { + const cond: Condition = { kind: 'and', conditions: [{ kind: 'compare', op: '>', left: sig('left.movement'), right: lit(50) }] } + // The only sample is 10h ahead of the whole replay window. + const r = runBacktest({ rule: notifyOn(cond), timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'left.movement': [{ t: 10 * HOUR, v: 300 }] } }) + expect(r.primary?.values.every(v => v === null)).toBe(true) + }) + + it('treats a sample older than the staleness bound as unavailable', () => { + const cond: Condition = { kind: 'and', conditions: [{ kind: 'compare', op: '>', left: sig('left.movement'), right: lit(50) }] } + // A single sample at t=0; by 00:20 it is >15 min stale. + const r = runBacktest({ rule: notifyOn(cond), timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'left.movement': [{ t: 0, v: 300 }] } }) + expect(r.primary?.values[0]).toBe(300) // fresh + expect(r.primary?.values[20]).toBeNull() // stale + }) + + it('produces null aggregate values for a windowed compare with no data', () => { + const cond: Condition = { + kind: 'and', + conditions: [{ kind: 'compare', op: '>', left: { kind: 'window', fn: 'avg', signal: 'left.movement', lastMin: 10 }, right: lit(200) }], + } + const r = runBacktest({ rule: notifyOn(cond), timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: {} }) + expect(r.avg?.values.every(v => v === null)).toBe(true) + expect(r.fires.length).toBe(0) + }) + + it('never fires a signalChange trigger when the signal is absent', () => { + const rule: BacktestRule = { + side: 'left', cooldownMin: null, trigger: { kind: 'signalChange', signal: 'left.movement' }, + conditions: vacuous, actions: [{ kind: 'notify', message: 'x' }] as Action[], + } + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: {} }) + expect(r.fires.length).toBe(0) + }) + + it('fires a timeOfDay trigger only once even when multiple steps share a minute', () => { + const rule: BacktestRule = { + side: 'left', cooldownMin: null, trigger: { kind: 'timeOfDay', at: '00:05' }, + conditions: vacuous, actions: [{ kind: 'notify', message: 'x' }] as Action[], + } + // Half-minute steps put two steps inside minute 5; the second must dedupe. + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 0.5, series: {} }) + expect(r.fires).toHaveLength(1) + }) + + it('reports a zero net delta for an edge action that subtracts two signals', () => { + const rule: BacktestRule = { + side: 'left', cooldownMin: null, trigger: tick, conditions: vacuous, + actions: [{ kind: 'setTemperature', temp: { kind: 'binary', op: '+', left: sig('left.currentTemperature'), right: sig('ambient.temperature') }, clamp: { min: 60, max: 75 } }] as Action[], + } + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: {} }) + expect(r.mode).toBe('edge') + expect(r.summary.netEffect).toContain('0°F') + }) + + it('reads through a clamp whose inner value is a literal to a bounding signal', () => { + const rule: BacktestRule = { + side: null, cooldownMin: null, trigger: tick, conditions: vacuous, + actions: [{ kind: 'setTemperature', temp: { kind: 'clamp', value: lit(72), min: sig('ambient.temperature'), max: lit(75) }, clamp: { min: 60, max: 75 } }] as Action[], + } + const r = runBacktest({ rule, timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series: { 'ambient.temperature': ambientSeries(65) } }) + // The clamp's bounding signal is ambient → policy mode tracking ambient. + expect(r.mode).toBe('policy') + }) + + it('rounds a non-positive primary axis maximum up to a whole number', () => { + const cond: Condition = { kind: 'and', conditions: [{ kind: 'compare', op: '>', left: sig('left.movement'), right: lit(0) }] } + const series = { 'left.movement': ambientSeries(0) } // every sample is 0 + const r = runBacktest({ rule: notifyOn(cond), timezone: 'UTC', startMs: 0, endMs: HOUR, stepMin: 1, series }) + expect(r.primaryAxis?.max).toBe(0) + }) +}) diff --git a/src/automation/tests/engine.test.ts b/src/automation/tests/engine.test.ts new file mode 100644 index 00000000..a97ec9ed --- /dev/null +++ b/src/automation/tests/engine.test.ts @@ -0,0 +1,647 @@ +import { describe, expect, it, vi } from 'vitest' +import { AutomationEngine, type AutomationEngineDeps } from '../engine' +import type { SignalSnapshot } from '../signals' +import { AUTOMATION_TICK_MS, type Action, type AutomationRule, type Condition, type DayOfWeek, type Expr, type RunOutcome, type Trigger, type Side } from '../types' + +interface HwCall { + op: 'temp' | 'power' + side: Side + temp?: number + duration?: number + on?: boolean +} + +interface RunDetail { + reason?: string + message?: string + actionsLastHour?: number + actions?: Array<{ + kind: string + skipped?: string + antiThrash?: boolean + clamped?: boolean + sent?: boolean + dryRun?: boolean + temp?: number + }> +} + +interface Harness { + engine: AutomationEngine + setNow: (ms: number) => void + advance: (ms: number) => void + setSignal: (key: string, value: number | undefined) => void + setClock: (nowMinutes: number, dayOfWeek?: DayOfWeek) => void + setRunOnce: (active: boolean) => void + runs: { id: number, outcome: RunOutcome, detail: RunDetail }[] + notifies: { id: number, message: string }[] + hwCalls: HwCall[] + disabled: number[] +} + +function makeHarness(rules: AutomationRule[]): Harness { + // Base clock is a realistic epoch so a `tick` trigger's first evaluation is + // due (production `now` is always >> any everyMin window from epoch 0). + let nowMs = 1_700_000_000_000 + let runOnce = false + let nowMinutes = 0 + let dayOfWeek: DayOfWeek = 'monday' + const snapshot: SignalSnapshot = {} + const runs: Harness['runs'] = [] + const notifies: Harness['notifies'] = [] + const hwCalls: HwCall[] = [] + const disabled: number[] = [] + + const deps: AutomationEngineDeps = { + signals: { read: () => ({ ...snapshot }) }, + now: () => nowMs, + clock: () => ({ nowMinutes, dayOfWeek }), + getHardware: () => ({ + connect: async () => {}, + setTemperature: async (side, temp, duration) => { hwCalls.push({ op: 'temp', side, temp, duration }) }, + setPower: async (side, on, temp) => { hwCalls.push({ op: 'power', side, on, temp }) }, + }), + withSideLock: async (_side, fn) => fn(), + broadcast: () => {}, + markMutated: () => {}, + loadRules: async () => rules, + recordRun: async (id, outcome, detail) => { runs.push({ id, outcome, detail: detail as RunDetail }) }, + disableRule: async (id) => { disabled.push(id) }, + hasActiveRunOnceSession: async () => runOnce, + notify: (id, message) => notifies.push({ id, message }), + } + + const engine = new AutomationEngine(deps) + return { + engine, + setNow: (ms) => { nowMs = ms }, + advance: (ms) => { nowMs += ms }, + setSignal: (key, value) => { snapshot[key] = value }, + setClock: (m, d) => { + nowMinutes = m + if (d) dayOfWeek = d + }, + setRunOnce: (a) => { runOnce = a }, + runs, + notifies, + hwCalls, + disabled, + } +} + +const lit = (value: number): Expr => ({ kind: 'literal', value }) +const sig = (signal: string): Expr => ({ kind: 'signal', signal }) +const tickEvery = (everyMin: number): Trigger => ({ kind: 'tick', everyMin }) +const always: Condition = { kind: 'and', conditions: [] } // vacuously true + +function rule(overrides: Partial): AutomationRule { + return { + id: 1, + name: 'test', + enabled: true, + side: 'left', + priority: 0, + dryRun: false, + cooldownMin: null, + trigger: tickEvery(1), + conditions: always, + actions: [], + ...overrides, + } +} + +describe('AutomationEngine — outcomes & audit log', () => { + it('only evaluates a tick trigger when its interval has elapsed', async () => { + const h = makeHarness([rule({ trigger: tickEvery(5), actions: [{ kind: 'notify', message: 'hi' }] })]) + await h.engine.reload() + await h.engine.tick() // first eval is due → 1 row + h.advance(60_000) + await h.engine.tick() // 1 min later, not due → no new row + expect(h.runs).toHaveLength(1) + h.advance(5 * 60_000) + await h.engine.tick() // interval elapsed → new row + expect(h.runs).toHaveLength(2) + }) + + it('logs skipped/condition-unknown when a referenced signal is missing', async () => { + const cond: Condition = { kind: 'compare', op: '>', left: sig('absent'), right: lit(5) } + const h = makeHarness([rule({ conditions: cond, actions: [{ kind: 'notify', message: 'x' }] })]) + await h.engine.reload() + await h.engine.tick() + expect(h.runs[0].outcome).toBe('skipped') + expect(h.runs[0].detail.reason).toBe('condition-unknown') + expect(h.notifies).toHaveLength(0) + }) + + it('logs skipped/condition-false when conditions do not hold', async () => { + const cond: Condition = { kind: 'compare', op: '>', left: sig('x'), right: lit(5) } + const h = makeHarness([rule({ conditions: cond, actions: [{ kind: 'notify', message: 'x' }] })]) + h.setSignal('x', 1) + await h.engine.reload() + await h.engine.tick() + expect(h.runs[0].outcome).toBe('skipped') + expect(h.runs[0].detail.reason).toBe('condition-false') + }) + + it('fires a notify action and logs fired', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'notify', message: 'movement high' }] })]) + await h.engine.reload() + await h.engine.tick() + expect(h.notifies).toEqual([{ id: 1, message: 'movement high' }]) + expect(h.runs[0].outcome).toBe('fired') + }) +}) + +describe('AutomationEngine — dry-run', () => { + it('emits notify but never touches hardware, logging dry_run', async () => { + const actions: Action[] = [ + { kind: 'notify', message: 'would warm' }, + { kind: 'setTemperature', temp: lit(72) }, + ] + const h = makeHarness([rule({ dryRun: true, actions })]) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls).toHaveLength(0) + expect(h.notifies).toHaveLength(1) + expect(h.runs[0].outcome).toBe('dry_run') + }) +}) + +describe('AutomationEngine — two-layer temp clamp', () => { + it('clamps to the per-action user band (layer 1), logs clamped', async () => { + const h = makeHarness([rule({ + actions: [{ kind: 'setTemperature', temp: lit(200), clamp: { min: 65, max: 75 } }], + })]) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls[0]).toMatchObject({ op: 'temp', side: 'left', temp: 75 }) + expect(h.runs[0].outcome).toBe('clamped') + }) + + it('falls back to the default user band when no clamp is given', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'setTemperature', temp: lit(40) }] })]) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls[0].temp).toBe(60) // AUTOMATION_DEFAULT_USER_MIN + }) + + it('applies the hardware bound (layer 2) even past the user band', async () => { + // User band intentionally wider than hardware (engine clamps defensively). + const h = makeHarness([rule({ + actions: [{ kind: 'setTemperature', temp: lit(200), clamp: { min: 50, max: 120 } }], + })]) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls[0].temp).toBe(110) // MAX_TEMP + }) +}) + +describe('AutomationEngine — both-side fan-out', () => { + it('applies a null-side (both) hardware action to left and right', async () => { + const h = makeHarness([rule({ + side: null, + actions: [{ kind: 'setTemperature', temp: lit(72), clamp: { min: 60, max: 100 } }], + })]) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls.map(c => c.side).sort()).toEqual(['left', 'right']) + expect(h.hwCalls.every(c => c.temp === 72)).toBe(true) + expect(h.runs[0].detail.actions).toHaveLength(2) + expect(h.runs[0].outcome).toBe('fired') + }) + + it('still honours an explicit per-action side over the null rule side', async () => { + const h = makeHarness([rule({ + side: null, + actions: [{ kind: 'setTemperature', side: 'right', temp: lit(72), clamp: { min: 60, max: 100 } }], + })]) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls).toHaveLength(1) + expect(h.hwCalls[0].side).toBe('right') + }) +}) + +describe('AutomationEngine — anti-thrash', () => { + it('does not re-send a setpoint that moved less than 0.5°F', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'setTemperature', temp: sig('target') }] })]) + h.setSignal('target', 80) + await h.engine.reload() + await h.engine.tick() // writes 80 + h.advance(60_000) + h.setSignal('target', 80.3) // within 0.5 of 80 → no write + await h.engine.tick() + expect(h.hwCalls).toHaveLength(1) + expect(h.runs[1].outcome).toBe('fired') // setpoint maintained + expect(h.runs[1].detail.actions?.[0]?.antiThrash).toBe(true) + + h.advance(60_000) + h.setSignal('target', 81) // moved ≥0.5 → write + await h.engine.tick() + expect(h.hwCalls).toHaveLength(2) + expect(h.hwCalls[1].temp).toBe(81) + }) +}) + +describe('AutomationEngine — runaway guard', () => { + it('auto-disables a rule that exceeds the hourly action budget', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'setTemperature', temp: sig('target') }] })]) + await h.engine.reload() + // Alternate the setpoint far enough each tick that anti-thrash never blocks. + for (let i = 0; i < 13; i++) { + h.setSignal('target', i % 2 === 0 ? 70 : 90) + await h.engine.tick() + h.advance(60_000) + } + // 12 writes allowed, the 13th eval trips the guard. + expect(h.hwCalls).toHaveLength(12) + expect(h.disabled).toContain(1) + const errorRun = h.runs.find(r => r.outcome === 'error') + expect(errorRun?.detail.reason).toBe('runaway-disabled') + }) +}) + +describe('AutomationEngine — precedence gates', () => { + it('skips hardware while a manual-override hold is active on the side', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'setTemperature', temp: lit(72) }] })]) + await h.engine.reload() + h.engine.registerManualOverride('left') + await h.engine.tick() + expect(h.hwCalls).toHaveLength(0) + expect(h.runs[0].outcome).toBe('skipped') + expect(h.runs[0].detail.actions?.[0]?.skipped).toBe('manual-override') + }) + + it('skips hardware while a run-once session is active on the side', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'setTemperature', temp: lit(72) }] })]) + h.setRunOnce(true) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls).toHaveLength(0) + expect(h.runs[0].detail.actions?.[0]?.skipped).toBe('run-once') + }) +}) + +describe('AutomationEngine — cooldown', () => { + it('skips re-firing within the cooldown window', async () => { + const h = makeHarness([rule({ cooldownMin: 30, actions: [{ kind: 'notify', message: 'x' }] })]) + await h.engine.reload() + await h.engine.tick() // fires + h.advance(5 * 60_000) // 5 min later, still within 30m cooldown + await h.engine.tick() + expect(h.runs[0].outcome).toBe('fired') + expect(h.runs[1].outcome).toBe('skipped') + expect(h.runs[1].detail.reason).toBe('cooldown') + }) +}) + +describe('AutomationEngine — windowed aggregate (example 2)', () => { + it('fires when avg(movement, 10m) crosses the threshold', async () => { + const cond: Condition = { + kind: 'compare', + op: '>', + left: { kind: 'window', fn: 'avg', signal: 'left.movement', lastMin: 10 }, + right: lit(200), + } + const h = makeHarness([rule({ conditions: cond, actions: [{ kind: 'notify', message: 'restless' }] })]) + h.setSignal('left.movement', 300) + await h.engine.reload() + // First tick records a sample then evaluates avg=300>200 → fires. + await h.engine.tick() + expect(h.runs[0].outcome).toBe('fired') + expect(h.notifies[0].message).toBe('restless') + }) +}) + +describe('AutomationEngine — triggers', () => { + it('signalChange fires only when the signal value changes', async () => { + const h = makeHarness([rule({ + trigger: { kind: 'signalChange', signal: 'ambient.temperature' }, + actions: [{ kind: 'notify', message: 'ambient moved' }], + })]) + h.setSignal('ambient.temperature', 68) + await h.engine.reload() + await h.engine.tick() // first observation: baseline, no fire + expect(h.runs).toHaveLength(0) + await h.engine.tick() // unchanged → no fire + expect(h.runs).toHaveLength(0) + h.setSignal('ambient.temperature', 70) + await h.engine.tick() // changed → fire + expect(h.runs).toHaveLength(1) + expect(h.runs[0].outcome).toBe('fired') + }) + + it('timeOfDay fires once at the matching minute', async () => { + const h = makeHarness([rule({ + trigger: { kind: 'timeOfDay', at: '23:00' }, + actions: [{ kind: 'notify', message: 'bedtime' }], + })]) + h.setClock(22 * 60 + 59) + await h.engine.reload() + await h.engine.tick() // 22:59 → no + expect(h.runs).toHaveLength(0) + h.setClock(23 * 60) + await h.engine.tick() // 23:00 → fire + await h.engine.tick() // same minute → no double-fire + expect(h.runs).toHaveLength(1) + }) +}) + +describe('AutomationEngine — disabled rules', () => { + it('never evaluates a disabled rule', async () => { + const h = makeHarness([rule({ enabled: false, actions: [{ kind: 'notify', message: 'x' }] })]) + await h.engine.reload() + await h.engine.tick() + expect(h.runs).toHaveLength(0) + expect(h.notifies).toHaveLength(0) + }) +}) + +describe('AutomationEngine — lifecycle', () => { + it('start() installs the tick timer and stop() clears it', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'notify', message: 'x' }] })]) + await h.engine.start() // reloads + installs interval + h.engine.stop() // clears the interval + // A second stop() is a safe no-op. + h.engine.stop() + }) +}) + +describe('AutomationEngine — global kill-switch', () => { + it('halts all evaluation while disabled and resumes when re-enabled', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'notify', message: 'x' }] })]) + await h.engine.reload() + expect(h.engine.isGloballyEnabled()).toBe(true) + + h.engine.setGlobalEnabled(false) + expect(h.engine.isGloballyEnabled()).toBe(false) + await h.engine.tick() + expect(h.runs).toHaveLength(0) + + h.engine.setGlobalEnabled(true) + await h.engine.tick() + expect(h.runs).toHaveLength(1) + }) +}) + +describe('AutomationEngine — reload', () => { + it('drops runtime for rules that no longer exist', async () => { + const rules = [rule({ id: 1, actions: [{ kind: 'notify', message: 'a' }] })] + const h = makeHarness(rules) + await h.engine.reload() + await h.engine.tick() // creates runtime for rule 1 + // Replace the rule set; reload should prune runtime for the removed id 1. + rules.length = 0 + rules.push(rule({ id: 2, actions: [{ kind: 'notify', message: 'b' }] })) + await h.engine.reload() + await h.engine.tick() + expect(h.runs.some(r => r.id === 2)).toBe(true) + }) +}) + +describe('AutomationEngine — setPower', () => { + it('writes power on with a resolved temperature and tracks the setpoint', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'setPower', on: true, temp: lit(72) }] })]) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls[0]).toMatchObject({ op: 'power', side: 'left', on: true, temp: 72 }) + expect(h.runs[0].outcome).toBe('fired') + }) + + it('writes power off without a temperature', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'setPower', on: false }] })]) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls[0]).toMatchObject({ op: 'power', side: 'left', on: false }) + expect(h.runs[0].outcome).toBe('fired') + }) +}) + +describe('AutomationEngine — unresolved temperature', () => { + it('skips a setTemperature whose expression resolves to undefined', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'setTemperature', temp: sig('absent') }] })]) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls).toHaveLength(0) + expect(h.runs[0].outcome).toBe('skipped') + expect(h.runs[0].detail.actions?.[0]?.skipped).toBe('temp-unknown') + }) +}) + +describe('AutomationEngine — window-bounded eval', () => { + it('handles windows nested in clamp/binary action temps and not/between/time conditions', async () => { + const win: Expr = { kind: 'window', fn: 'avg', signal: 'left.movement', lastMin: 30 } + const conditions: Condition = { + kind: 'and', + conditions: [ + { kind: 'not', condition: { kind: 'between', subject: win, min: lit(0), max: lit(1000) } }, + { kind: 'timeBetween', start: '23:00', end: '06:00' }, + ], + } + const action: Action = { + kind: 'setTemperature', + temp: { kind: 'clamp', value: { kind: 'binary', op: '+', left: win, right: lit(1) }, min: lit(60), max: lit(75) }, + } + const h = makeHarness([rule({ conditions, actions: [action] })]) + h.setSignal('left.movement', 500) + await h.engine.reload() + // Just needs to evaluate without throwing — exercises maxWindowMinutes' walk + // over the condition/expression trees. + await expect(h.engine.tick()).resolves.toBeUndefined() + expect(h.runs).toHaveLength(1) + }) +}) + +describe('AutomationEngine — action error', () => { + it('records an error outcome when a hardware write throws', async () => { + const runs: { id: number, outcome: RunOutcome, detail: RunDetail }[] = [] + const deps: AutomationEngineDeps = { + signals: { read: () => ({}) }, + now: () => 1_700_000_000_000, + clock: () => ({ nowMinutes: 0, dayOfWeek: 'monday' }), + getHardware: () => ({ + connect: async () => { throw new Error('hardware offline') }, + setTemperature: async () => {}, + setPower: async () => {}, + }), + withSideLock: async (_side, fn) => fn(), + broadcast: () => {}, + markMutated: () => {}, + loadRules: async () => [rule({ actions: [{ kind: 'setTemperature', temp: lit(72) }] })], + recordRun: async (id, outcome, detail) => { runs.push({ id, outcome, detail: detail as RunDetail }) }, + disableRule: async () => {}, + hasActiveRunOnceSession: async () => false, + notify: () => {}, + } + const engine = new AutomationEngine(deps) + await engine.reload() + await engine.tick() + expect(runs[0].outcome).toBe('error') + expect(runs[0].detail.reason).toBe('eval-threw') + }) + + it('stringifies a non-Error thrown during evaluation', async () => { + const runs: { id: number, outcome: RunOutcome, detail: RunDetail }[] = [] + const deps: AutomationEngineDeps = { + signals: { read: () => ({}) }, + now: () => 1_700_000_000_000, + clock: () => ({ nowMinutes: 0, dayOfWeek: 'monday' }), + getHardware: () => ({ connect: async () => {}, setTemperature: async () => {}, setPower: async () => {} }), + withSideLock: async (_side, fn) => fn(), + broadcast: () => {}, + markMutated: () => {}, + loadRules: async () => [rule({ actions: [{ kind: 'setTemperature', temp: lit(72) }] })], + recordRun: async (id, outcome, detail) => { runs.push({ id, outcome, detail: detail as RunDetail }) }, + disableRule: async () => {}, + // A non-Error rejection inside the per-rule try → caught and String()'d. + hasActiveRunOnceSession: async () => { throw 'gate exploded' }, + notify: () => {}, + } + const engine = new AutomationEngine(deps) + await engine.reload() + await engine.tick() + expect(runs[0].outcome).toBe('error') + expect(runs[0].detail.message).toBe('gate exploded') + }) +}) + +describe('AutomationEngine — branch coverage corners', () => { + it('start() is idempotent — a second call does not install a second timer', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'notify', message: 'x' }] })]) + await h.engine.start() + await h.engine.start() // timer already set → no-op on the interval + h.engine.stop() + }) + + it('the interval callback drives ticks', async () => { + vi.useFakeTimers() + try { + const h = makeHarness([rule({ actions: [{ kind: 'notify', message: 'x' }] })]) + await h.engine.start() + await vi.advanceTimersByTimeAsync(AUTOMATION_TICK_MS) + expect(h.runs.length).toBeGreaterThanOrEqual(1) + h.engine.stop() + } + finally { + vi.useRealTimers() + } + }) + + it('never overlaps two ticks', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'notify', message: 'x' }] })]) + await h.engine.reload() + // The first tick suspends at its first await with `ticking` set; the second + // sees it and bails immediately. + const p1 = h.engine.tick() + const p2 = h.engine.tick() + await Promise.all([p1, p2]) + expect(h.runs).toHaveLength(1) + }) + + it('keeps runtime for surviving rules while pruning removed ones', async () => { + const rules = [ + rule({ id: 1, actions: [{ kind: 'notify', message: 'a' }] }), + rule({ id: 2, actions: [{ kind: 'notify', message: 'b' }] }), + ] + const h = makeHarness(rules) + await h.engine.reload() + await h.engine.tick() // runtime for both 1 and 2 + rules.length = 0 + rules.push(rule({ id: 1, actions: [{ kind: 'notify', message: 'a' }] })) // keep 1, drop 2 + await h.engine.reload() + await h.engine.tick() + expect(h.runs.some(r => r.id === 1)).toBe(true) + }) + + it('logs through the optional log hook on lifecycle and kill-switch transitions', async () => { + const logs: string[] = [] + const deps: AutomationEngineDeps = { + signals: { read: () => ({}) }, + now: () => 1_700_000_000_000, + clock: () => ({ nowMinutes: 0, dayOfWeek: 'monday' }), + getHardware: () => ({ connect: async () => {}, setTemperature: async () => {}, setPower: async () => {} }), + withSideLock: async (_side, fn) => fn(), + broadcast: () => {}, + markMutated: () => {}, + loadRules: async () => [], + recordRun: async () => {}, + disableRule: async () => {}, + hasActiveRunOnceSession: async () => false, + notify: () => {}, + log: msg => logs.push(msg), + } + const engine = new AutomationEngine(deps) + await engine.start() + engine.setGlobalEnabled(false) + engine.setGlobalEnabled(true) + engine.stop() + expect(logs.some(m => m.includes('started'))).toBe(true) + expect(logs.some(m => m.includes('OFF'))).toBe(true) + expect(logs.some(m => m.includes('ON'))).toBe(true) + }) + + it('does not record a windowed sample when the signal is absent this tick', async () => { + const cond: Condition = { + kind: 'compare', + op: '>', + left: { kind: 'window', fn: 'avg', signal: 'left.movement', lastMin: 10 }, + right: lit(200), + } + const h = makeHarness([rule({ conditions: cond, actions: [{ kind: 'notify', message: 'x' }] })]) + await h.engine.reload() // left.movement never set → nothing to record → avg unknown + await h.engine.tick() + expect(h.runs[0].outcome).toBe('skipped') + expect(h.runs[0].detail.reason).toBe('condition-unknown') + }) + + it('signalChange stays inactive while its signal is unavailable', async () => { + const h = makeHarness([rule({ + trigger: { kind: 'signalChange', signal: 'absent' }, + actions: [{ kind: 'notify', message: 'x' }], + })]) + await h.engine.reload() + await h.engine.tick() + expect(h.runs).toHaveLength(0) + }) + + it('timeOfDay does not fire on a day outside its day filter', async () => { + const h = makeHarness([rule({ + trigger: { kind: 'timeOfDay', at: '23:00', days: ['saturday'] }, + actions: [{ kind: 'notify', message: 'x' }], + })]) + h.setClock(23 * 60, 'monday') // matching minute, wrong day + await h.engine.reload() + await h.engine.tick() + expect(h.runs).toHaveLength(0) + }) + + it('reports dry_run for a dry-run setPower without touching hardware', async () => { + const h = makeHarness([rule({ dryRun: true, actions: [{ kind: 'setPower', on: true, temp: lit(72) }] })]) + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls).toHaveLength(0) + expect(h.runs[0].outcome).toBe('dry_run') + }) + + it('writes power on with the hardware default temperature when none is resolved', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'setPower', on: true }] })]) // no temp + await h.engine.reload() + await h.engine.tick() + expect(h.hwCalls[0]).toMatchObject({ op: 'power', side: 'left', on: true }) + expect(h.hwCalls[0].temp).toBeUndefined() + expect(h.runs[0].outcome).toBe('fired') + }) + + it('reports clamped when an anti-thrash re-assertion is still out of band', async () => { + const h = makeHarness([rule({ actions: [{ kind: 'setTemperature', temp: sig('target'), clamp: { min: 60, max: 75 } }] })]) + h.setSignal('target', 200) // clamps to 75, sent + clamped + await h.engine.reload() + await h.engine.tick() + expect(h.runs[0].outcome).toBe('clamped') + h.advance(60_000) + h.setSignal('target', 201) // clamps to 75 again → anti-thrash, still clamped + await h.engine.tick() + expect(h.runs[1].detail.actions?.[0]?.antiThrash).toBe(true) + expect(h.runs[1].outcome).toBe('clamped') + }) +}) diff --git a/src/automation/tests/evaluator.test.ts b/src/automation/tests/evaluator.test.ts new file mode 100644 index 00000000..cd0b52ae --- /dev/null +++ b/src/automation/tests/evaluator.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { evaluateCondition } from '../evaluator' +import type { EvalContext } from '../expressions' +import { WindowStore } from '../windows' +import type { CompareOp, Condition, DayOfWeek } from '../types' + +function ctx(opts: { + signals?: Record + nowMinutes?: number + dayOfWeek?: DayOfWeek +}): EvalContext { + return { + signal: k => (opts.signals ?? {})[k], + windows: new WindowStore(), + nowMs: 0, + nowMinutes: opts.nowMinutes ?? 0, + dayOfWeek: opts.dayOfWeek ?? 'monday', + } +} + +const cmp = (op: CompareOp, signal: string, value: number): Condition => ({ + kind: 'compare', + op, + left: { kind: 'signal', signal }, + right: { kind: 'literal', value }, +}) + +describe('evaluateCondition — comparisons', () => { + it('evaluates numeric comparisons', () => { + const c = ctx({ signals: { x: 10 } }) + expect(evaluateCondition(cmp('>', 'x', 5), c)).toBe(true) + expect(evaluateCondition(cmp('<', 'x', 5), c)).toBe(false) + expect(evaluateCondition(cmp('>=', 'x', 10), c)).toBe(true) + expect(evaluateCondition(cmp('<=', 'x', 10), c)).toBe(true) + expect(evaluateCondition(cmp('==', 'x', 10), c)).toBe(true) + expect(evaluateCondition(cmp('!=', 'x', 10), c)).toBe(false) + }) + + it('returns undefined when a signal is unavailable', () => { + const c = ctx({ signals: {} }) + expect(evaluateCondition(cmp('>', 'x', 5), c)).toBeUndefined() + }) +}) + +describe('evaluateCondition — three-valued AND/OR/NOT', () => { + it('AND short-circuits on a definite false, else unknown if any unknown', () => { + const known = ctx({ signals: { a: 1, b: 1 } }) + expect(evaluateCondition({ kind: 'and', conditions: [cmp('>', 'a', 0), cmp('>', 'b', 0)] }, known)).toBe(true) + + const oneFalse = ctx({ signals: { a: 1 } }) // b missing + // a>0 true, b>0 unknown → unknown + expect(evaluateCondition({ kind: 'and', conditions: [cmp('>', 'a', 0), cmp('>', 'b', 0)] }, oneFalse)).toBeUndefined() + // a<0 false dominates even with b unknown + expect(evaluateCondition({ kind: 'and', conditions: [cmp('<', 'a', 0), cmp('>', 'b', 0)] }, oneFalse)).toBe(false) + }) + + it('OR short-circuits on a definite true, else unknown if any unknown', () => { + const c = ctx({ signals: { a: 1 } }) // b missing + expect(evaluateCondition({ kind: 'or', conditions: [cmp('>', 'a', 0), cmp('>', 'b', 0)] }, c)).toBe(true) + expect(evaluateCondition({ kind: 'or', conditions: [cmp('<', 'a', 0), cmp('>', 'b', 0)] }, c)).toBeUndefined() + }) + + it('OR of all-definite-false members is false', () => { + const c = ctx({ signals: { a: 1 } }) + expect(evaluateCondition({ kind: 'or', conditions: [cmp('<', 'a', 0), cmp('>', 'a', 5)] }, c)).toBe(false) + }) + + it('NOT inverts, preserving unknown', () => { + const c = ctx({ signals: { a: 1 } }) + expect(evaluateCondition({ kind: 'not', condition: cmp('>', 'a', 0) }, c)).toBe(false) + expect(evaluateCondition({ kind: 'not', condition: cmp('>', 'missing', 0) }, c)).toBeUndefined() + }) +}) + +describe('evaluateCondition — between/time/days', () => { + it('evaluates between (inclusive)', () => { + const c = ctx({ signals: { x: 5 } }) + const between: Condition = { + kind: 'between', + subject: { kind: 'signal', signal: 'x' }, + min: { kind: 'literal', value: 0 }, + max: { kind: 'literal', value: 10 }, + } + expect(evaluateCondition(between, c)).toBe(true) + }) + + it('returns undefined for a between whose subject is unavailable', () => { + const between: Condition = { + kind: 'between', + subject: { kind: 'signal', signal: 'missing' }, + min: { kind: 'literal', value: 0 }, + max: { kind: 'literal', value: 10 }, + } + expect(evaluateCondition(between, ctx({ signals: {} }))).toBeUndefined() + }) + + it('handles a same-day time window (09:00–17:00)', () => { + const win: Condition = { kind: 'timeBetween', start: '09:00', end: '17:00' } + expect(evaluateCondition(win, ctx({ nowMinutes: 12 * 60 }))).toBe(true) // inside + expect(evaluateCondition(win, ctx({ nowMinutes: 8 * 60 }))).toBe(false) // before start + expect(evaluateCondition(win, ctx({ nowMinutes: 18 * 60 }))).toBe(false) // after end + }) + + it('treats a zero-width time window as always false', () => { + const win: Condition = { kind: 'timeBetween', start: '09:00', end: '09:00' } + expect(evaluateCondition(win, ctx({ nowMinutes: 9 * 60 }))).toBe(false) + }) + + it('handles a time window that wraps past midnight (23:00–06:00)', () => { + const win: Condition = { kind: 'timeBetween', start: '23:00', end: '06:00' } + expect(evaluateCondition(win, ctx({ nowMinutes: 23 * 60 + 30 }))).toBe(true) // 23:30 + expect(evaluateCondition(win, ctx({ nowMinutes: 2 * 60 }))).toBe(true) // 02:00 + expect(evaluateCondition(win, ctx({ nowMinutes: 12 * 60 }))).toBe(false) // 12:00 + }) + + it('matches onDays', () => { + const days: Condition = { kind: 'onDays', days: ['saturday', 'sunday'] } + expect(evaluateCondition(days, ctx({ dayOfWeek: 'saturday' }))).toBe(true) + expect(evaluateCondition(days, ctx({ dayOfWeek: 'monday' }))).toBe(false) + }) +}) diff --git a/src/automation/tests/expressions.test.ts b/src/automation/tests/expressions.test.ts new file mode 100644 index 00000000..6ed36267 --- /dev/null +++ b/src/automation/tests/expressions.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { clamp, evaluateExpr, type EvalContext } from '../expressions' +import { WindowStore } from '../windows' +import type { Expr } from '../types' + +function ctx(signals: Record, windows = new WindowStore()): EvalContext { + return { + signal: k => signals[k], + windows, + nowMs: 0, + nowMinutes: 0, + dayOfWeek: 'monday', + } +} + +describe('clamp', () => { + it('respects min and max, ignoring undefined bounds', () => { + expect(clamp(5, 0, 10)).toBe(5) + expect(clamp(-1, 0, 10)).toBe(0) + expect(clamp(11, 0, 10)).toBe(10) + expect(clamp(11, undefined, 10)).toBe(10) + expect(clamp(-5, 0, undefined)).toBe(0) + expect(clamp(42, undefined, undefined)).toBe(42) + }) +}) + +describe('evaluateExpr', () => { + it('resolves literals and signals', () => { + const c = ctx({ 'ambient.temperature': 68 }) + expect(evaluateExpr({ kind: 'literal', value: 3 }, c)).toBe(3) + expect(evaluateExpr({ kind: 'signal', signal: 'ambient.temperature' }, c)).toBe(68) + }) + + it('computes ambient + 3 (the continuous-policy expression)', () => { + const c = ctx({ 'ambient.temperature': 68 }) + const expr: Expr = { + kind: 'binary', + op: '+', + left: { kind: 'signal', signal: 'ambient.temperature' }, + right: { kind: 'literal', value: 3 }, + } + expect(evaluateExpr(expr, c)).toBe(71) + }) + + it('propagates undefined through arithmetic when a signal is missing', () => { + const c = ctx({}) + const expr: Expr = { + kind: 'binary', + op: '+', + left: { kind: 'signal', signal: 'ambient.temperature' }, + right: { kind: 'literal', value: 3 }, + } + expect(evaluateExpr(expr, c)).toBeUndefined() + }) + + it('computes subtraction and multiplication', () => { + const c = ctx({ 'left.currentTemperature': 80 }) + const sub: Expr = { kind: 'binary', op: '-', left: { kind: 'signal', signal: 'left.currentTemperature' }, right: { kind: 'literal', value: 2 } } + const mul: Expr = { kind: 'binary', op: '*', left: { kind: 'literal', value: 4 }, right: { kind: 'literal', value: 3 } } + expect(evaluateExpr(sub, c)).toBe(78) + expect(evaluateExpr(mul, c)).toBe(12) + }) + + it('treats divide-by-zero as undefined', () => { + const c = ctx({}) + const expr: Expr = { + kind: 'binary', + op: '/', + left: { kind: 'literal', value: 10 }, + right: { kind: 'literal', value: 0 }, + } + expect(evaluateExpr(expr, c)).toBeUndefined() + }) + + it('divides when the denominator is non-zero', () => { + const c = ctx({}) + const expr: Expr = { kind: 'binary', op: '/', left: { kind: 'literal', value: 10 }, right: { kind: 'literal', value: 4 } } + expect(evaluateExpr(expr, c)).toBe(2.5) + }) + + it('returns undefined for an unrecognized binary operator', () => { + const c = ctx({}) + const expr = { kind: 'binary', op: '%', left: { kind: 'literal', value: 10 }, right: { kind: 'literal', value: 4 } } as unknown as Expr + expect(evaluateExpr(expr, c)).toBeUndefined() + }) + + it('evaluates clamp() expressions', () => { + const c = ctx({}) + const expr: Expr = { + kind: 'clamp', + value: { kind: 'literal', value: 200 }, + min: { kind: 'literal', value: 60 }, + max: { kind: 'literal', value: 100 }, + } + expect(evaluateExpr(expr, c)).toBe(100) + }) + + it('propagates undefined out of a clamp whose value is unavailable', () => { + const c = ctx({}) + const expr: Expr = { + kind: 'clamp', + value: { kind: 'signal', signal: 'missing' }, + min: { kind: 'literal', value: 60 }, + max: { kind: 'literal', value: 100 }, + } + expect(evaluateExpr(expr, c)).toBeUndefined() + }) + + it('reads windowed aggregates from the store', () => { + const windows = new WindowStore() + windows.record('left.movement', 300, 0) + windows.record('left.movement', 100, 0) + const c = { ...ctx({}, windows), nowMs: 0 } + expect(evaluateExpr({ kind: 'window', fn: 'avg', signal: 'left.movement', lastMin: 10 }, c)).toBe(200) + }) +}) diff --git a/src/automation/tests/instance.test.ts b/src/automation/tests/instance.test.ts new file mode 100644 index 00000000..3e80f894 --- /dev/null +++ b/src/automation/tests/instance.test.ts @@ -0,0 +1,275 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Tests for src/automation/instance.ts — the AutomationEngine singleton wrapper. + * + * Covers: lazy init + caching, timezone-from-db with fallbacks, kill-switch + * restore from persisted settings (on / off / unreadable), teardown, and the + * production dependency closures the wrapper injects (loadRules, recordRun, + * disableRule, hasActiveRunOnceSession, and the hardware/broadcast wiring). + * + * The AutomationEngine class and the hardware/signal modules are mocked at the + * import boundary so only the wrapper's wiring is exercised; engine behaviour + * is covered in engine.test.ts. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ctorMock = vi.fn() +const startMock = vi.fn(async () => {}) +const stopMock = vi.fn() +const setGlobalEnabledMock = vi.fn() +let capturedDeps: any = null + +vi.mock('../engine', () => { + class AutomationEngine { + constructor(deps: any) { + capturedDeps = deps + ctorMock(deps) + } + + start = startMock + stop = stopMock + setGlobalEnabled = setGlobalEnabledMock + } + return { AutomationEngine } +}) + +vi.mock('../signals', () => ({ + DeviceSignalReader: class { + read() { return {} } + }, + clockInTimezone: vi.fn(() => ({ nowMinutes: 123, dayOfWeek: 'monday' })), +})) + +const hardwareClient = { connect: async () => {}, setTemperature: async () => {}, setPower: async () => {} } +const markSideMutatedMock = vi.fn() +const broadcastMock = vi.fn() + +vi.mock('@/src/hardware/dacMonitor.instance', () => ({ getSharedHardwareClient: () => hardwareClient })) +vi.mock('@/src/hardware/deviceStateSync', () => ({ markSideMutated: markSideMutatedMock })) +vi.mock('@/src/hardware/sideLock', () => ({ withSideLock: async (_side: any, fn: () => Promise) => fn() })) +vi.mock('@/src/streaming/broadcastMutationStatus', () => ({ broadcastMutationStatus: broadcastMock })) +vi.mock('drizzle-orm', () => ({ and: (...a: any[]) => ({ a }), eq: (...a: any[]) => ({ a }), gt: (...a: any[]) => ({ a }) })) +vi.mock('@/src/db/schema', () => ({ + automationRuns: {}, + automations: { id: 'id' }, + deviceSettings: { autopilotEnabled: 'autopilotEnabled', timezone: 'timezone' }, + runOnceSessions: { id: 'id', side: 'side', status: 'status', expiresAt: 'expiresAt' }, +})) + +// Reassignable behaviours the db mock delegates to. +let selectImpl: () => Promise = async () => [] +let insertImpl: (v: unknown) => Promise = async () => {} + +vi.mock('@/src/db', () => { + const thenable = (): any => { + const p: any = { + from() { return p }, + where() { return p }, + limit() { return p }, + then(res: (v: any) => void, rej?: (e: unknown) => void) { selectImpl().then(res, rej) }, + } + return p + } + return { + db: { + select: () => thenable(), + insert: () => ({ values: (v: unknown) => insertImpl(v) }), + update: () => ({ set: () => ({ where: async (w: unknown) => w }) }), + }, + } +}) + +import type * as InstanceModuleTypes from '../instance' +type InstanceModule = typeof InstanceModuleTypes + +async function freshModule(): Promise { + vi.resetModules() + return await import('../instance') +} + +beforeEach(() => { + ctorMock.mockClear() + startMock.mockClear() + stopMock.mockClear() + setGlobalEnabledMock.mockClear() + markSideMutatedMock.mockClear() + broadcastMock.mockClear() + capturedDeps = null + selectImpl = async () => [] + insertImpl = async () => {} +}) + +describe('automation/instance — init & caching', () => { + it('constructs the engine, starts it, and caches the instance', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + selectImpl = async () => [{ timezone: 'UTC', on: true }] + const mod = await freshModule() + + expect(mod.getAutomationEngineIfRunning()).toBeNull() + const a = await mod.getAutomationEngine() + const b = await mod.getAutomationEngine() + + expect(a).toBe(b) + expect(ctorMock).toHaveBeenCalledTimes(1) + expect(startMock).toHaveBeenCalledTimes(1) + expect(mod.getAutomationEngineIfRunning()).toBe(a) + expect(setGlobalEnabledMock).not.toHaveBeenCalled() // kill-switch on → no override + expect(log).toHaveBeenCalledWith('AutomationEngine initialized with timezone:', 'UTC') + log.mockRestore() + }) + + it('returns the same in-flight promise to concurrent callers', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + selectImpl = async () => [{ timezone: 'UTC', on: true }] + const mod = await freshModule() + // Both calls race before the first init resolves → second hits the cached promise. + const [a, b] = await Promise.all([mod.getAutomationEngine(), mod.getAutomationEngine()]) + expect(a).toBe(b) + expect(ctorMock).toHaveBeenCalledTimes(1) + log.mockRestore() + }) + + it('falls back to the default timezone when no settings row exists', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + selectImpl = async () => [] + const mod = await freshModule() + await mod.getAutomationEngine() + expect(log).toHaveBeenCalledWith('AutomationEngine initialized with timezone:', 'America/Los_Angeles') + log.mockRestore() + }) + + it('falls back to the default timezone when the db read throws', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + selectImpl = async () => { + throw new Error('db down') + } + const mod = await freshModule() + await mod.getAutomationEngine() + expect(log).toHaveBeenCalledWith('AutomationEngine initialized with timezone:', 'America/Los_Angeles') + log.mockRestore() + }) +}) + +describe('automation/instance — kill-switch restore', () => { + it('engages the kill-switch when the persisted setting is off', async () => { + selectImpl = async () => [{ timezone: 'UTC', on: false }] + const mod = await freshModule() + await mod.getAutomationEngine() + expect(setGlobalEnabledMock).toHaveBeenCalledWith(false) + }) + + it('leaves autopilot enabled when the kill-switch read throws', async () => { + // First select (timezone) succeeds; second (kill-switch) throws. + let calls = 0 + selectImpl = async () => { + calls++ + if (calls === 1) return [{ timezone: 'UTC' }] + throw new Error('settings unreadable') + } + const mod = await freshModule() + await mod.getAutomationEngine() + expect(setGlobalEnabledMock).not.toHaveBeenCalled() + }) +}) + +describe('automation/instance — shutdown', () => { + it('stops the engine and clears state', async () => { + selectImpl = async () => [{ timezone: 'UTC', on: true }] + const mod = await freshModule() + await mod.getAutomationEngine() + + await mod.shutdownAutomationEngine() + expect(stopMock).toHaveBeenCalledTimes(1) + expect(mod.getAutomationEngineIfRunning()).toBeNull() + }) + + it('is a no-op when no engine is running', async () => { + const mod = await freshModule() + await mod.shutdownAutomationEngine() + expect(stopMock).not.toHaveBeenCalled() + }) +}) + +describe('automation/instance — injected dependency closures', () => { + it('wires loadRules / recordRun / disableRule / runOnce / hardware deps', async () => { + selectImpl = async () => [{ timezone: 'UTC', on: true }] + const mod = await freshModule() + await mod.getAutomationEngine() + expect(capturedDeps).toBeTruthy() + + // loadRules maps DB rows to the engine's AutomationRule shape. + selectImpl = async () => [{ + id: 9, + name: 'r', + enabled: true, + side: 'left', + priority: 0, + dryRun: false, + cooldownMin: 30, + trigger: { kind: 'tick', everyMin: 1 }, + conditions: { kind: 'and', conditions: [] }, + actions: [{ kind: 'notify', message: 'x' }], + }] + const rules = await capturedDeps.loadRules() + expect(rules).toEqual([{ + id: 9, + name: 'r', + enabled: true, + side: 'left', + priority: 0, + dryRun: false, + cooldownMin: 30, + trigger: { kind: 'tick', everyMin: 1 }, + conditions: { kind: 'and', conditions: [] }, + actions: [{ kind: 'notify', message: 'x' }], + }]) + + // recordRun inserts; the failure path is swallowed with a warning. + let inserted: any = null + insertImpl = async (v) => { + inserted = v + } + await capturedDeps.recordRun(9, 'fired', { actions: [] }) + expect(inserted).toMatchObject({ automationId: 9, outcome: 'fired' }) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + insertImpl = async () => { + throw new Error('insert failed') + } + await expect(capturedDeps.recordRun(9, 'error', {})).resolves.toBeUndefined() + expect(warn).toHaveBeenCalled() + // A non-Error rejection is logged verbatim (the e-is-not-Error branch). + insertImpl = async () => { + throw 'string failure' + } + await expect(capturedDeps.recordRun(9, 'error', {})).resolves.toBeUndefined() + expect(warn).toHaveBeenCalledWith('[automation] failed to record run:', 'string failure') + warn.mockRestore() + + // disableRule resolves through the mocked update chain. + await expect(capturedDeps.disableRule(9)).resolves.toBeUndefined() + + // hasActiveRunOnceSession reflects whether a row was found. + selectImpl = async () => [{ id: 1 }] + expect(await capturedDeps.hasActiveRunOnceSession('left')).toBe(true) + selectImpl = async () => [] + expect(await capturedDeps.hasActiveRunOnceSession('right')).toBe(false) + + // Remaining wiring closures. + expect(typeof capturedDeps.now()).toBe('number') + expect(capturedDeps.clock()).toEqual({ nowMinutes: 123, dayOfWeek: 'monday' }) + expect(capturedDeps.getHardware()).toBe(hardwareClient) + expect(await capturedDeps.withSideLock('left', async () => 42)).toBe(42) + + capturedDeps.broadcast('left', { targetLevel: 0 }) + expect(broadcastMock).toHaveBeenCalledWith('left', { targetLevel: 0 }) + capturedDeps.markMutated('left') + expect(markSideMutatedMock).toHaveBeenCalledWith('left') + + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + capturedDeps.notify(9, 'hello') + capturedDeps.log('a message') + expect(log).toHaveBeenCalled() + log.mockRestore() + }) +}) diff --git a/src/automation/tests/signals.test.ts b/src/automation/tests/signals.test.ts new file mode 100644 index 00000000..84bd9720 --- /dev/null +++ b/src/automation/tests/signals.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AutomationRule, Condition, Expr } from '../types' + +// The DAC monitor is mocked at the import boundary so DeviceSignalReader can be +// exercised without real hardware. `getLastStatusMock` is swapped per test. +let getLastStatusMock: () => unknown = () => null +let monitorRunning = true + +vi.mock('@/src/hardware/dacMonitor.instance', () => ({ + getDacMonitorIfRunning: () => (monitorRunning ? { getLastStatus: getLastStatusMock } : null), +})) + +import { + DeviceSignalReader, + clockInTimezone, + collectWindowSignals, +} from '../signals' + +beforeEach(() => { + getLastStatusMock = () => null + monitorRunning = true +}) + +describe('clockInTimezone', () => { + it('returns minutes-since-midnight and the weekday for a known instant', () => { + // 2021-11-14T15:30:00Z is a Sunday; UTC keeps wall time == instant. + const { nowMinutes, dayOfWeek } = clockInTimezone('UTC', new Date('2021-11-14T15:30:00Z')) + expect(nowMinutes).toBe(15 * 60 + 30) + expect(dayOfWeek).toBe('sunday') + }) + + it('shifts the wall clock into the requested timezone', () => { + // 08:00Z in Los Angeles (UTC-8 in winter) is 00:00 local, Monday. + const { nowMinutes, dayOfWeek } = clockInTimezone('America/Los_Angeles', new Date('2021-11-15T08:00:00Z')) + expect(nowMinutes).toBe(0) + expect(dayOfWeek).toBe('monday') + }) + + it('throws on an invalid timezone', () => { + expect(() => clockInTimezone('Not/AZone', new Date())).toThrow() + }) +}) + +describe('DeviceSignalReader', () => { + it('returns an empty snapshot when the monitor is not running', () => { + monitorRunning = false + expect(new DeviceSignalReader().read()).toEqual({}) + }) + + it('returns an empty snapshot when there is no last status frame', () => { + getLastStatusMock = () => null + expect(new DeviceSignalReader().read()).toEqual({}) + }) + + it('maps both sides plus a low water flag', () => { + getLastStatusMock = () => ({ + leftSide: { currentTemperature: 75, targetTemperature: 80, currentLevel: 10 }, + rightSide: { currentTemperature: 70, targetTemperature: 68, currentLevel: -5 }, + waterLevel: 'low', + }) + expect(new DeviceSignalReader().read()).toEqual({ + 'left.currentTemperature': 75, + 'left.targetTemperature': 80, + 'left.currentLevel': 10, + 'right.currentTemperature': 70, + 'right.targetTemperature': 68, + 'right.currentLevel': -5, + 'water.low': 1, + }) + }) + + it('encodes an ok water level as 0 and skips an absent side', () => { + getLastStatusMock = () => ({ + leftSide: { currentTemperature: 72, targetTemperature: 72, currentLevel: 0 }, + rightSide: undefined, + waterLevel: 'ok', + }) + expect(new DeviceSignalReader().read()).toEqual({ + 'left.currentTemperature': 72, + 'left.targetTemperature': 72, + 'left.currentLevel': 0, + 'water.low': 0, + }) + }) + + it('omits the water flag for an unknown water level', () => { + getLastStatusMock = () => ({ + leftSide: { currentTemperature: 72, targetTemperature: 72, currentLevel: 0 }, + rightSide: { currentTemperature: 72, targetTemperature: 72, currentLevel: 0 }, + waterLevel: 'unknown', + }) + expect(new DeviceSignalReader().read()['water.low']).toBeUndefined() + }) + + it('warns and degrades to an empty snapshot when the read throws', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + getLastStatusMock = () => { + throw new Error('frame corrupt') + } + expect(new DeviceSignalReader().read()).toEqual({}) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) + +describe('collectWindowSignals', () => { + const win = (signal: string): Expr => ({ kind: 'window', fn: 'avg', signal, lastMin: 10 }) + const lit = (value: number): Expr => ({ kind: 'literal', value }) + + function rule(overrides: Partial): AutomationRule { + return { + id: 1, + name: 'r', + enabled: true, + side: 'left', + priority: 0, + dryRun: false, + cooldownMin: null, + trigger: { kind: 'tick', everyMin: 1 }, + conditions: { kind: 'and', conditions: [] }, + actions: [], + ...overrides, + } + } + + it('collects window keys from nested and/or/not/compare/between conditions', () => { + const cond: Condition = { + kind: 'and', + conditions: [ + { kind: 'or', conditions: [ + { kind: 'compare', op: '>', left: win('left.movement'), right: lit(200) }, + ] }, + { kind: 'not', condition: { kind: 'compare', op: '<', left: win('left.heartRate'), right: lit(50) } }, + { kind: 'between', subject: win('left.hrv'), min: lit(0), max: win('right.hrv') }, + ], + } + expect(collectWindowSignals([rule({ conditions: cond })])).toEqual( + new Set(['left.movement', 'left.heartRate', 'left.hrv', 'right.hrv']), + ) + }) + + it('collects window keys from setTemperature and setPower action temps', () => { + const rules = [rule({ + actions: [ + { kind: 'setTemperature', temp: { kind: 'clamp', value: win('ambient.temperature'), min: lit(60), max: lit(75) } }, + { kind: 'setPower', on: true, temp: win('left.breathingRate') }, + ], + })] + expect(collectWindowSignals(rules)).toEqual(new Set(['ambient.temperature', 'left.breathingRate'])) + }) + + it('ignores disabled rules', () => { + const cond: Condition = { kind: 'compare', op: '>', left: win('left.movement'), right: lit(1) } + expect(collectWindowSignals([rule({ enabled: false, conditions: cond })])).toEqual(new Set()) + }) + + it('returns an empty set when no windows are referenced', () => { + const cond: Condition = { kind: 'compare', op: '>', left: { kind: 'signal', signal: 'left.movement' }, right: lit(1) } + expect(collectWindowSignals([rule({ conditions: cond })])).toEqual(new Set()) + }) +}) diff --git a/src/automation/tests/windows.test.ts b/src/automation/tests/windows.test.ts new file mode 100644 index 00000000..1b2a3888 --- /dev/null +++ b/src/automation/tests/windows.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { WindowStore } from '../windows' + +const MIN = 60_000 + +describe('WindowStore', () => { + it('aggregates avg/min/max/sum/count over a trailing window', () => { + const w = new WindowStore() + const now = 100 * MIN + w.record('m', 100, now - 9 * MIN) + w.record('m', 200, now - 5 * MIN) + w.record('m', 300, now - 1 * MIN) + + expect(w.aggregate('avg', 'm', 10, now)).toBe(200) + expect(w.aggregate('min', 'm', 10, now)).toBe(100) + expect(w.aggregate('max', 'm', 10, now)).toBe(300) + expect(w.aggregate('sum', 'm', 10, now)).toBe(600) + expect(w.aggregate('count', 'm', 10, now)).toBe(3) + }) + + it('excludes samples older than the window', () => { + const w = new WindowStore() + const now = 100 * MIN + w.record('m', 100, now - 20 * MIN) // out of window + w.record('m', 400, now - 2 * MIN) + expect(w.aggregate('avg', 'm', 10, now)).toBe(400) + expect(w.aggregate('count', 'm', 10, now)).toBe(1) + }) + + it('returns undefined for unknown keys and empty windows (except count)', () => { + const w = new WindowStore() + const now = 100 * MIN + expect(w.aggregate('avg', 'missing', 10, now)).toBeUndefined() + w.record('m', 50, now - 30 * MIN) + expect(w.aggregate('avg', 'm', 10, now)).toBeUndefined() // exists but empty in-window + expect(w.aggregate('count', 'm', 10, now)).toBe(0) + }) + + it('prunes samples older than maxAgeMin', () => { + const w = new WindowStore() + const now = 100 * MIN + w.record('m', 1, now - 90 * MIN) + w.record('m', 2, now - 2 * MIN) + w.prune(now, 60) + expect(w.aggregate('count', 'm', 120, now)).toBe(1) + }) + + it('drops a buffer entirely when every sample ages out', () => { + const w = new WindowStore() + const now = 100 * MIN + w.record('m', 1, now - 90 * MIN) + w.record('m', 2, now - 80 * MIN) + w.prune(now, 60) + // Both samples pruned → key removed → unknown key, not an empty in-window. + expect(w.aggregate('count', 'm', 120, now)).toBeUndefined() + }) +}) diff --git a/src/automation/types.ts b/src/automation/types.ts new file mode 100644 index 00000000..87177a15 --- /dev/null +++ b/src/automation/types.ts @@ -0,0 +1,111 @@ +/** + * Autopilot rules-engine model — the WHEN / IF / THEN "language". + * + * These are hand-authored AST types and serve as the single source of truth: + * the zod schemas in `src/server/validation-schemas.ts` validate JSON against + * these shapes at the boundary, and the engine evaluates them directly. + */ + +export type Side = 'left' | 'right' + +export type DayOfWeek + = | 'sunday' + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday' + +export type CompareOp = '>' | '>=' | '<' | '<=' | '==' | '!=' +export type BinaryOp = '+' | '-' | '*' | '/' +export type WindowFn = 'avg' | 'min' | 'max' | 'sum' | 'count' + +/** + * Expression AST — used for both condition operands and action params. + * Every node evaluates to a number, or `undefined` when a referenced signal is + * unavailable (which propagates so a rule skips rather than firing on missing + * data). + */ +export type Expr + = | { kind: 'literal', value: number } + | { kind: 'signal', signal: string } + | { kind: 'window', fn: WindowFn, signal: string, lastMin: number } + | { kind: 'binary', op: BinaryOp, left: Expr, right: Expr } + | { kind: 'clamp', value: Expr, min: Expr, max: Expr } + +/** + * Condition AST — an AND/OR/NOT tree over comparisons. Evaluates to a boolean, + * or `undefined` ("unknown") when an underlying signal is unavailable. + */ +export type Condition + = | { kind: 'and', conditions: Condition[] } + | { kind: 'or', conditions: Condition[] } + | { kind: 'not', condition: Condition } + | { kind: 'compare', op: CompareOp, left: Expr, right: Expr } + | { kind: 'between', subject: Expr, min: Expr, max: Expr } + | { kind: 'timeBetween', start: string, end: string } // HH:MM, wraps past midnight + | { kind: 'onDays', days: DayOfWeek[] } + +/** + * Trigger (WHEN) — what wakes the rule up for an evaluation. + */ +export type Trigger + = | { kind: 'tick', everyMin: number } + | { kind: 'signalChange', signal: string } + | { kind: 'timeOfDay', at: string, days?: DayOfWeek[] } // HH:MM + +/** + * Action (THEN). `notify` is the no-hardware safe default. `setTemperature` + * carries an optional per-action clamp band (layer 1 of the two-layer clamp); + * `side` defaults to the rule's side. + */ +export type Action + = | { kind: 'notify', message: string } + | { + kind: 'setTemperature' + side?: Side + temp: Expr + clamp?: { min: number, max: number } + durationSec?: number + } + | { kind: 'setPower', side?: Side, on: boolean, temp?: Expr } + +/** + * A fully-resolved automation as the engine consumes it (JSON columns parsed). + */ +export interface AutomationRule { + id: number + name: string + enabled: boolean + side: Side | null + priority: number + dryRun: boolean + cooldownMin: number | null + trigger: Trigger + conditions: Condition + actions: Action[] +} + +export type RunOutcome = 'fired' | 'skipped' | 'clamped' | 'dry_run' | 'error' + +// --------------------------------------------------------------------------- +// Engine tunables (see docs/adr/0023-autopilot-reactive-automations.md "Resolved tunables") +// --------------------------------------------------------------------------- + +/** Global evaluator tick — 60s aligns to ambient/movement cadence. */ +export const AUTOMATION_TICK_MS = 60_000 + +/** Manual-override hold: touching the dial suspends autopilot on that side. */ +export const AUTOMATION_MANUAL_OVERRIDE_MS = 30 * 60_000 + +/** Layer-1 clamp defaults when an action omits its own `clamp` band. */ +export const AUTOMATION_DEFAULT_USER_MIN = 60 +export const AUTOMATION_DEFAULT_USER_MAX = 100 + +/** Anti-thrash: only re-assert a setpoint when it moves at least this much. */ +export const AUTOMATION_ANTI_THRASH_F = 0.5 + +/** Runaway guard: a rule firing more than this many times in a rolling hour + * is auto-disabled (enabled=false) and surfaced via an `error` run row. */ +export const AUTOMATION_MAX_ACTIONS_PER_HOUR = 12 diff --git a/src/automation/windows.ts b/src/automation/windows.ts new file mode 100644 index 00000000..81d57f60 --- /dev/null +++ b/src/automation/windows.ts @@ -0,0 +1,67 @@ +/** + * Windowed-aggregate buffers — backs `avg|min|max|sum|count(signal, last N min)`. + * + * The engine samples every windowed signal once per tick and records the value + * here; aggregate queries then read back over a trailing time window. Buffers + * are pruned to the largest window any rule asks for so memory stays bounded. + */ + +import type { WindowFn } from './types' + +interface Sample { + t: number // epoch ms + v: number +} + +export class WindowStore { + private buffers = new Map() + + /** Record a numeric sample for a signal key at time `atMs`. */ + record(key: string, value: number, atMs: number): void { + let buf = this.buffers.get(key) + if (!buf) { + buf = [] + this.buffers.set(key, buf) + } + buf.push({ t: atMs, v: value }) + } + + /** + * Aggregate samples for `key` over the trailing `lastMin` minutes ending at + * `nowMs`. Returns `undefined` when no samples fall in the window (so the + * expression propagates "unavailable" and the rule skips). `count` returns 0 + * only if the buffer exists but is empty in-window — callers treat that as a + * valid zero. + */ + aggregate(fn: WindowFn, key: string, lastMin: number, nowMs: number): number | undefined { + const buf = this.buffers.get(key) + if (!buf) return undefined + const cutoff = nowMs - lastMin * 60_000 + const values: number[] = [] + for (const s of buf) { + if (s.t >= cutoff && s.t <= nowMs) values.push(s.v) + } + if (fn === 'count') return values.length + if (values.length === 0) return undefined + switch (fn) { + case 'avg': + return values.reduce((a, b) => a + b, 0) / values.length + case 'sum': + return values.reduce((a, b) => a + b, 0) + case 'min': + return Math.min(...values) + case 'max': + return Math.max(...values) + } + } + + /** Drop samples older than `maxAgeMin` minutes before `nowMs`. */ + prune(nowMs: number, maxAgeMin: number): void { + const cutoff = nowMs - maxAgeMin * 60_000 + for (const [key, buf] of this.buffers) { + const kept = buf.filter(s => s.t >= cutoff) + if (kept.length === 0) this.buffers.delete(key) + else this.buffers.set(key, kept) + } + } +} diff --git a/src/components/Autopilot/AutomationsList.tsx b/src/components/Autopilot/AutomationsList.tsx new file mode 100644 index 00000000..082631cc --- /dev/null +++ b/src/components/Autopilot/AutomationsList.tsx @@ -0,0 +1,172 @@ +/** + * Automations list — the dense table-of-rules. Each row renders its rule as a + * plain-English sentence with the dynamic parts in the accent colour, plus an + * enabled toggle, status/side badges, last-fired and fires-today. + */ +'use client' + +import { Icon } from './icons' +import { Button, SideBadge, StatusBadge, Toggle } from './primitives' +import { type BuilderRule, buildSentence } from './builderModel' + +export interface ListItem { + id: number + name: string + enabled: boolean + mode: 'active' | 'dryrun' + side: 'left' | 'right' | 'both' + builder: BuilderRule + lastFired: string + firesToday: number +} + +function RuleSentence({ b }: { b: BuilderRule }) { + const chunks = buildSentence(b) + return ( + + {chunks.map((c, i) => ( + {c.text} + ))} + + ) +} + +function Row({ a, onToggle, onOpen }: { a: ListItem, onToggle: (id: number, enabled: boolean) => void, onOpen: (a: ListItem) => void }) { + return ( +
onOpen(a)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpen(a) + } + }} + className="group grid cursor-pointer grid-cols-[auto_1fr_auto] items-center gap-4 border-b border-zinc-800/60 px-5 py-4 transition-colors hover:bg-zinc-900/40 focus:outline-none focus:ring-2 focus:ring-zinc-500/60" + > +
e.stopPropagation()} className="pt-0.5"> + onToggle(a.id, !a.enabled)} /> +
+
+
+ {a.name} + + +
+ +
+
+
+
Last fired
+
{a.lastFired}
+
+
+
Today
+
+ {a.firesToday} + {' '} + fire + {a.firesToday === 1 ? '' : 's'} +
+
+ +
+
+ ) +} + +function EmptyState({ onNew }: { onNew: () => void }) { + return ( +
+
+
+ +
+

No automations yet

+

+ Autopilot reacts to live signals — movement, heart rate, ambient temperature — instead of just the clock. + Build a rule as + {' '} + When + {' '} + · + {' '} + If + {' '} + · + {' '} + Then + , then backtest it against past nights before it ever touches your bed. +

+
+ +
+
+ {[{ t: 'Hold ambient + 3°F overnight', s: 'continuous policy' }, { t: 'Cool down when restless', s: 'edge-triggered rule' }].map((x, i) => ( +
+
{x.t}
+
{x.s}
+
+ ))} +
+
+
+ ) +} + +export function AutomationsList({ items, loading, onToggle, onOpen, onNew }: { + items: ListItem[] + loading: boolean + onToggle: (id: number, enabled: boolean) => void + onOpen: (a: ListItem) => void + onNew: () => void +}) { + const activeCount = items.filter(a => a.enabled && a.mode === 'active').length + const dryCount = items.filter(a => a.enabled && a.mode === 'dryrun').length + const empty = !loading && items.length === 0 + return ( +
+
+
+

Automations

+

+ {empty + ? 'Reactive rules that respond to live signals' + : ( + <> + {activeCount} + {' '} + active · + {' '} + {dryCount} + {' '} + in dry-run · + {' '} + {items.length} + {' '} + total + + )} +

+
+ {!empty && ( + + )} +
+ + {loading + ?
Loading automations…
+ : empty + ? + :
{items.map(a => )}
} +
+ ) +} diff --git a/src/components/Autopilot/AutopilotConsole.tsx b/src/components/Autopilot/AutopilotConsole.tsx new file mode 100644 index 00000000..ed248bc8 --- /dev/null +++ b/src/components/Autopilot/AutopilotConsole.tsx @@ -0,0 +1,174 @@ +/** + * Autopilot console — the full-bleed desktop surface that hosts the Automations + * list, the Rule editor (modal), and the Diagnostics/status panel behind a + * left side-nav. Breaks out of the app's mobile `max-w-md` shell the same way + * the diagnostics console does. Owns all tRPC data + mutations. + */ +'use client' + +import { useMemo, useState } from 'react' +import { trpc } from '@/src/utils/trpc' +import { Icon, type IconName } from './icons' +import { AutomationsList, type ListItem } from './AutomationsList' +import { RuleEditor } from './RuleEditor' +import { StatusPanel } from './StatusPanel' +import { type BuilderRule, blankRule, fromAST, toAST } from './builderModel' + +const ACCENT = '#0c87c2' + +// Scoped styles ported from the design HTML: mono face, slim scrollbars, the +// modal fade, and the accent default — confined to `.ap-console`. +const SCOPED_CSS = ` +.ap-console { --accent: ${ACCENT}; } +.ap-console .mono { font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace; font-feature-settings: 'tnum'; } +.ap-console .tabular-nums { font-variant-numeric: tabular-nums; } +.ap-console *::-webkit-scrollbar { width: 10px; height: 10px; } +.ap-console *::-webkit-scrollbar-track { background: transparent; } +.ap-console *::-webkit-scrollbar-thumb { background: #27272a; border-radius: 999px; border: 2px solid transparent; background-clip: content-box; } +.ap-console input::placeholder { color: #52525b; } +@keyframes apFade { from { opacity: 0; transform: scale(0.99); } to { opacity: 1; transform: none; } } +` + +function NavItem({ icon, label, active, badge, onClick }: { icon: IconName, label: string, active: boolean, badge?: number, onClick: () => void }) { + const I = Icon[icon] + return ( + + ) +} + +export function AutopilotConsole() { + const utils = trpc.useUtils() + const [screen, setScreen] = useState<'list' | 'status'>('list') + const [editing, setEditing] = useState(null) + + const listQ = trpc.automations.list.useQuery({}) + const statusQ = trpc.automations.status.useQuery({}, { refetchInterval: 15000 }) + const runsQ = trpc.automations.runs.useQuery({ limit: 100 }, { refetchInterval: 15000 }) + + const invalidate = () => { + void utils.automations.list.invalidate() + void utils.automations.status.invalidate() + void utils.automations.runs.invalidate() + } + + const createM = trpc.automations.create.useMutation({ onSuccess: invalidate }) + const updateM = trpc.automations.update.useMutation({ onSuccess: invalidate }) + const setEnabledM = trpc.automations.setEnabled.useMutation({ onSuccess: invalidate }) + const setDryRunM = trpc.automations.setDryRun.useMutation({ onSuccess: invalidate }) + const killM = trpc.automations.setKillSwitch.useMutation({ onSuccess: () => { + void utils.automations.status.invalidate() + void utils.automations.getKillSwitch.invalidate() + } }) + + // Build list items from the rule rows + the status map (last-fired/today). + const items: ListItem[] = useMemo(() => { + const rows = listQ.data ?? [] + const statusById = new Map((statusQ.data?.rules ?? []).map(s => [s.id, s])) + return rows.map((row) => { + const builder = fromAST(row) + const s = statusById.get(row.id) + const lastFired = s?.lastFiredAt ? agoShort(s.lastFiredAt) : 'never' + return { + id: row.id, + name: row.name, + enabled: row.enabled, + mode: row.dryRun ? 'dryrun' : 'active', + side: (row.side ?? 'both') as ListItem['side'], + builder, + lastFired, + firesToday: s?.firesToday ?? 0, + } + }) + }, [listQ.data, statusQ.data]) + + const saving = createM.isPending || updateM.isPending + + const save = (rule: BuilderRule) => { + const ast = toAST(rule) + if (rule.id != null) updateM.mutate({ id: rule.id, ...ast }, { onSuccess: () => setEditing(null) }) + else createM.mutate(ast, { onSuccess: () => setEditing(null) }) + } + + const killed = statusQ.data ? !statusQ.data.globalEnabled : false + const activeCount = items.filter(i => i.enabled && i.mode === 'active').length + + return ( +
+