amaran-light-cli 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -14
- package/dist/__tests__/autoCct.test.js +27 -0
- package/dist/__tests__/autoCct.test.js.map +1 -1
- package/dist/__tests__/bleHttpControl.test.d.ts +2 -0
- package/dist/__tests__/bleHttpControl.test.d.ts.map +1 -0
- package/dist/__tests__/bleHttpControl.test.js +230 -0
- package/dist/__tests__/bleHttpControl.test.js.map +1 -0
- package/dist/__tests__/bleStack.test.d.ts +2 -0
- package/dist/__tests__/bleStack.test.d.ts.map +1 -0
- package/dist/__tests__/bleStack.test.js +1239 -0
- package/dist/__tests__/bleStack.test.js.map +1 -0
- package/dist/__tests__/cctUtil.test.js +9 -4
- package/dist/__tests__/cctUtil.test.js.map +1 -1
- package/dist/__tests__/circadianDashboardSettings.test.d.ts +2 -0
- package/dist/__tests__/circadianDashboardSettings.test.d.ts.map +1 -0
- package/dist/__tests__/circadianDashboardSettings.test.js +82 -0
- package/dist/__tests__/circadianDashboardSettings.test.js.map +1 -0
- package/dist/__tests__/circadianIsolation.test.d.ts +2 -0
- package/dist/__tests__/circadianIsolation.test.d.ts.map +1 -0
- package/dist/__tests__/circadianIsolation.test.js +30 -0
- package/dist/__tests__/circadianIsolation.test.js.map +1 -0
- package/dist/__tests__/cli.config.test.js +49 -0
- package/dist/__tests__/cli.config.test.js.map +1 -1
- package/dist/__tests__/cli.smoke.test.js +34 -0
- package/dist/__tests__/cli.smoke.test.js.map +1 -1
- package/dist/__tests__/currentCct.test.d.ts +2 -0
- package/dist/__tests__/currentCct.test.d.ts.map +1 -0
- package/dist/__tests__/currentCct.test.js +29 -0
- package/dist/__tests__/currentCct.test.js.map +1 -0
- package/dist/__tests__/dashboard.test.d.ts +2 -0
- package/dist/__tests__/dashboard.test.d.ts.map +1 -0
- package/dist/__tests__/dashboard.test.js +126 -0
- package/dist/__tests__/dashboard.test.js.map +1 -0
- package/dist/__tests__/daylightSimulationParseUtils.test.d.ts +2 -0
- package/dist/__tests__/daylightSimulationParseUtils.test.d.ts.map +1 -0
- package/dist/__tests__/daylightSimulationParseUtils.test.js +16 -0
- package/dist/__tests__/daylightSimulationParseUtils.test.js.map +1 -0
- package/dist/__tests__/desktopImport.test.d.ts +2 -0
- package/dist/__tests__/desktopImport.test.d.ts.map +1 -0
- package/dist/__tests__/desktopImport.test.js +234 -0
- package/dist/__tests__/desktopImport.test.js.map +1 -0
- package/dist/__tests__/lightControl.basic.test.d.ts +2 -0
- package/dist/__tests__/lightControl.basic.test.d.ts.map +1 -0
- package/dist/__tests__/lightControl.basic.test.js +119 -0
- package/dist/__tests__/lightControl.basic.test.js.map +1 -0
- package/dist/__tests__/lightControl.colorEffects.test.d.ts +2 -0
- package/dist/__tests__/lightControl.colorEffects.test.d.ts.map +1 -0
- package/dist/__tests__/lightControl.colorEffects.test.js +170 -0
- package/dist/__tests__/lightControl.colorEffects.test.js.map +1 -0
- package/dist/__tests__/lightControl.management.test.d.ts +2 -0
- package/dist/__tests__/lightControl.management.test.d.ts.map +1 -0
- package/dist/__tests__/lightControl.management.test.js +90 -0
- package/dist/__tests__/lightControl.management.test.js.map +1 -0
- package/dist/__tests__/lightControlTestUtils.d.ts +10 -0
- package/dist/__tests__/lightControlTestUtils.d.ts.map +1 -0
- package/dist/__tests__/lightControlTestUtils.js +57 -0
- package/dist/__tests__/lightControlTestUtils.js.map +1 -0
- package/dist/__tests__/meshConfiguration.test.d.ts +2 -0
- package/dist/__tests__/meshConfiguration.test.d.ts.map +1 -0
- package/dist/__tests__/meshConfiguration.test.js +78 -0
- package/dist/__tests__/meshConfiguration.test.js.map +1 -0
- package/dist/__tests__/nativeGroups.test.d.ts +2 -0
- package/dist/__tests__/nativeGroups.test.d.ts.map +1 -0
- package/dist/__tests__/nativeGroups.test.js +104 -0
- package/dist/__tests__/nativeGroups.test.js.map +1 -0
- package/dist/__tests__/programApi.test.d.ts +2 -0
- package/dist/__tests__/programApi.test.d.ts.map +1 -0
- package/dist/__tests__/programApi.test.js +73 -0
- package/dist/__tests__/programApi.test.js.map +1 -0
- package/dist/__tests__/programs.test.d.ts +2 -0
- package/dist/__tests__/programs.test.d.ts.map +1 -0
- package/dist/__tests__/programs.test.js +176 -0
- package/dist/__tests__/programs.test.js.map +1 -0
- package/dist/__tests__/scheduleMaker.test.d.ts +2 -0
- package/dist/__tests__/scheduleMaker.test.d.ts.map +1 -0
- package/dist/__tests__/scheduleMaker.test.js +25 -0
- package/dist/__tests__/scheduleMaker.test.js.map +1 -0
- package/dist/ble/async.d.ts +2 -0
- package/dist/ble/async.d.ts.map +1 -0
- package/dist/ble/async.js +15 -0
- package/dist/ble/async.js.map +1 -0
- package/dist/ble/colors.d.ts +5 -0
- package/dist/ble/colors.d.ts.map +1 -0
- package/dist/ble/colors.js +44 -0
- package/dist/ble/colors.js.map +1 -0
- package/dist/ble/configuration.d.ts +22 -0
- package/dist/ble/configuration.d.ts.map +1 -0
- package/dist/ble/configuration.js +122 -0
- package/dist/ble/configuration.js.map +1 -0
- package/dist/ble/controller.d.ts +118 -0
- package/dist/ble/controller.d.ts.map +1 -0
- package/dist/ble/controller.js +931 -0
- package/dist/ble/controller.js.map +1 -0
- package/dist/ble/crypto.d.ts +34 -0
- package/dist/ble/crypto.d.ts.map +1 -0
- package/dist/ble/crypto.js +178 -0
- package/dist/ble/crypto.js.map +1 -0
- package/dist/ble/daemon.d.ts +2 -0
- package/dist/ble/daemon.d.ts.map +1 -0
- package/dist/ble/daemon.js +76 -0
- package/dist/ble/daemon.js.map +1 -0
- package/dist/ble/dashboard.d.ts +331 -0
- package/dist/ble/dashboard.d.ts.map +1 -0
- package/dist/ble/dashboard.js +280 -0
- package/dist/ble/dashboard.js.map +1 -0
- package/dist/ble/desktop.d.ts +4 -0
- package/dist/ble/desktop.d.ts.map +1 -0
- package/dist/ble/desktop.js +33 -0
- package/dist/ble/desktop.js.map +1 -0
- package/dist/ble/desktopLibrary.d.ts +12 -0
- package/dist/ble/desktopLibrary.d.ts.map +1 -0
- package/dist/ble/desktopLibrary.js +346 -0
- package/dist/ble/desktopLibrary.js.map +1 -0
- package/dist/ble/effects.d.ts +34 -0
- package/dist/ble/effects.d.ts.map +1 -0
- package/dist/ble/effects.js +138 -0
- package/dist/ble/effects.js.map +1 -0
- package/dist/ble/fan.d.ts +244 -0
- package/dist/ble/fan.d.ts.map +1 -0
- package/dist/ble/fan.js +46 -0
- package/dist/ble/fan.js.map +1 -0
- package/dist/ble/library.d.ts +341 -0
- package/dist/ble/library.d.ts.map +1 -0
- package/dist/ble/library.js +201 -0
- package/dist/ble/library.js.map +1 -0
- package/dist/ble/media.d.ts +133 -0
- package/dist/ble/media.d.ts.map +1 -0
- package/dist/ble/media.js +186 -0
- package/dist/ble/media.js.map +1 -0
- package/dist/ble/nativeGroups.d.ts +22 -0
- package/dist/ble/nativeGroups.d.ts.map +1 -0
- package/dist/ble/nativeGroups.js +114 -0
- package/dist/ble/nativeGroups.js.map +1 -0
- package/dist/ble/packets.d.ts +3 -0
- package/dist/ble/packets.d.ts.map +1 -0
- package/dist/ble/packets.js +15 -0
- package/dist/ble/packets.js.map +1 -0
- package/dist/ble/programs.d.ts +318 -0
- package/dist/ble/programs.d.ts.map +1 -0
- package/dist/ble/programs.js +329 -0
- package/dist/ble/programs.js.map +1 -0
- package/dist/ble/provisioning.d.ts +13 -0
- package/dist/ble/provisioning.d.ts.map +1 -0
- package/dist/ble/provisioning.js +39 -0
- package/dist/ble/provisioning.js.map +1 -0
- package/dist/ble/server.d.ts +18 -0
- package/dist/ble/server.d.ts.map +1 -0
- package/dist/ble/server.js +548 -0
- package/dist/ble/server.js.map +1 -0
- package/dist/ble/service.d.ts +2 -0
- package/dist/ble/service.d.ts.map +1 -0
- package/dist/ble/service.js +77 -0
- package/dist/ble/service.js.map +1 -0
- package/dist/ble/settings.d.ts +18 -0
- package/dist/ble/settings.d.ts.map +1 -0
- package/dist/ble/settings.js +25 -0
- package/dist/ble/settings.js.map +1 -0
- package/dist/ble/setup.d.ts +2 -0
- package/dist/ble/setup.d.ts.map +1 -0
- package/dist/ble/setup.js +31 -0
- package/dist/ble/setup.js.map +1 -0
- package/dist/ble/storage.d.ts +94 -0
- package/dist/ble/storage.d.ts.map +1 -0
- package/dist/ble/storage.js +132 -0
- package/dist/ble/storage.js.map +1 -0
- package/dist/ble/telink.d.ts +63 -0
- package/dist/ble/telink.d.ts.map +1 -0
- package/dist/ble/telink.js +108 -0
- package/dist/ble/telink.js.map +1 -0
- package/dist/ble/transitions.d.ts +3 -0
- package/dist/ble/transitions.d.ts.map +1 -0
- package/dist/ble/transitions.js +28 -0
- package/dist/ble/transitions.js.map +1 -0
- package/dist/ble/transport.d.ts +48 -0
- package/dist/ble/transport.d.ts.map +1 -0
- package/dist/ble/transport.js +370 -0
- package/dist/ble/transport.js.map +1 -0
- package/dist/circadian.d.ts +3 -0
- package/dist/circadian.d.ts.map +1 -1
- package/dist/circadian.js +2 -0
- package/dist/circadian.js.map +1 -1
- package/dist/cli.d.ts +3 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +125 -6
- package/dist/cli.js.map +1 -1
- package/dist/commands/__tests__/newCommands.test.js +3 -4
- package/dist/commands/__tests__/newCommands.test.js.map +1 -1
- package/dist/commands/cmdUtils.d.ts +5 -2
- package/dist/commands/cmdUtils.d.ts.map +1 -1
- package/dist/commands/cmdUtils.js +29 -10
- package/dist/commands/cmdUtils.js.map +1 -1
- package/dist/commands/daylightSimulation/autoCct.d.ts.map +1 -1
- package/dist/commands/daylightSimulation/autoCct.js +67 -197
- package/dist/commands/daylightSimulation/autoCct.js.map +1 -1
- package/dist/commands/daylightSimulation/graphSchedule.d.ts +1 -1
- package/dist/commands/daylightSimulation/graphSchedule.d.ts.map +1 -1
- package/dist/commands/daylightSimulation/printSchedule.d.ts +1 -1
- package/dist/commands/daylightSimulation/printSchedule.d.ts.map +1 -1
- package/dist/commands/daylightSimulation/printSchedule.js +2 -2
- package/dist/commands/daylightSimulation/printSchedule.js.map +1 -1
- package/dist/commands/daylightSimulation/service.d.ts +1 -1
- package/dist/commands/daylightSimulation/service.d.ts.map +1 -1
- package/dist/commands/daylightSimulation/service.js +5 -3
- package/dist/commands/daylightSimulation/service.js.map +1 -1
- package/dist/commands/daylightSimulation/simulateSchedule.d.ts +1 -1
- package/dist/commands/daylightSimulation/simulateSchedule.d.ts.map +1 -1
- package/dist/commands/daylightSimulation/simulateSchedule.js +5 -4
- package/dist/commands/daylightSimulation/simulateSchedule.js.map +1 -1
- package/dist/commands/deviceControl/ble.d.ts +4 -0
- package/dist/commands/deviceControl/ble.d.ts.map +1 -0
- package/dist/commands/deviceControl/ble.js +214 -0
- package/dist/commands/deviceControl/ble.js.map +1 -0
- package/dist/commands/deviceControl/cct.d.ts.map +1 -1
- package/dist/commands/deviceControl/cct.js +41 -11
- package/dist/commands/deviceControl/cct.js.map +1 -1
- package/dist/commands/deviceControl/color.d.ts.map +1 -1
- package/dist/commands/deviceControl/color.js +13 -3
- package/dist/commands/deviceControl/color.js.map +1 -1
- package/dist/commands/deviceControl/config.d.ts.map +1 -1
- package/dist/commands/deviceControl/config.js +26 -3
- package/dist/commands/deviceControl/config.js.map +1 -1
- package/dist/commands/deviceControl/{discover.d.ts → desktop.d.ts} +3 -2
- package/dist/commands/deviceControl/desktop.d.ts.map +1 -0
- package/dist/commands/deviceControl/desktop.js +66 -0
- package/dist/commands/deviceControl/desktop.js.map +1 -0
- package/dist/commands/deviceControl/effect.d.ts.map +1 -1
- package/dist/commands/deviceControl/effect.js +90 -159
- package/dist/commands/deviceControl/effect.js.map +1 -1
- package/dist/commands/deviceControl/fan.d.ts.map +1 -1
- package/dist/commands/deviceControl/fan.js +77 -95
- package/dist/commands/deviceControl/fan.js.map +1 -1
- package/dist/commands/deviceControl/group.d.ts.map +1 -1
- package/dist/commands/deviceControl/group.js +43 -6
- package/dist/commands/deviceControl/group.js.map +1 -1
- package/dist/commands/deviceControl/hsi.d.ts.map +1 -1
- package/dist/commands/deviceControl/hsi.js +3 -1
- package/dist/commands/deviceControl/hsi.js.map +1 -1
- package/dist/commands/deviceControl/info.d.ts.map +1 -1
- package/dist/commands/deviceControl/info.js +0 -50
- package/dist/commands/deviceControl/info.js.map +1 -1
- package/dist/commands/deviceControl/intensity.d.ts.map +1 -1
- package/dist/commands/deviceControl/intensity.js +32 -8
- package/dist/commands/deviceControl/intensity.js.map +1 -1
- package/dist/commands/deviceControl/list.d.ts.map +1 -1
- package/dist/commands/deviceControl/list.js +3 -2
- package/dist/commands/deviceControl/list.js.map +1 -1
- package/dist/commands/deviceControl/power.d.ts.map +1 -1
- package/dist/commands/deviceControl/power.js +9 -3
- package/dist/commands/deviceControl/power.js.map +1 -1
- package/dist/commands/deviceControl/preset.d.ts.map +1 -1
- package/dist/commands/deviceControl/preset.js +56 -19
- package/dist/commands/deviceControl/preset.js.map +1 -1
- package/dist/commands/deviceControl/program.d.ts +4 -0
- package/dist/commands/deviceControl/program.d.ts.map +1 -0
- package/dist/commands/deviceControl/program.js +199 -0
- package/dist/commands/deviceControl/program.js.map +1 -0
- package/dist/commands/deviceControl/quickshot.d.ts.map +1 -1
- package/dist/commands/deviceControl/quickshot.js +75 -13
- package/dist/commands/deviceControl/quickshot.js.map +1 -1
- package/dist/commands/deviceControl/scene.d.ts.map +1 -1
- package/dist/commands/deviceControl/scene.js +72 -9
- package/dist/commands/deviceControl/scene.js.map +1 -1
- package/dist/commands/deviceControl/status.d.ts.map +1 -1
- package/dist/commands/deviceControl/status.js +26 -3
- package/dist/commands/deviceControl/status.js.map +1 -1
- package/dist/commands/parseUtils.d.ts +1 -1
- package/dist/commands/parseUtils.d.ts.map +1 -1
- package/dist/commands/parseUtils.js +1 -11
- package/dist/commands/parseUtils.js.map +1 -1
- package/dist/commands-api.d.ts +1 -1
- package/dist/commands-api.d.ts.map +1 -1
- package/dist/commands-api.js +1 -1
- package/dist/commands-api.js.map +1 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +4 -2
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +82 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +13 -2
- package/dist/config.js.map +1 -1
- package/dist/daylightSimulation/cctUtil.d.ts.map +1 -1
- package/dist/daylightSimulation/cctUtil.js +2 -2
- package/dist/daylightSimulation/cctUtil.js.map +1 -1
- package/dist/daylightSimulation/constants.d.ts +10 -0
- package/dist/daylightSimulation/constants.d.ts.map +1 -1
- package/dist/daylightSimulation/constants.js +4 -0
- package/dist/daylightSimulation/constants.js.map +1 -1
- package/dist/daylightSimulation/currentCct.d.ts +42 -0
- package/dist/daylightSimulation/currentCct.d.ts.map +1 -0
- package/dist/daylightSimulation/currentCct.js +217 -0
- package/dist/daylightSimulation/currentCct.js.map +1 -0
- package/dist/daylightSimulation/dashboardSettings.d.ts +47 -0
- package/dist/daylightSimulation/dashboardSettings.d.ts.map +1 -0
- package/dist/daylightSimulation/dashboardSettings.js +95 -0
- package/dist/daylightSimulation/dashboardSettings.js.map +1 -0
- package/dist/daylightSimulation/dashboardStatus.d.ts +72 -0
- package/dist/daylightSimulation/dashboardStatus.d.ts.map +1 -0
- package/dist/daylightSimulation/dashboardStatus.js +191 -0
- package/dist/daylightSimulation/dashboardStatus.js.map +1 -0
- package/dist/daylightSimulation/parseUtils.d.ts +3 -0
- package/dist/daylightSimulation/parseUtils.d.ts.map +1 -0
- package/dist/daylightSimulation/parseUtils.js +25 -0
- package/dist/daylightSimulation/parseUtils.js.map +1 -0
- package/dist/daylightSimulation/scheduleMaker.d.ts +2 -2
- package/dist/daylightSimulation/scheduleMaker.d.ts.map +1 -1
- package/dist/daylightSimulation/scheduleMaker.js +6 -6
- package/dist/daylightSimulation/scheduleMaker.js.map +1 -1
- package/dist/daylightSimulation/types.d.ts +13 -2
- package/dist/daylightSimulation/types.d.ts.map +1 -1
- package/dist/daylightSimulation/types.js +0 -2
- package/dist/daylightSimulation/types.js.map +1 -1
- package/dist/device-control.d.ts +1 -0
- package/dist/device-control.d.ts.map +1 -1
- package/dist/device-control.js +1 -0
- package/dist/device-control.js.map +1 -1
- package/dist/deviceControl/bleHttpControl.d.ts +129 -0
- package/dist/deviceControl/bleHttpControl.d.ts.map +1 -0
- package/dist/deviceControl/bleHttpControl.js +753 -0
- package/dist/deviceControl/bleHttpControl.js.map +1 -0
- package/dist/deviceControl/lightControl.d.ts +8 -68
- package/dist/deviceControl/lightControl.d.ts.map +1 -1
- package/dist/deviceControl/lightControl.js +6 -246
- package/dist/deviceControl/lightControl.js.map +1 -1
- package/dist/deviceControl/lightControlCommands.d.ts +68 -0
- package/dist/deviceControl/lightControlCommands.d.ts.map +1 -0
- package/dist/deviceControl/lightControlCommands.js +244 -0
- package/dist/deviceControl/lightControlCommands.js.map +1 -0
- package/dist/deviceControl/types.d.ts +75 -4
- package/dist/deviceControl/types.d.ts.map +1 -1
- package/dist/deviceControl/types.js +1 -0
- package/dist/deviceControl/types.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/test/MockLightServer.d.ts +1 -0
- package/dist/test/MockLightServer.d.ts.map +1 -1
- package/dist/test/MockLightServer.js +5 -0
- package/dist/test/MockLightServer.js.map +1 -1
- package/docs/CHANGELOG.md +37 -0
- package/docs/DIRECT_BLE.md +359 -0
- package/package.json +8 -4
- package/scripts/ble-cli-check.ts +129 -0
- package/scripts/ble-config-probe.ts +109 -0
- package/scripts/ble-effects-check.ts +82 -0
- package/scripts/ble-extended-probe.ts +44 -0
- package/scripts/ble-fan-check.ts +129 -0
- package/scripts/ble-feature-probe.ts +37 -0
- package/scripts/ble-followup-check.ts +165 -0
- package/scripts/ble-hardware-check.ts +99 -0
- package/scripts/ble-live-extended-check.ts +205 -0
- package/scripts/ble-non-ota-check.ts +193 -0
- package/scripts/ble-probe.ts +45 -0
- package/scripts/calibrate-webcam.ts +152 -0
- package/scripts/desktop-reference.ts +85 -0
- package/scripts/webcam.ts +129 -0
- package/dist/__tests__/lightControl.test.d.ts +0 -2
- package/dist/__tests__/lightControl.test.d.ts.map +0 -1
- package/dist/__tests__/lightControl.test.js +0 -424
- package/dist/__tests__/lightControl.test.js.map +0 -1
- package/dist/commands/deviceControl/discover.d.ts.map +0 -1
- package/dist/commands/deviceControl/discover.js +0 -29
- package/dist/commands/deviceControl/discover.js.map +0 -1
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { capabilities, VerifiedController, validateAction } from '../ble/controller.js';
|
|
7
|
+
import { cmac, MeshCrypto } from '../ble/crypto.js';
|
|
8
|
+
import { EFFECTS, effectPacket } from '../ble/effects.js';
|
|
9
|
+
import { LocalLibrary } from '../ble/library.js';
|
|
10
|
+
import { packet } from '../ble/packets.js';
|
|
11
|
+
import { createBleServer } from '../ble/server.js';
|
|
12
|
+
import { decodeProductInfo } from '../ble/settings.js';
|
|
13
|
+
import { atomicJson, MeshConfigSchema, SequenceStore } from '../ble/storage.js';
|
|
14
|
+
import { brightnessPacket, cctPacket, decodeFan, decodeState, FAN_MODES, fanPacket, hsiPacket, parseFanMode, powerPacket, readStatePacket, } from '../ble/telink.js';
|
|
15
|
+
import { interpolateState } from '../ble/transitions.js';
|
|
16
|
+
import { ProxyAssembler, proxyFragments } from '../ble/transport.js';
|
|
17
|
+
import { commandCallbackResult, getAppliedNumber, getLightDevices } from '../commands/cmdUtils.js';
|
|
18
|
+
import registerCct from '../commands/deviceControl/cct.js';
|
|
19
|
+
import registerFan from '../commands/deviceControl/fan.js';
|
|
20
|
+
import BleHttpController from '../deviceControl/bleHttpControl.js';
|
|
21
|
+
// Public Bluetooth Mesh specification test keys; never real fixture credentials.
|
|
22
|
+
const config = {
|
|
23
|
+
netKey: '7dd7364cd842ad18c17c2b820c84c3d6',
|
|
24
|
+
appKey: '63964771734fbd76e3b40519d1d94a48',
|
|
25
|
+
source: 32766,
|
|
26
|
+
lights: [
|
|
27
|
+
{ key: 'desk', name: '200x desk', address: 6, mac: '', model: '200x-s' },
|
|
28
|
+
{ key: 'back', name: '150c back', address: 10, mac: '', model: '150c' },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
const initial = {
|
|
32
|
+
sleep: true,
|
|
33
|
+
intensity: 10,
|
|
34
|
+
mode: 'cct',
|
|
35
|
+
cct: 3200,
|
|
36
|
+
gm: 0,
|
|
37
|
+
observedAt: new Date().toISOString(),
|
|
38
|
+
};
|
|
39
|
+
const dirs = [];
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
for (const dir of dirs.splice(0))
|
|
42
|
+
rmSync(dir, { recursive: true, force: true });
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
});
|
|
45
|
+
function fakeLink() {
|
|
46
|
+
const states = new Map(config.lights.map((light) => [light.address, { ...initial }]));
|
|
47
|
+
const fans = new Map(config.lights.map((light) => [
|
|
48
|
+
light.address,
|
|
49
|
+
{
|
|
50
|
+
mode: 1,
|
|
51
|
+
speed: 1000,
|
|
52
|
+
temperature: 35,
|
|
53
|
+
highTemperature: false,
|
|
54
|
+
supported: {
|
|
55
|
+
smart: true,
|
|
56
|
+
medium: true,
|
|
57
|
+
high: false,
|
|
58
|
+
max: false,
|
|
59
|
+
silent: false,
|
|
60
|
+
low: false,
|
|
61
|
+
off: false,
|
|
62
|
+
manual: false,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
]));
|
|
66
|
+
const link = {
|
|
67
|
+
ready: true,
|
|
68
|
+
states,
|
|
69
|
+
fans,
|
|
70
|
+
get state() {
|
|
71
|
+
const state = states.get(6);
|
|
72
|
+
if (!state)
|
|
73
|
+
throw new Error('Missing fixture');
|
|
74
|
+
return state;
|
|
75
|
+
},
|
|
76
|
+
connect: vi.fn(async () => {
|
|
77
|
+
link.ready = true;
|
|
78
|
+
}),
|
|
79
|
+
disconnect: vi.fn(async () => {
|
|
80
|
+
link.ready = false;
|
|
81
|
+
}),
|
|
82
|
+
readState: vi.fn(async (address) => {
|
|
83
|
+
const state = states.get(address);
|
|
84
|
+
if (!state)
|
|
85
|
+
throw new Error('Missing fixture');
|
|
86
|
+
return { ...state, observedAt: new Date().toISOString() };
|
|
87
|
+
}),
|
|
88
|
+
readFan: vi.fn(async (address) => {
|
|
89
|
+
const fan = fans.get(address);
|
|
90
|
+
if (!fan)
|
|
91
|
+
throw new Error('Missing fan');
|
|
92
|
+
return structuredClone(fan);
|
|
93
|
+
}),
|
|
94
|
+
send: vi.fn(async (address, payload) => {
|
|
95
|
+
const targets = address === 0xffff ? config.lights.map((light) => light.address) : [address];
|
|
96
|
+
for (const target of targets) {
|
|
97
|
+
const state = states.get(target);
|
|
98
|
+
if (!state)
|
|
99
|
+
throw new Error('Unknown fake fixture');
|
|
100
|
+
const bits = payload.readBigUInt64LE() | (BigInt(payload[8]) << 64n);
|
|
101
|
+
const intensity = Number((bits >> 62n) & 1023n);
|
|
102
|
+
switch (payload[9]) {
|
|
103
|
+
case 0x8c:
|
|
104
|
+
state.sleep = !payload[8];
|
|
105
|
+
break;
|
|
106
|
+
case 0x8f:
|
|
107
|
+
state.intensity = intensity;
|
|
108
|
+
break;
|
|
109
|
+
case 0x82:
|
|
110
|
+
case 0x81:
|
|
111
|
+
case 0x87: {
|
|
112
|
+
const reply = Buffer.from(payload);
|
|
113
|
+
reply[9] &= 127;
|
|
114
|
+
reply[1] |= 1;
|
|
115
|
+
reply[0] = reply.subarray(1).reduce((sum, byte) => sum + byte, 0) & 255;
|
|
116
|
+
const decoded = decodeState(reply);
|
|
117
|
+
if (!decoded)
|
|
118
|
+
throw new Error('No fake decoded state');
|
|
119
|
+
states.set(target, decoded);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case 0x89: {
|
|
123
|
+
const fan = fans.get(target);
|
|
124
|
+
if (!fan)
|
|
125
|
+
throw new Error('No fake fan');
|
|
126
|
+
fan.mode = payload[8];
|
|
127
|
+
fan.speed = fan.mode === FAN_MODES.manual ? payload.readUInt16LE(6) : fan.mode === FAN_MODES.off ? 0 : 1500;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
134
|
+
return link;
|
|
135
|
+
}
|
|
136
|
+
describe('mesh cryptography and proxy framing', () => {
|
|
137
|
+
const crypto = new MeshCrypto(config.netKey, config.appKey);
|
|
138
|
+
it('matches NIST CMAC and Bluetooth key derivation vectors', () => {
|
|
139
|
+
const key = Buffer.from('2b7e151628aed2a6abf7158809cf4f3c', 'hex');
|
|
140
|
+
expect(cmac(key, Buffer.alloc(0)).toString('hex')).toBe('bb1d6929e95937287fa37d129b756746');
|
|
141
|
+
expect(cmac(key, Buffer.from('6bc1bee22e409f96e93d7e117393172a', 'hex')).toString('hex')).toBe('070a16b46b4d4144f79bdd9dd04a287c');
|
|
142
|
+
expect(crypto.nid).toBe(0x68);
|
|
143
|
+
expect(crypto.networkId.toString('hex')).toBe('3ecaff672f673370');
|
|
144
|
+
expect(crypto.aid).toBe(0x26);
|
|
145
|
+
});
|
|
146
|
+
it.each([false, true])('authenticates network PDUs (proxy=%s)', (proxy) => {
|
|
147
|
+
const message = {
|
|
148
|
+
source: 32766,
|
|
149
|
+
destination: proxy ? 0 : 6,
|
|
150
|
+
sequence: 128,
|
|
151
|
+
iv: 0,
|
|
152
|
+
control: proxy,
|
|
153
|
+
transport: Buffer.from([0, 0]),
|
|
154
|
+
};
|
|
155
|
+
const encoded = crypto.network(message, proxy);
|
|
156
|
+
expect(crypto.readNetwork(encoded, 0, proxy)).toEqual(message);
|
|
157
|
+
encoded[encoded.length - 1] ^= 1;
|
|
158
|
+
expect(() => crypto.readNetwork(encoded, 0, proxy)).toThrow();
|
|
159
|
+
});
|
|
160
|
+
it('authenticates application data and the correct destination', () => {
|
|
161
|
+
const message = { source: 6, destination: 1, sequence: 555, iv: 0, control: false, transport: Buffer.alloc(0) };
|
|
162
|
+
const access = Buffer.concat([Buffer.from([0x26]), readStatePacket()]);
|
|
163
|
+
const lower = crypto.access(access, 555, 6, 1, 0);
|
|
164
|
+
expect(crypto.readAccess(lower.subarray(1), message)).toEqual(access);
|
|
165
|
+
expect(() => crypto.readAccess(lower.subarray(1), { ...message, destination: 32766 })).toThrow();
|
|
166
|
+
});
|
|
167
|
+
it('only selects advertisements from the configured network', () => {
|
|
168
|
+
expect(crypto.matchesAdvertisement(Buffer.concat([Buffer.from([0]), crypto.networkId]), [6])).toBe(true);
|
|
169
|
+
expect(crypto.matchesAdvertisement(Buffer.alloc(9), [6])).toBe(false);
|
|
170
|
+
expect(() => crypto.readBeacon(Buffer.alloc(22))).toThrow();
|
|
171
|
+
});
|
|
172
|
+
it('reassembles BLE proxy fragments and rejects orphan continuations', () => {
|
|
173
|
+
const data = Buffer.alloc(66, 42);
|
|
174
|
+
const fragments = proxyFragments(0, data);
|
|
175
|
+
expect(fragments.every((fragment) => fragment.length <= 20)).toBe(true);
|
|
176
|
+
const assembler = new ProxyAssembler();
|
|
177
|
+
const results = fragments.map((fragment) => assembler.push(fragment));
|
|
178
|
+
expect(results.at(-1)).toEqual({ type: 0, data });
|
|
179
|
+
expect(() => new ProxyAssembler().push(fragments[1])).toThrow('Unexpected proxy continuation');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
describe('actual Amaran packet encoding', () => {
|
|
183
|
+
it('encodes SDK-compatible power and read requests', () => {
|
|
184
|
+
expect(powerPacket(true).toString('hex')).toBe('8d00000000000000018c');
|
|
185
|
+
expect(powerPacket(false).toString('hex')).toBe('8c00000000000000008c');
|
|
186
|
+
expect(readStatePacket().toString('hex')).toBe('0e00000000000000000e');
|
|
187
|
+
});
|
|
188
|
+
it('uses ten-kelvin CCT units and preserves tenth-percent brightness', () => {
|
|
189
|
+
const packet = cctPacket(3200, 10);
|
|
190
|
+
const bits = packet.readBigUInt64LE() | (BigInt(packet[8]) << 64n);
|
|
191
|
+
expect(Number((bits >> 52n) & 1023n)).toBe(320);
|
|
192
|
+
expect(Number((bits >> 62n) & 1023n)).toBe(10);
|
|
193
|
+
expect(packet[4]).toBe(0);
|
|
194
|
+
expect(brightnessPacket(1)[7]).toBe(64);
|
|
195
|
+
});
|
|
196
|
+
it('decodes hardware-captured on/off and CCT replies', () => {
|
|
197
|
+
expect(decodeState(Buffer.from('da010000004001940202', 'hex'))).toMatchObject({
|
|
198
|
+
sleep: false,
|
|
199
|
+
intensity: 10,
|
|
200
|
+
cct: 3200,
|
|
201
|
+
mode: 'cct',
|
|
202
|
+
});
|
|
203
|
+
expect(decodeState(Buffer.from('d9000000004001940202', 'hex'))).toMatchObject({
|
|
204
|
+
sleep: true,
|
|
205
|
+
intensity: 10,
|
|
206
|
+
cct: 3200,
|
|
207
|
+
});
|
|
208
|
+
expect(decodeState(Buffer.from('8d0000000040a1a80202', 'hex'))).toMatchObject({ sleep: true, cct: 6500 });
|
|
209
|
+
});
|
|
210
|
+
it('rejects corrupt replies and nonfinite or out-of-range commands', () => {
|
|
211
|
+
expect(() => decodeState(Buffer.alloc(10, 1))).toThrow('checksum');
|
|
212
|
+
for (const value of [NaN, Infinity, -1, 1001])
|
|
213
|
+
expect(() => brightnessPacket(value)).toThrow();
|
|
214
|
+
expect(() => cctPacket(8000, 10)).toThrow();
|
|
215
|
+
expect(() => hsiPacket(361, 100, 10)).toThrow();
|
|
216
|
+
});
|
|
217
|
+
it('encodes neutral G/M as index ten, not the old full-magenta index zero', () => {
|
|
218
|
+
expect(cctPacket(3200, 10, 0).toString('hex')).toBe('59000000004001940282');
|
|
219
|
+
for (const gm of [-100, -30, 0, 30, 100]) {
|
|
220
|
+
const packet = cctPacket(3200, 10, gm);
|
|
221
|
+
expect(Number((packet.readBigUInt64LE() >> 45n) & 127n)).toBe(gm / 10 + 10);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
it.each(Object.entries(EFFECTS))('uses native wire ID for %s and round-trips active parameters', (name, id) => {
|
|
225
|
+
const effect = Object.keys(EFFECTS).find((key) => key === name);
|
|
226
|
+
if (!effect || !Object.hasOwn(EFFECTS, effect))
|
|
227
|
+
throw new Error('Unknown effect');
|
|
228
|
+
const options = {
|
|
229
|
+
effect: name,
|
|
230
|
+
intensity: 20,
|
|
231
|
+
frequency: 1,
|
|
232
|
+
speed: 2,
|
|
233
|
+
trigger: 2,
|
|
234
|
+
cct: 3200,
|
|
235
|
+
gm: 30,
|
|
236
|
+
palette: 0,
|
|
237
|
+
};
|
|
238
|
+
const packet = effectPacket(options);
|
|
239
|
+
expect(packet[8]).toBe(id);
|
|
240
|
+
expect(packet[9]).toBe(0x87);
|
|
241
|
+
expect(decodeState(packet)).toMatchObject({
|
|
242
|
+
mode: 'effect',
|
|
243
|
+
effect: name,
|
|
244
|
+
intensity: 20,
|
|
245
|
+
frequency: 1,
|
|
246
|
+
...(['lightning', 'faulty-bulb', 'pulsing', 'strobe', 'explosion'].includes(name) ? { trigger: 2 } : {}),
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
it('encodes the separate HSI effect layout without CCT/G/M overlap', () => {
|
|
250
|
+
const packet = effectPacket({
|
|
251
|
+
effect: 'pulsing',
|
|
252
|
+
intensity: 20,
|
|
253
|
+
frequency: 1,
|
|
254
|
+
speed: 2,
|
|
255
|
+
trigger: 2,
|
|
256
|
+
cct: 3200,
|
|
257
|
+
gm: 0,
|
|
258
|
+
palette: 0,
|
|
259
|
+
hue: 120,
|
|
260
|
+
saturation: 90,
|
|
261
|
+
});
|
|
262
|
+
expect(decodeState(packet)).toMatchObject({
|
|
263
|
+
effect: 'pulsing',
|
|
264
|
+
hue: 120,
|
|
265
|
+
sat: 90,
|
|
266
|
+
speed: 2,
|
|
267
|
+
trigger: 2,
|
|
268
|
+
intensity: 20,
|
|
269
|
+
frequency: 1,
|
|
270
|
+
});
|
|
271
|
+
expect(decodeState(packet)?.cct).toBeUndefined();
|
|
272
|
+
expect(decodeState(packet)?.gm).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
it('decodes fan telemetry and rejects unknown or inherited-property mode names', () => {
|
|
275
|
+
const packet = Buffer.alloc(10);
|
|
276
|
+
packet[3] = 0x40;
|
|
277
|
+
packet[4] = 4;
|
|
278
|
+
packet[5] = 35;
|
|
279
|
+
packet.writeUInt16LE(1200, 6);
|
|
280
|
+
packet[8] = 1;
|
|
281
|
+
packet[9] = 9;
|
|
282
|
+
packet[0] = packet.subarray(1).reduce((sum, byte) => sum + byte, 0) & 255;
|
|
283
|
+
expect(decodeFan(packet)).toMatchObject({
|
|
284
|
+
mode: 1,
|
|
285
|
+
speed: 1200,
|
|
286
|
+
temperature: 35,
|
|
287
|
+
supported: { smart: true, medium: true },
|
|
288
|
+
});
|
|
289
|
+
expect(fanPacket('smart')[8]).toBe(1);
|
|
290
|
+
expect(fanPacket('medium')[8]).toBe(5);
|
|
291
|
+
for (const mode of ['auto', '__proto__', 'toString', -1, 8, 0.5, NaN])
|
|
292
|
+
expect(() => parseFanMode(mode)).toThrow();
|
|
293
|
+
});
|
|
294
|
+
it('encodes all native modes and the SDK manual-RPM field without truncation', () => {
|
|
295
|
+
for (const [name, code] of Object.entries(FAN_MODES)) {
|
|
296
|
+
expect(parseFanMode(` ${name.toUpperCase()} `)).toBe(name);
|
|
297
|
+
expect(parseFanMode(code)).toBe(name);
|
|
298
|
+
expect(parseFanMode(String(code))).toBe(name);
|
|
299
|
+
const packet = fanPacket(parseFanMode(name), name === 'manual' ? 2200 : undefined);
|
|
300
|
+
expect(packet[8]).toBe(code);
|
|
301
|
+
expect(packet[9]).toBe(0x89);
|
|
302
|
+
}
|
|
303
|
+
expect(fanPacket('manual', 2200).toString('hex')).toBe('29000000000098080089');
|
|
304
|
+
expect(fanPacket('off').toString('hex')).toBe('8c000000000000000389');
|
|
305
|
+
expect(fanPacket('manual', 65535).readUInt16LE(6)).toBe(65535);
|
|
306
|
+
for (const rpm of [undefined, -1, 65536, 0.5, NaN, Infinity])
|
|
307
|
+
expect(() => fanPacket('manual', rpm)).toThrow();
|
|
308
|
+
expect(() => fanPacket('smart', 0)).toThrow('only valid');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
describe('persistent mesh sequence identity', () => {
|
|
312
|
+
function directory() {
|
|
313
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'amaran-sequence-test-'));
|
|
314
|
+
dirs.push(directory);
|
|
315
|
+
atomicJson(path.join(directory, 'sequence.json'), { networkId: 'test', source: 32766, next: 0 });
|
|
316
|
+
return directory;
|
|
317
|
+
}
|
|
318
|
+
it('reserves counters before use and never reuses them after restart', () => {
|
|
319
|
+
const dir = directory();
|
|
320
|
+
const first = new SequenceStore(dir, 'test', 32766);
|
|
321
|
+
expect(first.take()).toBe(0);
|
|
322
|
+
expect(JSON.parse(readFileSync(path.join(dir, 'sequence.json'), 'utf8')).next).toBe(128);
|
|
323
|
+
expect(() => new SequenceStore(dir, 'test', 32766)).toThrow('locked');
|
|
324
|
+
first.close();
|
|
325
|
+
const next = new SequenceStore(dir, 'test', 32766);
|
|
326
|
+
expect(next.take()).toBe(128);
|
|
327
|
+
next.close();
|
|
328
|
+
});
|
|
329
|
+
it('fails closed on identity mismatch, missing state and counter exhaustion', () => {
|
|
330
|
+
const dir = directory();
|
|
331
|
+
expect(() => new SequenceStore(dir, 'other', 32766)).toThrow('identity changed');
|
|
332
|
+
atomicJson(path.join(dir, 'sequence.json'), { networkId: 'test', source: 32766, next: 0x1000000 });
|
|
333
|
+
const exhausted = new SequenceStore(dir, 'test', 32766);
|
|
334
|
+
expect(() => exhausted.take()).toThrow('exhausted');
|
|
335
|
+
exhausted.close();
|
|
336
|
+
rmSync(path.join(dir, 'sequence.json'));
|
|
337
|
+
expect(() => new SequenceStore(dir, 'test', 32766)).toThrow('missing');
|
|
338
|
+
});
|
|
339
|
+
it('rejects overlapping fixture and controller addresses', () => {
|
|
340
|
+
expect(() => MeshConfigSchema.parse({ ...config, source: 7 })).toThrow();
|
|
341
|
+
expect(() => MeshConfigSchema.parse({ ...config, source: 1 })).toThrow();
|
|
342
|
+
expect(() => MeshConfigSchema.parse({ ...config, lights: [config.lights[0], config.lights[0]] })).toThrow();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
describe('verified command execution', () => {
|
|
346
|
+
it('persists manual overrides and checks them atomically before automatic CCT', async () => {
|
|
347
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'amaran-override-'));
|
|
348
|
+
dirs.push(directory);
|
|
349
|
+
const link = fakeLink();
|
|
350
|
+
const controller = new VerifiedController(config, link, new LocalLibrary(directory));
|
|
351
|
+
const results = await Promise.all([
|
|
352
|
+
controller.execute('back', 'hsi', { hue: 120, saturation: 100, brightness: 1 }),
|
|
353
|
+
controller.automaticCct('back', { kelvin: 5600, brightness: 5 }),
|
|
354
|
+
]);
|
|
355
|
+
expect(results[1]).toMatchObject({ skipped: true, reason: 'manual-override' });
|
|
356
|
+
expect(link.states.get(10)?.mode).toBe('hsi');
|
|
357
|
+
const restarted = new VerifiedController(config, link, new LocalLibrary(directory));
|
|
358
|
+
await expect(restarted.automaticCct('back', { kelvin: 5600 })).resolves.toMatchObject({ skipped: true });
|
|
359
|
+
await restarted.override(['back'], 0);
|
|
360
|
+
await expect(restarted.automaticCct('back', { kelvin: 5600 })).resolves.toMatchObject({
|
|
361
|
+
skipped: false,
|
|
362
|
+
state: { cct: 5600 },
|
|
363
|
+
});
|
|
364
|
+
expect(restarted.overrideStatus(['back']).back).toBe(0);
|
|
365
|
+
await restarted.override(['back'], 1);
|
|
366
|
+
const now = Date.now();
|
|
367
|
+
vi.spyOn(Date, 'now').mockReturnValue(now + 61_000);
|
|
368
|
+
expect(restarted.overrideStatus(['back']).back).toBe(0);
|
|
369
|
+
});
|
|
370
|
+
it('skips automatic changes for sleeping, stopped-cooling and thermally protected fixtures', async () => {
|
|
371
|
+
const link = fakeLink();
|
|
372
|
+
const controller = new VerifiedController(config, link);
|
|
373
|
+
await expect(controller.automaticCct('desk', { kelvin: 3200 })).resolves.toMatchObject({
|
|
374
|
+
skipped: true,
|
|
375
|
+
reason: 'light-off',
|
|
376
|
+
});
|
|
377
|
+
link.state.sleep = false;
|
|
378
|
+
const fan = link.fans.get(6);
|
|
379
|
+
if (!fan)
|
|
380
|
+
throw new Error('No fan');
|
|
381
|
+
fan.mode = 3;
|
|
382
|
+
await expect(controller.automaticCct('desk', { kelvin: 3200 })).resolves.toMatchObject({
|
|
383
|
+
skipped: true,
|
|
384
|
+
reason: 'stopped-cooling',
|
|
385
|
+
});
|
|
386
|
+
fan.highTemperature = true;
|
|
387
|
+
await expect(controller.automaticCct('desk', { kelvin: 3200 })).resolves.toMatchObject({
|
|
388
|
+
skipped: true,
|
|
389
|
+
reason: 'thermal-protection',
|
|
390
|
+
});
|
|
391
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
392
|
+
});
|
|
393
|
+
it('captures fan profiles and restores sleeping scenes without flashing their remembered brightness', async () => {
|
|
394
|
+
const link = fakeLink();
|
|
395
|
+
const controller = new VerifiedController(config, link);
|
|
396
|
+
const snapshot = await controller.snapshot();
|
|
397
|
+
expect(snapshot.back.fan).toEqual({ mode: 'smart' });
|
|
398
|
+
await controller.fan('back', 'medium');
|
|
399
|
+
link.send.mockClear();
|
|
400
|
+
await controller.restore(snapshot);
|
|
401
|
+
expect(link.fans.get(10)?.mode).toBe(1);
|
|
402
|
+
const cctWrites = link.send.mock.calls.filter(([, data]) => data[9] === 0x82);
|
|
403
|
+
for (const [, data] of cctWrites)
|
|
404
|
+
expect(Number(((data.readBigUInt64LE() | (BigInt(data[8]) << 64n)) >> 62n) & 1023n)).toBe(0);
|
|
405
|
+
expect(link.state).toMatchObject({ sleep: true, intensity: 10 });
|
|
406
|
+
});
|
|
407
|
+
it('stops effects for a logical group while preserving each fixture previous mode and power', async () => {
|
|
408
|
+
const link = fakeLink();
|
|
409
|
+
const controller = new VerifiedController(config, link);
|
|
410
|
+
await controller.execute('back', 'hsi', { hue: 120, saturation: 100, brightness: 1 });
|
|
411
|
+
await controller.execute('desk', 'effect', { name: 'pulsing', brightness: 0 });
|
|
412
|
+
await controller.execute('back', 'effect', { name: 'pulsing', brightness: 0 });
|
|
413
|
+
const restored = await controller.batch(['desk', 'back'], 'effect-stop', {});
|
|
414
|
+
expect(restored.states.desk).toMatchObject({ mode: 'cct', sleep: true, intensity: 10 });
|
|
415
|
+
expect(restored.states.back).toMatchObject({ mode: 'hsi', sleep: false, hue: 120 });
|
|
416
|
+
});
|
|
417
|
+
it('interpolates CCT/tint and shortest-path hue, crossing color modes through zero', () => {
|
|
418
|
+
const from = { ...initial, sleep: false, mode: 'hsi', hue: 350, sat: 100 };
|
|
419
|
+
expect(interpolateState(from, { ...from, hue: 10 }, 0.5)).toMatchObject({ hue: 0 });
|
|
420
|
+
expect(interpolateState({ ...initial, gm: -20 }, { ...initial, cct: 5200, gm: 20 }, 0.5)).toMatchObject({
|
|
421
|
+
cct: 4200,
|
|
422
|
+
gm: 0,
|
|
423
|
+
});
|
|
424
|
+
expect(interpolateState({ ...initial, sleep: false }, from, 0.5).intensity).toBe(0);
|
|
425
|
+
});
|
|
426
|
+
it('transitions steady settings and rejects unsupported target groups before writes', async () => {
|
|
427
|
+
const link = fakeLink();
|
|
428
|
+
link.state.sleep = false;
|
|
429
|
+
const controller = new VerifiedController(config, link);
|
|
430
|
+
await expect(controller.transition(['desk', 'back'], 'hsi', { hue: 120, saturation: 100 }, 1)).rejects.toThrow('does not support');
|
|
431
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
432
|
+
await expect(controller.transition(['desk'], 'cct', { kelvin: 4500, brightness: 2 }, 1)).resolves.toMatchObject({
|
|
433
|
+
states: { desk: { cct: 4500, intensity: 20, sleep: false } },
|
|
434
|
+
});
|
|
435
|
+
await expect(controller.transition(['back'], 'hsi', { hue: 120, saturation: 100 }, 1)).resolves.toMatchObject({
|
|
436
|
+
states: { back: { mode: 'hsi', hue: 120, sleep: true } },
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
it('cancels a transition without restoring its target or sending later frames', async () => {
|
|
440
|
+
const link = fakeLink();
|
|
441
|
+
const controller = new VerifiedController(config, link);
|
|
442
|
+
const cancel = new AbortController();
|
|
443
|
+
const operation = controller.transition(['desk'], 'cct', { kelvin: 5600 }, 1, cancel.signal);
|
|
444
|
+
setTimeout(() => cancel.abort(), 30);
|
|
445
|
+
await expect(operation).rejects.toThrow('intermediate settings');
|
|
446
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
447
|
+
});
|
|
448
|
+
it('renames local groups without changing their identity or membership', () => {
|
|
449
|
+
const library = new LocalLibrary();
|
|
450
|
+
const group = library.createGroup('Old');
|
|
451
|
+
library.updateGroup(group.id, 'desk', false);
|
|
452
|
+
expect(library.renameGroup(group.id, 'New')).toEqual({ ...group, name: 'New', members: ['desk'] });
|
|
453
|
+
library.createGroup('Other');
|
|
454
|
+
expect(() => library.renameGroup(group.id, 'OTHER')).toThrow('already exists');
|
|
455
|
+
});
|
|
456
|
+
it('decodes native product/version fields without inventing a semantic firmware version', () => {
|
|
457
|
+
const data = packet(0, (12n << 16n) | (11n << 22n) | (13n << 28n) | (65n << 43n) | (27n << 50n) | (39n << 66n));
|
|
458
|
+
expect(decodeProductInfo(data)).toMatchObject({
|
|
459
|
+
driverHardware: 12,
|
|
460
|
+
controllerSoftware: 11,
|
|
461
|
+
controllerHardware: 13,
|
|
462
|
+
protocolVersion: 39,
|
|
463
|
+
cctMin: 2700,
|
|
464
|
+
cctMax: 6500,
|
|
465
|
+
});
|
|
466
|
+
data[0] ^= 1;
|
|
467
|
+
expect(() => decodeProductInfo(data)).toThrow('checksum');
|
|
468
|
+
});
|
|
469
|
+
it('sends trigger requests once and distinguishes settings verification from an event acknowledgement', async () => {
|
|
470
|
+
const link = fakeLink();
|
|
471
|
+
const controller = new VerifiedController(config, link);
|
|
472
|
+
await controller.execute('desk', 'effect', { name: 'lightning', brightness: 0 });
|
|
473
|
+
link.send.mockClear();
|
|
474
|
+
const result = await controller.execute('desk', 'effect-trigger', {});
|
|
475
|
+
expect(result).toMatchObject({
|
|
476
|
+
effect: 'lightning',
|
|
477
|
+
intensity: 0,
|
|
478
|
+
triggerRequest: { sent: true, eventConfirmed: false },
|
|
479
|
+
});
|
|
480
|
+
expect(link.send).toHaveBeenCalledOnce();
|
|
481
|
+
expect(Number((link.send.mock.calls[0][1].readBigUInt64LE() >> 31n) & 3n)).toBe(1);
|
|
482
|
+
const send = link.send.getMockImplementation();
|
|
483
|
+
if (!send)
|
|
484
|
+
throw new Error('No send');
|
|
485
|
+
link.send.mockImplementation(async (address, data) => {
|
|
486
|
+
await send(address, data);
|
|
487
|
+
link.readState.mockRejectedValue(new Error('lost reply'));
|
|
488
|
+
});
|
|
489
|
+
link.send.mockClear();
|
|
490
|
+
await expect(controller.execute('desk', 'effect-trigger', {})).rejects.toThrow('not retried');
|
|
491
|
+
expect(link.send).toHaveBeenCalledOnce();
|
|
492
|
+
});
|
|
493
|
+
it('reports a common applied value for groups without inventing one for mixed results', () => {
|
|
494
|
+
expect(getAppliedNumber({ states: { desk: { cct: 4500 }, front: { cct: 4500 } } }, 'cct')).toBe(4500);
|
|
495
|
+
expect(getAppliedNumber({ states: { desk: { cct: 4500 }, front: { cct: 3200 } } }, 'cct')).toBeUndefined();
|
|
496
|
+
expect(getAppliedNumber({ intensity: 20 }, 'intensity')).toBe(20);
|
|
497
|
+
});
|
|
498
|
+
it('preserves 150c tint across CCT changes and rejects tint on 200x before any traffic', async () => {
|
|
499
|
+
const link = fakeLink();
|
|
500
|
+
const controller = new VerifiedController(config, link);
|
|
501
|
+
await expect(controller.execute('back', 'gm', { value: 32 })).resolves.toMatchObject({
|
|
502
|
+
gm: 30,
|
|
503
|
+
cct: 3200,
|
|
504
|
+
intensity: 10,
|
|
505
|
+
});
|
|
506
|
+
await expect(controller.execute('back', 'cct', { kelvin: 5600 })).resolves.toMatchObject({ gm: 30, cct: 5600 });
|
|
507
|
+
const count = link.send.mock.calls.length;
|
|
508
|
+
await expect(controller.execute('desk', 'gm', { value: 0 })).rejects.toThrow('does not support G/M');
|
|
509
|
+
expect(link.send).toHaveBeenCalledTimes(count);
|
|
510
|
+
});
|
|
511
|
+
it('clamps relative controls and maps named colors through actual HSI', async () => {
|
|
512
|
+
const controller = new VerifiedController(config, fakeLink());
|
|
513
|
+
await expect(controller.execute('desk', 'increment-brightness', { delta: -10 })).resolves.toMatchObject({
|
|
514
|
+
intensity: 0,
|
|
515
|
+
});
|
|
516
|
+
await expect(controller.execute('desk', 'increment-cct', { delta: 5000 })).resolves.toMatchObject({ cct: 6500 });
|
|
517
|
+
await expect(controller.execute('back', 'color', { color: '#0f0', brightness: 1 })).resolves.toMatchObject({
|
|
518
|
+
mode: 'hsi',
|
|
519
|
+
hue: 120,
|
|
520
|
+
sat: 100,
|
|
521
|
+
});
|
|
522
|
+
await expect(controller.execute('back', 'gm', { value: 0 })).rejects.toThrow('CCT mode');
|
|
523
|
+
});
|
|
524
|
+
it('prevalidates the whole batch, broadcasts once, and reports actual states', async () => {
|
|
525
|
+
const link = fakeLink();
|
|
526
|
+
const controller = new VerifiedController(config, link);
|
|
527
|
+
await expect(controller.batch(['back', 'desk'], 'hsi', { hue: 120, saturation: 100 })).rejects.toThrow('does not support');
|
|
528
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
529
|
+
await expect(controller.batch(['back'], 'brightness', { value: 2 }, true)).rejects.toThrow('whole mesh');
|
|
530
|
+
const result = await controller.batch(['desk', 'back'], 'brightness', { value: 2 }, true);
|
|
531
|
+
expect(result.delivery).toBe('mesh-broadcast');
|
|
532
|
+
expect(link.send).toHaveBeenCalledTimes(1);
|
|
533
|
+
expect(link.send.mock.calls[0][0]).toBe(0xffff);
|
|
534
|
+
expect(Object.values(result.states).map((state) => state.intensity)).toEqual([20, 20]);
|
|
535
|
+
});
|
|
536
|
+
it('rejects unequal broadcast settings before writes and repairs missing batch delivery', async () => {
|
|
537
|
+
const link = fakeLink();
|
|
538
|
+
link.state.intensity = 30;
|
|
539
|
+
const controller = new VerifiedController(config, link);
|
|
540
|
+
await expect(controller.batch(['desk', 'back'], 'cct', { kelvin: 3200 }, true)).rejects.toThrow('identical');
|
|
541
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
542
|
+
link.send.mockResolvedValueOnce(undefined);
|
|
543
|
+
await expect(controller.batch(['desk', 'back'], 'brightness', { value: 2 }, true)).resolves.toMatchObject({
|
|
544
|
+
states: { desk: { intensity: 20 }, back: { intensity: 20 } },
|
|
545
|
+
});
|
|
546
|
+
expect(link.send.mock.calls.map(([address]) => address)).toEqual([0xffff, 6, 10]);
|
|
547
|
+
});
|
|
548
|
+
it('enables advertised profiles on both models and refuses unsupported or thermally locked changes', async () => {
|
|
549
|
+
const link = fakeLink();
|
|
550
|
+
const controller = new VerifiedController(config, link);
|
|
551
|
+
await expect(controller.fan('back', 'off')).rejects.toThrow('does not advertise');
|
|
552
|
+
await expect(controller.fan('desk', 'max')).rejects.toThrow('does not advertise');
|
|
553
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
554
|
+
await expect(controller.fan('back', ' Medium ')).resolves.toMatchObject({
|
|
555
|
+
mode: 5,
|
|
556
|
+
modeName: 'medium',
|
|
557
|
+
allowedModes: ['smart', 'medium'],
|
|
558
|
+
});
|
|
559
|
+
await expect(controller.fan('desk', 'medium')).resolves.toMatchObject({ mode: 5, speed: 1500 });
|
|
560
|
+
const fan = link.fans.get(6);
|
|
561
|
+
if (!fan)
|
|
562
|
+
throw new Error('No test fan');
|
|
563
|
+
fan.highTemperature = true;
|
|
564
|
+
await expect(controller.fan('desk', 'medium')).rejects.toThrow('Thermal protection');
|
|
565
|
+
await expect(controller.fan('desk', 'smart')).rejects.toThrow('Thermal protection');
|
|
566
|
+
await expect(controller.fan('desk')).resolves.toMatchObject({ highTemperature: true, allowedModes: [] });
|
|
567
|
+
});
|
|
568
|
+
it('preserves native effect parameters during changes and restores saved steady state after restart', async () => {
|
|
569
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'amaran-library-test-'));
|
|
570
|
+
dirs.push(directory);
|
|
571
|
+
const link = fakeLink();
|
|
572
|
+
const controller = new VerifiedController(config, link, new LocalLibrary(directory));
|
|
573
|
+
await expect(controller.execute('back', 'effect', {
|
|
574
|
+
name: 'pulsing',
|
|
575
|
+
hue: 120,
|
|
576
|
+
saturation: 90,
|
|
577
|
+
frequency: 1,
|
|
578
|
+
speed: 4,
|
|
579
|
+
brightness: 2,
|
|
580
|
+
})).resolves.toMatchObject({ mode: 'effect', hue: 120, sat: 90, speed: 4 });
|
|
581
|
+
await expect(controller.execute('back', 'effect-intensity', { value: 3 })).resolves.toMatchObject({
|
|
582
|
+
hue: 120,
|
|
583
|
+
sat: 90,
|
|
584
|
+
speed: 4,
|
|
585
|
+
intensity: 30,
|
|
586
|
+
});
|
|
587
|
+
await expect(controller.execute('back', 'effect-speed', { value: 7 })).resolves.toMatchObject({
|
|
588
|
+
frequency: 7,
|
|
589
|
+
speed: 4,
|
|
590
|
+
});
|
|
591
|
+
await expect(controller.execute('back', 'effect-animation-speed', { value: 2 })).resolves.toMatchObject({
|
|
592
|
+
frequency: 7,
|
|
593
|
+
speed: 2,
|
|
594
|
+
});
|
|
595
|
+
await controller.execute('back', 'effect', { name: 'fire', brightness: 2, frequency: 1 });
|
|
596
|
+
const writes = link.send.mock.calls.length;
|
|
597
|
+
await expect(controller.execute('back', 'effect-animation-speed', { value: 2 })).rejects.toThrow('no animation speed');
|
|
598
|
+
expect(link.send).toHaveBeenCalledTimes(writes);
|
|
599
|
+
const restarted = new VerifiedController(config, link, new LocalLibrary(directory));
|
|
600
|
+
await expect(restarted.execute('back', 'effect-stop', {})).resolves.toMatchObject({
|
|
601
|
+
mode: 'cct',
|
|
602
|
+
cct: 3200,
|
|
603
|
+
gm: 0,
|
|
604
|
+
intensity: 10,
|
|
605
|
+
sleep: true,
|
|
606
|
+
});
|
|
607
|
+
const count = link.send.mock.calls.length;
|
|
608
|
+
await restarted.execute('back', 'effect-stop', {});
|
|
609
|
+
expect(link.send).toHaveBeenCalledTimes(count);
|
|
610
|
+
});
|
|
611
|
+
it('accepts zero RPM as telemetry rather than falsely diagnosing an unverified profile', async () => {
|
|
612
|
+
const link = fakeLink();
|
|
613
|
+
const send = link.send.getMockImplementation();
|
|
614
|
+
if (!send)
|
|
615
|
+
throw new Error('Missing fake send');
|
|
616
|
+
link.send.mockImplementation(async (address, payload) => {
|
|
617
|
+
await send(address, payload);
|
|
618
|
+
const fan = link.fans.get(address);
|
|
619
|
+
if (fan && payload[9] === 0x89 && payload[8] === 5)
|
|
620
|
+
fan.speed = 0;
|
|
621
|
+
});
|
|
622
|
+
const controller = new VerifiedController(config, link);
|
|
623
|
+
for (const key of ['desk', 'back']) {
|
|
624
|
+
await expect(controller.fan(key, 'medium')).resolves.toMatchObject({
|
|
625
|
+
mode: 5,
|
|
626
|
+
speed: 0,
|
|
627
|
+
rpmStatus: 'zero-reported',
|
|
628
|
+
});
|
|
629
|
+
await expect(controller.fan(key, '5')).resolves.toMatchObject({ mode: 5, speed: 0 });
|
|
630
|
+
}
|
|
631
|
+
expect(link.send.mock.calls.map(([, payload]) => payload[8])).toEqual([5, 5]);
|
|
632
|
+
expect(link.readState).not.toHaveBeenCalled();
|
|
633
|
+
});
|
|
634
|
+
it('supports all eight native modes when the fixture advertises them', async () => {
|
|
635
|
+
const link = fakeLink();
|
|
636
|
+
const fan = link.fans.get(10);
|
|
637
|
+
if (!fan)
|
|
638
|
+
throw new Error('Missing test fan');
|
|
639
|
+
for (const name of Object.keys(FAN_MODES))
|
|
640
|
+
fan.supported[parseFanMode(name)] = true;
|
|
641
|
+
const controller = new VerifiedController(config, link);
|
|
642
|
+
expect((await controller.fan('back')).allowedModes).toEqual(Object.keys(FAN_MODES));
|
|
643
|
+
for (const [name, code] of Object.entries(FAN_MODES)) {
|
|
644
|
+
await expect(controller.fan('back', code, undefined, name === 'manual' ? 2200 : undefined)).resolves.toMatchObject({ mode: code, modeName: name });
|
|
645
|
+
}
|
|
646
|
+
expect(link.send.mock.calls.every(([, payload]) => payload[9] === 0x89)).toBe(true);
|
|
647
|
+
expect(link.states.get(10)).toMatchObject(initial);
|
|
648
|
+
});
|
|
649
|
+
it('requires explicit manual RPM and refuses stopped cooling while LEDs emit light', async () => {
|
|
650
|
+
const link = fakeLink();
|
|
651
|
+
const fan = link.fans.get(6);
|
|
652
|
+
if (!fan)
|
|
653
|
+
throw new Error('Missing test fan');
|
|
654
|
+
fan.supported.manual = true;
|
|
655
|
+
fan.supported.off = true;
|
|
656
|
+
link.state.sleep = false;
|
|
657
|
+
const controller = new VerifiedController(config, link);
|
|
658
|
+
await expect(controller.fan('desk', 'manual')).rejects.toThrow('explicit RPM');
|
|
659
|
+
await expect(controller.fan('desk', 'smart', undefined, 2200)).rejects.toThrow('only valid');
|
|
660
|
+
await expect(controller.fan('desk', undefined, undefined, 2200)).rejects.toThrow('requires manual');
|
|
661
|
+
await expect(controller.fan('desk', 'off')).rejects.toThrow('stopped cooling');
|
|
662
|
+
await expect(controller.fan('desk', 'manual', undefined, 0)).rejects.toThrow('stopped cooling');
|
|
663
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
664
|
+
link.state.intensity = 0;
|
|
665
|
+
await expect(controller.fan('desk', 'manual', undefined, 0)).resolves.toMatchObject({ mode: 0, speed: 0 });
|
|
666
|
+
await expect(controller.fan('desk', 'off')).resolves.toMatchObject({ mode: 3, speed: 0 });
|
|
667
|
+
});
|
|
668
|
+
it('checks manual RPM as well as mode and restores Smart after a mismatched speed', async () => {
|
|
669
|
+
const link = fakeLink();
|
|
670
|
+
const fan = link.fans.get(10);
|
|
671
|
+
if (!fan)
|
|
672
|
+
throw new Error('Missing test fan');
|
|
673
|
+
fan.supported.manual = true;
|
|
674
|
+
const send = link.send.getMockImplementation();
|
|
675
|
+
if (!send)
|
|
676
|
+
throw new Error('Missing test send');
|
|
677
|
+
link.send.mockImplementation(async (address, payload) => {
|
|
678
|
+
await send(address, payload);
|
|
679
|
+
if (payload[8] === 0)
|
|
680
|
+
fan.speed = 1200;
|
|
681
|
+
});
|
|
682
|
+
await expect(new VerifiedController(config, link).fan('back', 'manual', undefined, 2200)).rejects.toThrow('mode/RPM did not verify');
|
|
683
|
+
expect(fan.mode).toBe(1);
|
|
684
|
+
expect(link.send.mock.calls.map(([, payload]) => payload[8])).toEqual([0, 1]);
|
|
685
|
+
});
|
|
686
|
+
it('prevalidates stopped-cooling requests for the entire batch', async () => {
|
|
687
|
+
const link = fakeLink();
|
|
688
|
+
for (const fan of link.fans.values())
|
|
689
|
+
fan.supported.off = true;
|
|
690
|
+
const back = link.states.get(10);
|
|
691
|
+
if (!back)
|
|
692
|
+
throw new Error('Missing test state');
|
|
693
|
+
back.sleep = false;
|
|
694
|
+
await expect(new VerifiedController(config, link).fans(['desk', 'back'], 'off')).rejects.toThrow('stopped cooling');
|
|
695
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
696
|
+
});
|
|
697
|
+
it('prevalidates every fan target before writes and reports partial delivery explicitly', async () => {
|
|
698
|
+
const link = fakeLink();
|
|
699
|
+
const controller = new VerifiedController(config, link);
|
|
700
|
+
const back = link.fans.get(10);
|
|
701
|
+
if (!back)
|
|
702
|
+
throw new Error('Missing test fan');
|
|
703
|
+
back.supported.medium = false;
|
|
704
|
+
await expect(controller.fans(['desk', 'back'], 'medium')).rejects.toThrow('does not advertise');
|
|
705
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
706
|
+
back.supported.medium = true;
|
|
707
|
+
back.highTemperature = true;
|
|
708
|
+
await expect(controller.fans(['desk', 'back'], 'medium')).rejects.toThrow('Thermal protection');
|
|
709
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
710
|
+
back.highTemperature = false;
|
|
711
|
+
const send = link.send.getMockImplementation();
|
|
712
|
+
if (!send)
|
|
713
|
+
throw new Error('Missing test send');
|
|
714
|
+
link.send.mockImplementation(async (address, payload) => {
|
|
715
|
+
if (address === 10)
|
|
716
|
+
throw new Error('write failed');
|
|
717
|
+
await send(address, payload);
|
|
718
|
+
});
|
|
719
|
+
await expect(controller.fans(['desk', 'back'], 'medium')).rejects.toThrow('verified targets: desk');
|
|
720
|
+
expect(link.fans.get(6)?.mode).toBe(5);
|
|
721
|
+
expect(back.mode).toBe(1);
|
|
722
|
+
});
|
|
723
|
+
it('stops on a thermal trip without trying to restart the fan or lamp', async () => {
|
|
724
|
+
const link = fakeLink();
|
|
725
|
+
const send = link.send.getMockImplementation();
|
|
726
|
+
if (!send)
|
|
727
|
+
throw new Error('Missing test send');
|
|
728
|
+
link.send.mockImplementation(async (address, payload) => {
|
|
729
|
+
await send(address, payload);
|
|
730
|
+
const fan = link.fans.get(address);
|
|
731
|
+
if (fan)
|
|
732
|
+
fan.highTemperature = true;
|
|
733
|
+
});
|
|
734
|
+
await expect(new VerifiedController(config, link).fan('back', 'medium')).rejects.toThrow('Thermal protection');
|
|
735
|
+
expect(link.send.mock.calls.map(([, payload]) => payload[9])).toEqual([0x89]);
|
|
736
|
+
});
|
|
737
|
+
it('recovers genuine mode mismatches to Smart without substituting success', async () => {
|
|
738
|
+
const link = fakeLink();
|
|
739
|
+
const send = link.send.getMockImplementation();
|
|
740
|
+
if (!send)
|
|
741
|
+
throw new Error('Missing test send');
|
|
742
|
+
link.send.mockImplementation(async (address, payload) => {
|
|
743
|
+
await send(address, payload);
|
|
744
|
+
const fan = link.fans.get(address);
|
|
745
|
+
if (fan && payload[8] === 5)
|
|
746
|
+
fan.mode = 4;
|
|
747
|
+
});
|
|
748
|
+
await expect(new VerifiedController(config, link).fan('desk', 'medium')).rejects.toThrow('Smart automatic cooling is confirmed');
|
|
749
|
+
expect(link.fans.get(6)?.mode).toBe(1);
|
|
750
|
+
expect(link.send.mock.calls.map(([, payload]) => payload[8])).toEqual([5, 1]);
|
|
751
|
+
});
|
|
752
|
+
it('does not run Smart recovery if a subsequent read discovers thermal protection', async () => {
|
|
753
|
+
const link = fakeLink();
|
|
754
|
+
const fan = link.fans.get(6);
|
|
755
|
+
if (!fan)
|
|
756
|
+
throw new Error('Missing test fan');
|
|
757
|
+
link.send.mockImplementation(async () => {
|
|
758
|
+
fan.mode = 4;
|
|
759
|
+
});
|
|
760
|
+
let reads = 0;
|
|
761
|
+
link.readFan.mockImplementation(async () => ({ ...fan, highTemperature: ++reads >= 8 }));
|
|
762
|
+
await expect(new VerifiedController(config, link).fan('desk', 'medium')).rejects.toThrow('Thermal protection');
|
|
763
|
+
expect(link.send).toHaveBeenCalledOnce();
|
|
764
|
+
});
|
|
765
|
+
it('reconnects fan reads and rejects cancelled or empty operations before mutation', async () => {
|
|
766
|
+
const link = fakeLink();
|
|
767
|
+
const controller = new VerifiedController(config, link);
|
|
768
|
+
link.readFan.mockRejectedValueOnce(new Error('disconnected'));
|
|
769
|
+
await expect(controller.fan('back')).resolves.toMatchObject({ mode: 1, rpmStatus: 'rotation-reported' });
|
|
770
|
+
expect(link.connect).toHaveBeenCalledOnce();
|
|
771
|
+
await expect(controller.fans([], 'medium')).rejects.toThrow('nonempty');
|
|
772
|
+
await expect(controller.fans(['back', 'back'], 'medium')).rejects.toThrow('unique');
|
|
773
|
+
const cancel = new AbortController();
|
|
774
|
+
cancel.abort();
|
|
775
|
+
await expect(controller.fans(['back'], 'medium', cancel.signal)).rejects.toThrow();
|
|
776
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
777
|
+
});
|
|
778
|
+
it('reports failed cooling recovery without touching power or intensity', async () => {
|
|
779
|
+
const link = fakeLink();
|
|
780
|
+
const fan = link.fans.get(6);
|
|
781
|
+
if (!fan)
|
|
782
|
+
throw new Error('Missing test fan');
|
|
783
|
+
link.send.mockImplementation(async () => {
|
|
784
|
+
fan.mode = 4;
|
|
785
|
+
});
|
|
786
|
+
await expect(new VerifiedController(config, link).fan('desk', 'medium')).rejects.toThrow('recovery could not be verified');
|
|
787
|
+
expect(link.send.mock.calls.map(([, payload]) => payload[9])).toEqual([0x89, 0x89]);
|
|
788
|
+
});
|
|
789
|
+
it('cancels remaining fan targets and only performs safe recovery after an in-flight write', async () => {
|
|
790
|
+
const link = fakeLink();
|
|
791
|
+
const send = link.send.getMockImplementation();
|
|
792
|
+
if (!send)
|
|
793
|
+
throw new Error('Missing test send');
|
|
794
|
+
const cancel = new AbortController();
|
|
795
|
+
link.send.mockImplementation(async (address, payload) => {
|
|
796
|
+
await send(address, payload);
|
|
797
|
+
if (payload[8] === 5)
|
|
798
|
+
cancel.abort();
|
|
799
|
+
});
|
|
800
|
+
await expect(new VerifiedController(config, link).fans(['desk', 'back'], 'medium', cancel.signal)).rejects.toThrow('Fan batch stopped');
|
|
801
|
+
expect(link.send.mock.calls.map(([address, payload]) => [address, payload[8]])).toEqual([
|
|
802
|
+
[6, 5],
|
|
803
|
+
[6, 1],
|
|
804
|
+
]);
|
|
805
|
+
expect(link.fans.get(10)?.mode).toBe(1);
|
|
806
|
+
});
|
|
807
|
+
it('rejects malformed fan telemetry instead of replacing it with zero RPM', async () => {
|
|
808
|
+
const link = fakeLink();
|
|
809
|
+
const fan = link.fans.get(10);
|
|
810
|
+
if (!fan)
|
|
811
|
+
throw new Error('Missing test fan');
|
|
812
|
+
link.readFan.mockResolvedValue({ ...fan, speed: -1 });
|
|
813
|
+
await expect(new VerifiedController(config, link).fan('back', 'medium')).rejects.toThrow();
|
|
814
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
815
|
+
});
|
|
816
|
+
it('rejects unsupported effect variants and meaningless parameters before traffic', async () => {
|
|
817
|
+
const link = fakeLink();
|
|
818
|
+
const controller = new VerifiedController(config, link);
|
|
819
|
+
for (const args of [
|
|
820
|
+
{ name: 'strobe' },
|
|
821
|
+
{ name: 'fire', kelvin: 3200 },
|
|
822
|
+
{ name: 'fire', speed: 2 },
|
|
823
|
+
{ name: 'pulsing', hue: 120, saturation: 100, gm: 0 },
|
|
824
|
+
]) {
|
|
825
|
+
await expect(controller.execute('back', 'effect', args)).rejects.toThrow();
|
|
826
|
+
}
|
|
827
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
828
|
+
});
|
|
829
|
+
it('fades at bounded cadence, preserves power, and cancels before more writes', async () => {
|
|
830
|
+
const link = fakeLink();
|
|
831
|
+
const controller = new VerifiedController(config, link);
|
|
832
|
+
await expect(controller.fade(['desk', 'back'], 2, 0.5)).resolves.toMatchObject({
|
|
833
|
+
states: { desk: { intensity: 20, sleep: true }, back: { intensity: 20, sleep: true } },
|
|
834
|
+
});
|
|
835
|
+
const cancel = new AbortController();
|
|
836
|
+
const operation = controller.fade(['desk'], 5, 1, cancel.signal);
|
|
837
|
+
setTimeout(() => cancel.abort(), 20);
|
|
838
|
+
await expect(operation).rejects.toThrow('intermediate brightness');
|
|
839
|
+
expect(link.state.intensity).toBe(20);
|
|
840
|
+
expect(link.send).toHaveBeenCalledTimes(2);
|
|
841
|
+
});
|
|
842
|
+
it('persists local libraries and rejects incompatible preset recalls before changing any fixture', async () => {
|
|
843
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'amaran-library-test-'));
|
|
844
|
+
dirs.push(directory);
|
|
845
|
+
const library = new LocalLibrary(directory);
|
|
846
|
+
const group = library.createGroup('Work');
|
|
847
|
+
library.updateGroup(group.id, 'desk', false);
|
|
848
|
+
library.save('scenes', 'Evening', { desk: initial, back: initial });
|
|
849
|
+
const loaded = new LocalLibrary(directory);
|
|
850
|
+
expect(loaded.group('work').members).toEqual(['desk']);
|
|
851
|
+
expect(loaded.find('scenes', 'evening').states.back.gm).toBe(0);
|
|
852
|
+
expect(() => loaded.save('presets', 'Bad', { desk: initial, back: initial })).toThrow('exactly one');
|
|
853
|
+
const link = fakeLink();
|
|
854
|
+
const controller = new VerifiedController(config, link);
|
|
855
|
+
await expect(controller.restore({ back: initial, desk: { ...initial, mode: 'hsi', hue: 120, sat: 100 } })).rejects.toThrow('does not support');
|
|
856
|
+
await expect(controller.restore({ desk: { ...initial, gm: 30 } })).rejects.toThrow('non-neutral G/M');
|
|
857
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
858
|
+
expect((await controller.snapshot()).desk.gm).toBeUndefined();
|
|
859
|
+
loaded.delete('scenes', 'Evening');
|
|
860
|
+
expect(new LocalLibrary(directory).list('scenes')).toEqual([]);
|
|
861
|
+
});
|
|
862
|
+
it('validates model capabilities before sending anything', async () => {
|
|
863
|
+
const link = fakeLink();
|
|
864
|
+
const controller = new VerifiedController(config, link);
|
|
865
|
+
expect(capabilities(config.lights[0])).toMatchObject({ hsi_support: false, cct_min: 2700, cct_max: 6500 });
|
|
866
|
+
await expect(controller.execute('desk', 'hsi', { hue: 0, saturation: 100, brightness: 1 })).rejects.toThrow('does not support');
|
|
867
|
+
await expect(controller.execute('desk', 'cct', { kelvin: 7500 })).rejects.toThrow('6500');
|
|
868
|
+
expect(() => validateAction(config.lights[0], 'cct', { kelvin: 3200, gm: 50 })).toThrow('does not support G/M');
|
|
869
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
870
|
+
expect(link.readState).not.toHaveBeenCalled();
|
|
871
|
+
});
|
|
872
|
+
it('preserves actual brightness when CCT omits brightness', async () => {
|
|
873
|
+
const link = fakeLink();
|
|
874
|
+
const controller = new VerifiedController(config, link);
|
|
875
|
+
await expect(controller.execute('desk', 'cct', { kelvin: 5600 })).resolves.toMatchObject({
|
|
876
|
+
intensity: 10,
|
|
877
|
+
sleep: false,
|
|
878
|
+
cct: 5600,
|
|
879
|
+
});
|
|
880
|
+
expect(link.readState).toHaveBeenCalledTimes(2);
|
|
881
|
+
});
|
|
882
|
+
it('quantizes fractional requests to the fixtures whole-percent dimming resolution', async () => {
|
|
883
|
+
const link = fakeLink();
|
|
884
|
+
const controller = new VerifiedController(config, link);
|
|
885
|
+
await expect(controller.execute('desk', 'brightness', { value: 1.2 })).resolves.toMatchObject({ intensity: 10 });
|
|
886
|
+
await expect(controller.execute('back', 'cct', { kelvin: 4542, brightness: 1.8 })).resolves.toMatchObject({
|
|
887
|
+
intensity: 20,
|
|
888
|
+
cct: 4500,
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
it('serializes concurrent commands and toggles against fresh state', async () => {
|
|
892
|
+
const link = fakeLink();
|
|
893
|
+
const controller = new VerifiedController(config, link);
|
|
894
|
+
const results = await Promise.all([
|
|
895
|
+
controller.execute('desk', 'toggle', {}),
|
|
896
|
+
controller.execute('desk', 'toggle', {}),
|
|
897
|
+
]);
|
|
898
|
+
expect(results.map((state) => state.sleep)).toEqual([false, true]);
|
|
899
|
+
});
|
|
900
|
+
it('reconnects after a failed state read', async () => {
|
|
901
|
+
const link = fakeLink();
|
|
902
|
+
vi.mocked(link.readState).mockRejectedValueOnce(new Error('disconnected'));
|
|
903
|
+
await expect(new VerifiedController(config, link).execute('desk', 'state', {})).resolves.toMatchObject({
|
|
904
|
+
...initial,
|
|
905
|
+
observedAt: expect.any(String),
|
|
906
|
+
});
|
|
907
|
+
expect(link.connect).toHaveBeenCalledOnce();
|
|
908
|
+
});
|
|
909
|
+
it('never reports success when writes do not change the actual fixture', async () => {
|
|
910
|
+
const link = fakeLink();
|
|
911
|
+
vi.mocked(link.send).mockResolvedValue(undefined);
|
|
912
|
+
await expect(new VerifiedController(config, link).execute('desk', 'on', {})).rejects.toThrow('Unable to verify');
|
|
913
|
+
expect(link.send).toHaveBeenCalledTimes(3);
|
|
914
|
+
});
|
|
915
|
+
it('does not apply requests cancelled before execution', async () => {
|
|
916
|
+
const link = fakeLink();
|
|
917
|
+
const cancel = new AbortController();
|
|
918
|
+
cancel.abort();
|
|
919
|
+
await expect(new VerifiedController(config, link).execute('desk', 'on', {}, cancel.signal)).rejects.toThrow();
|
|
920
|
+
expect(link.send).not.toHaveBeenCalled();
|
|
921
|
+
});
|
|
922
|
+
it('serves truthful state and model capabilities through the existing CLI client', async () => {
|
|
923
|
+
const link = fakeLink();
|
|
924
|
+
const circadianStatus = {
|
|
925
|
+
generatedAt: '2026-09-13T22:30:00.000Z',
|
|
926
|
+
service: {
|
|
927
|
+
installed: true,
|
|
928
|
+
loaded: true,
|
|
929
|
+
active: true,
|
|
930
|
+
healthy: true,
|
|
931
|
+
intervalSeconds: 60,
|
|
932
|
+
curve: 'cie-daylight',
|
|
933
|
+
weatherConfigured: false,
|
|
934
|
+
lastRunAt: '2026-09-13T22:29:38.000Z',
|
|
935
|
+
lastTarget: { cct: 6002, intensity: 25 },
|
|
936
|
+
},
|
|
937
|
+
settings: {
|
|
938
|
+
enabled: true,
|
|
939
|
+
intervalSeconds: 60,
|
|
940
|
+
curve: 'cie-daylight',
|
|
941
|
+
weather: false,
|
|
942
|
+
cctMin: 1700,
|
|
943
|
+
cctMax: 6500,
|
|
944
|
+
intensityMin: 5,
|
|
945
|
+
intensityMax: 25,
|
|
946
|
+
},
|
|
947
|
+
current: {
|
|
948
|
+
time: '2026-09-13T22:30:00.000Z',
|
|
949
|
+
cct: 6000,
|
|
950
|
+
intensity: 25,
|
|
951
|
+
curve: 'cie-daylight',
|
|
952
|
+
weatherActive: false,
|
|
953
|
+
weatherSource: 'none',
|
|
954
|
+
},
|
|
955
|
+
schedule: {
|
|
956
|
+
date: '2026-09-13',
|
|
957
|
+
timeZone: 'America/Los_Angeles',
|
|
958
|
+
intervalMinutes: 15,
|
|
959
|
+
intensityLimit: 25,
|
|
960
|
+
points: [
|
|
961
|
+
{
|
|
962
|
+
time: '2026-09-13T07:00:00.000Z',
|
|
963
|
+
cct: 2000,
|
|
964
|
+
intensity: 5,
|
|
965
|
+
appliedIntensity: 5,
|
|
966
|
+
sunlightLux: 0,
|
|
967
|
+
systemCapacityLux: 9500,
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
time: '2026-09-14T07:00:00.000Z',
|
|
971
|
+
cct: 2000,
|
|
972
|
+
intensity: 5,
|
|
973
|
+
appliedIntensity: 5,
|
|
974
|
+
sunlightLux: 0,
|
|
975
|
+
systemCapacityLux: 9500,
|
|
976
|
+
},
|
|
977
|
+
],
|
|
978
|
+
},
|
|
979
|
+
};
|
|
980
|
+
const updateCircadianSettings = vi.fn(async () => circadianStatus);
|
|
981
|
+
const server = createBleServer(new VerifiedController(config, link), undefined, {
|
|
982
|
+
circadianStatus: async () => circadianStatus,
|
|
983
|
+
updateCircadianSettings,
|
|
984
|
+
});
|
|
985
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
986
|
+
const address = server.address();
|
|
987
|
+
if (!address || typeof address === 'string')
|
|
988
|
+
throw new Error('Missing server address');
|
|
989
|
+
try {
|
|
990
|
+
const url = `http://127.0.0.1:${address.port}`;
|
|
991
|
+
const client = await BleHttpController.connect(url);
|
|
992
|
+
await expect(commandCallbackResult((callback) => client.saveScene('Baseline', callback))).resolves.toMatchObject({
|
|
993
|
+
data: { name: 'Baseline', states: { back: { gm: 0 } } },
|
|
994
|
+
});
|
|
995
|
+
await commandCallbackResult((callback) => client.createGroup('Work', callback));
|
|
996
|
+
await commandCallbackResult((callback) => client.addToGroup('Work', 'desk', callback));
|
|
997
|
+
const withGroups = await BleHttpController.connect(url);
|
|
998
|
+
expect(getLightDevices(withGroups.getDevices())).toHaveLength(2);
|
|
999
|
+
const group = withGroups.getDevices().find((device) => device.device_type === 'ble-group');
|
|
1000
|
+
if (!group?.node_id)
|
|
1001
|
+
throw new Error('Missing logical group');
|
|
1002
|
+
await commandCallbackResult((callback) => withGroups.setIntensity(group.node_id, 20, callback));
|
|
1003
|
+
expect(link.state.intensity).toBe(20);
|
|
1004
|
+
expect(link.states.get(10)?.intensity).toBe(10);
|
|
1005
|
+
await expect(commandCallbackResult((callback) => withGroups.getNodeConfig(group.node_id, callback))).resolves.toMatchObject({
|
|
1006
|
+
data: { work_mode: 'group', member_states: { desk: { intensity: 20, cct: 3200 } } },
|
|
1007
|
+
});
|
|
1008
|
+
await commandCallbackResult((callback) => client.recallScene('Baseline', callback));
|
|
1009
|
+
expect(link.state.intensity).toBe(10);
|
|
1010
|
+
await expect(commandCallbackResult((callback) => client.setHSI('back', 120, 100, 10, 3200, 0, callback))).rejects.toThrow('basic HSI');
|
|
1011
|
+
await expect(commandCallbackResult((callback) => client.getFanInfo('back', callback))).resolves.toMatchObject({
|
|
1012
|
+
mode: 1,
|
|
1013
|
+
temperature: 35,
|
|
1014
|
+
});
|
|
1015
|
+
await expect(commandCallbackResult((callback) => withGroups.fanStates([group.node_id, 'desk'], 'medium', callback))).resolves.toMatchObject({
|
|
1016
|
+
states: { desk: { mode: 5, modeName: 'medium' } },
|
|
1017
|
+
});
|
|
1018
|
+
expect(link.fans.get(10)?.mode).toBe(1);
|
|
1019
|
+
await expect(commandCallbackResult((callback) => withGroups.getFanInfo(group.node_id, callback))).resolves.toMatchObject({ states: { desk: { mode: 5 } } });
|
|
1020
|
+
await expect(commandCallbackResult((callback) => withGroups.setFanMode(group.node_id, 1, callback))).resolves.toMatchObject({ states: { desk: { mode: 1 } } });
|
|
1021
|
+
await expect(commandCallbackResult((callback) => withGroups.getFanSpeed(group.node_id, callback))).rejects.toThrow('separate fan states');
|
|
1022
|
+
const fanProgram = new Command();
|
|
1023
|
+
registerFan(fanProgram, {
|
|
1024
|
+
createController: async () => withGroups,
|
|
1025
|
+
findDevice: (controller, query) => controller.getDevices().find((device) => device.node_id === query) ?? null,
|
|
1026
|
+
asyncCommand: (fn) => fn,
|
|
1027
|
+
});
|
|
1028
|
+
const fanOutput = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
1029
|
+
await fanProgram.parseAsync(['node', 'test', 'fan', 'mode', 'all', 'medium', '--backend', 'ble', '--json']);
|
|
1030
|
+
const fanJson = JSON.parse(String(fanOutput.mock.calls.at(-1)?.[0]));
|
|
1031
|
+
expect(fanJson.states).toMatchObject({ desk: { mode: 5 }, back: { mode: 5 } });
|
|
1032
|
+
await fanProgram.parseAsync(['node', 'test', 'fan', 'info', '--backend', 'ble', '--json']);
|
|
1033
|
+
expect(JSON.parse(String(fanOutput.mock.calls.at(-1)?.[0])).states.back.allowedModes).toEqual([
|
|
1034
|
+
'smart',
|
|
1035
|
+
'medium',
|
|
1036
|
+
]);
|
|
1037
|
+
fanOutput.mockRestore();
|
|
1038
|
+
const allFans = await (await fetch(`${url}/fans`)).json();
|
|
1039
|
+
expect(allFans).toMatchObject({
|
|
1040
|
+
ok: true,
|
|
1041
|
+
verified: true,
|
|
1042
|
+
result: { states: { desk: { mode: 5 }, back: { mode: 5 } } },
|
|
1043
|
+
});
|
|
1044
|
+
const groupFans = await (await fetch(`${url}/lights/${encodeURIComponent(group.node_id)}/fan`)).json();
|
|
1045
|
+
expect(Object.keys(groupFans.result.states)).toEqual(['desk']);
|
|
1046
|
+
const badFans = await fetch(`${url}/fans`, { method: 'POST', body: JSON.stringify({ targets: [] }) });
|
|
1047
|
+
expect(badFans.status).toBe(400);
|
|
1048
|
+
const data = await new Promise((resolve, reject) => client.getNodeConfig('desk', (ok, message, result) => (ok ? resolve(result) : reject(new Error(message)))));
|
|
1049
|
+
expect(data).toMatchObject({ data: { sleep: true, intensity: 10, cct: 3200, hsi_support: false } });
|
|
1050
|
+
await new Promise((resolve, reject) => client.setCCT('desk', 5600, undefined, (ok, message) => (ok ? resolve() : reject(new Error(message)))));
|
|
1051
|
+
expect(link.state).toMatchObject({ cct: 5600, intensity: 10, sleep: false });
|
|
1052
|
+
const bad = await fetch(`${url}/lights/desk/hsi`, {
|
|
1053
|
+
method: 'POST',
|
|
1054
|
+
body: JSON.stringify({ hue: 0, saturation: 100, brightness: 1 }),
|
|
1055
|
+
});
|
|
1056
|
+
expect(bad.status).toBe(400);
|
|
1057
|
+
expect(await bad.json()).toMatchObject({ ok: false });
|
|
1058
|
+
expect((await fetch(url, { headers: { origin: 'https://example.com' } })).status).toBe(403);
|
|
1059
|
+
const dashboard = await fetch(`${url}/dashboard`);
|
|
1060
|
+
expect(dashboard.headers.get('content-security-policy')).toContain("default-src 'self'");
|
|
1061
|
+
const dashboardMarkup = (await dashboard.text()).toLowerCase();
|
|
1062
|
+
expect(dashboardMarkup).toContain('direct bluetooth mesh');
|
|
1063
|
+
expect(dashboardMarkup).toContain('circadian service');
|
|
1064
|
+
expect(dashboardMarkup).toContain('hover or slide over the graph');
|
|
1065
|
+
expect(dashboardMarkup).toContain('href="/favicon.svg"');
|
|
1066
|
+
const favicon = await fetch(`${url}/favicon.svg`);
|
|
1067
|
+
expect(favicon.headers.get('content-type')).toContain('image/svg+xml');
|
|
1068
|
+
expect(await favicon.text()).toContain('<svg');
|
|
1069
|
+
expect((await fetch(`${url}/dashboard/settings`, {
|
|
1070
|
+
method: 'POST',
|
|
1071
|
+
headers: { origin: url },
|
|
1072
|
+
body: JSON.stringify({ title: 'Studio lights' }),
|
|
1073
|
+
})).status).toBe(200);
|
|
1074
|
+
const dashboardStatus = await (await fetch(`${url}/dashboard/status`)).json();
|
|
1075
|
+
expect(dashboardStatus).toMatchObject({
|
|
1076
|
+
ok: true,
|
|
1077
|
+
verified: true,
|
|
1078
|
+
result: { connected: true, lighting: { desk: {}, back: {} }, fans: { desk: {}, back: {} } },
|
|
1079
|
+
});
|
|
1080
|
+
expect(await (await fetch(`${url}/dashboard/status-cache`)).json()).toMatchObject({
|
|
1081
|
+
ok: true,
|
|
1082
|
+
result: dashboardStatus.result,
|
|
1083
|
+
});
|
|
1084
|
+
const circadianStatus = await (await fetch(`${url}/dashboard/circadian`)).json();
|
|
1085
|
+
expect(circadianStatus).toMatchObject({
|
|
1086
|
+
ok: true,
|
|
1087
|
+
result: {
|
|
1088
|
+
service: { active: true, lastTarget: { cct: 6002, intensity: 25 } },
|
|
1089
|
+
current: { weatherActive: false },
|
|
1090
|
+
schedule: { intervalMinutes: 15, intensityLimit: 25 },
|
|
1091
|
+
},
|
|
1092
|
+
});
|
|
1093
|
+
expect(circadianStatus.result.schedule.points[0]).toMatchObject({
|
|
1094
|
+
sunlightLux: 0,
|
|
1095
|
+
systemCapacityLux: 9500,
|
|
1096
|
+
});
|
|
1097
|
+
const updatedCircadianStatus = await (await fetch(`${url}/dashboard/circadian/settings`, {
|
|
1098
|
+
method: 'POST',
|
|
1099
|
+
headers: { 'content-type': 'application/json' },
|
|
1100
|
+
body: JSON.stringify({ curve: 'physics', weather: true }),
|
|
1101
|
+
})).json();
|
|
1102
|
+
expect(updatedCircadianStatus).toMatchObject({ ok: true, result: { settings: { curve: 'cie-daylight' } } });
|
|
1103
|
+
expect(updateCircadianSettings).toHaveBeenCalledWith({ curve: 'physics', weather: true });
|
|
1104
|
+
const program = new Command();
|
|
1105
|
+
registerCct(program, {
|
|
1106
|
+
createController: async () => client,
|
|
1107
|
+
findDevice: (controller, query) => controller.getDevices().find((device) => device.node_id === query) ?? null,
|
|
1108
|
+
asyncCommand: (fn) => fn,
|
|
1109
|
+
});
|
|
1110
|
+
const previousExit = process.exitCode;
|
|
1111
|
+
try {
|
|
1112
|
+
await program.parseAsync(['node', 'test', 'cct', '7500', '--backend', 'ble']);
|
|
1113
|
+
expect(process.exitCode).toBe(1);
|
|
1114
|
+
}
|
|
1115
|
+
finally {
|
|
1116
|
+
process.exitCode = previousExit;
|
|
1117
|
+
}
|
|
1118
|
+
const output = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
1119
|
+
await program.parseAsync(['node', 'test', 'cct', '4542', 'desk', '--backend', 'ble']);
|
|
1120
|
+
expect(output).toHaveBeenCalledWith(expect.stringContaining('4500K (rounded from 4542K)'));
|
|
1121
|
+
}
|
|
1122
|
+
finally {
|
|
1123
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
it('wires extended modes and manual RPM through fixture/group/all APIs and the CLI', async () => {
|
|
1127
|
+
const link = fakeLink();
|
|
1128
|
+
for (const fan of link.fans.values())
|
|
1129
|
+
for (const name of Object.keys(FAN_MODES))
|
|
1130
|
+
fan.supported[parseFanMode(name)] = true;
|
|
1131
|
+
const library = new LocalLibrary();
|
|
1132
|
+
const group = library.createGroup('Fans');
|
|
1133
|
+
for (const key of ['desk', 'back'])
|
|
1134
|
+
library.updateGroup(group.id, key, false);
|
|
1135
|
+
const server = createBleServer(new VerifiedController(config, link), library);
|
|
1136
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
1137
|
+
const address = server.address();
|
|
1138
|
+
if (!address || typeof address === 'string')
|
|
1139
|
+
throw new Error('Missing server address');
|
|
1140
|
+
try {
|
|
1141
|
+
const url = `http://127.0.0.1:${address.port}`;
|
|
1142
|
+
const client = await BleHttpController.connect(url);
|
|
1143
|
+
await commandCallbackResult((callback) => client.fanStates([group.id], 'silent', callback));
|
|
1144
|
+
await expect(commandCallbackResult((callback) => client.setFanSpeed('back', 2200, callback))).resolves.toMatchObject({ states: { back: { mode: 0, speed: 2200 } } });
|
|
1145
|
+
const posted = await fetch(`${url}/lights/desk/fan`, {
|
|
1146
|
+
method: 'POST',
|
|
1147
|
+
body: JSON.stringify({ mode: 0, rpm: 1800 }),
|
|
1148
|
+
});
|
|
1149
|
+
expect(await posted.json()).toMatchObject({ ok: true, verified: true, result: { mode: 0, speed: 1800 } });
|
|
1150
|
+
const program = new Command();
|
|
1151
|
+
registerFan(program, {
|
|
1152
|
+
createController: async () => client,
|
|
1153
|
+
findDevice: (controller, key) => controller.getDevices().find((device) => device.node_id === key) ?? null,
|
|
1154
|
+
asyncCommand: (fn) => fn,
|
|
1155
|
+
});
|
|
1156
|
+
const output = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
1157
|
+
await program.parseAsync([
|
|
1158
|
+
'node',
|
|
1159
|
+
'test',
|
|
1160
|
+
'fan',
|
|
1161
|
+
'mode',
|
|
1162
|
+
group.id,
|
|
1163
|
+
'manual',
|
|
1164
|
+
'--rpm',
|
|
1165
|
+
'2200',
|
|
1166
|
+
'--json',
|
|
1167
|
+
'--backend',
|
|
1168
|
+
'ble',
|
|
1169
|
+
]);
|
|
1170
|
+
expect(JSON.parse(String(output.mock.calls.at(-1)?.[0])).states).toMatchObject({
|
|
1171
|
+
desk: { mode: 0, speed: 2200 },
|
|
1172
|
+
back: { mode: 0, speed: 2200 },
|
|
1173
|
+
});
|
|
1174
|
+
await program.parseAsync(['node', 'test', 'fan', 'speed', 'all', '2400', '--json', '--backend', 'ble']);
|
|
1175
|
+
expect(JSON.parse(String(output.mock.calls.at(-1)?.[0])).states).toMatchObject({
|
|
1176
|
+
desk: { mode: 0, speed: 2400 },
|
|
1177
|
+
back: { mode: 0, speed: 2400 },
|
|
1178
|
+
});
|
|
1179
|
+
await expect(program.parseAsync(['node', 'test', 'fan', 'mode', 'back', 'manual', '--backend', 'ble'])).rejects.toThrow('explicit RPM');
|
|
1180
|
+
expect(link.send.mock.calls.every(([, payload]) => payload[9] === 0x89)).toBe(true);
|
|
1181
|
+
}
|
|
1182
|
+
finally {
|
|
1183
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
it('exposes overrides, product reads and persistent library updates through the client', async () => {
|
|
1187
|
+
const link = fakeLink();
|
|
1188
|
+
Object.assign(link, {
|
|
1189
|
+
readProductInfo: vi.fn(async () => ({
|
|
1190
|
+
driverSoftware: 0,
|
|
1191
|
+
driverHardware: 12,
|
|
1192
|
+
controllerSoftware: 11,
|
|
1193
|
+
controllerHardware: 13,
|
|
1194
|
+
cctMin: 2700,
|
|
1195
|
+
cctMax: 6500,
|
|
1196
|
+
protocolVersion: 39,
|
|
1197
|
+
effects: { manual: true, music: true, picker: true, program: true, touchbar: true },
|
|
1198
|
+
})),
|
|
1199
|
+
});
|
|
1200
|
+
const library = new LocalLibrary();
|
|
1201
|
+
const server = createBleServer(new VerifiedController(config, link, library), library);
|
|
1202
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
1203
|
+
const address = server.address();
|
|
1204
|
+
if (!address || typeof address === 'string')
|
|
1205
|
+
throw new Error('No test address');
|
|
1206
|
+
try {
|
|
1207
|
+
const client = await BleHttpController.connect(`http://127.0.0.1:${address.port}`);
|
|
1208
|
+
await expect(commandCallbackResult((cb) => client.getProductInfo('desk', cb))).resolves.toMatchObject({
|
|
1209
|
+
data: { controllerSoftware: 11, protocolVersion: 39 },
|
|
1210
|
+
});
|
|
1211
|
+
await commandCallbackResult((cb) => client.setCCT('back', 3200, 10, cb));
|
|
1212
|
+
await expect(commandCallbackResult((cb) => client.setAutomaticCCT('back', 5600, 10, cb))).resolves.toMatchObject({
|
|
1213
|
+
skipped: true,
|
|
1214
|
+
});
|
|
1215
|
+
await commandCallbackResult((cb) => client.overrides(['back'], 0, cb));
|
|
1216
|
+
await expect(commandCallbackResult((cb) => client.setAutomaticCCT('back', 5600, 10, cb))).resolves.toMatchObject({
|
|
1217
|
+
skipped: false,
|
|
1218
|
+
});
|
|
1219
|
+
await commandCallbackResult((cb) => client.createGroup('Before', cb));
|
|
1220
|
+
await commandCallbackResult((cb) => client.addToGroup('Before', 'back', cb));
|
|
1221
|
+
await commandCallbackResult((cb) => client.renameGroup('Before', 'After', cb));
|
|
1222
|
+
expect(library.group('After').members).toEqual(['back']);
|
|
1223
|
+
await commandCallbackResult((cb) => client.saveSaved('scenes', 'Back only', [library.group('After').id], cb));
|
|
1224
|
+
expect(Object.keys(library.find('scenes', 'Back only').states)).toEqual(['back']);
|
|
1225
|
+
await commandCallbackResult((cb) => client.replaceSaved('scenes', 'Back only', 'All now', 'all', cb));
|
|
1226
|
+
expect(Object.keys(library.find('scenes', 'All now').states)).toEqual(['desk', 'back']);
|
|
1227
|
+
await commandCallbackResult((cb) => client.savePreset('back', 'Old preset', cb));
|
|
1228
|
+
await commandCallbackResult((cb) => client.updateSaved('presets', 'Old preset', 'New preset', cb));
|
|
1229
|
+
expect(library.find('presets', 'New preset').states.back.fan).toEqual({ mode: 'smart' });
|
|
1230
|
+
await commandCallbackResult((cb) => client.saveQuickshot('Old quickshot', cb));
|
|
1231
|
+
await commandCallbackResult((cb) => client.updateSaved('quickshots', 'Old quickshot', 'New quickshot', cb));
|
|
1232
|
+
expect(library.find('quickshots', 'New quickshot').states.back.cct).toBe(5600);
|
|
1233
|
+
}
|
|
1234
|
+
finally {
|
|
1235
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1238
|
+
});
|
|
1239
|
+
//# sourceMappingURL=bleStack.test.js.map
|