amaran-light-cli 1.10.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 +154 -17
- package/dist/__tests__/autoCct.test.js +35 -4
- package/dist/__tests__/autoCct.test.js.map +1 -1
- package/dist/__tests__/autoCctMaxLux.test.js +11 -9
- package/dist/__tests__/autoCctMaxLux.test.js.map +1 -1
- package/dist/__tests__/autostart.test.js +18 -0
- package/dist/__tests__/autostart.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 +11 -5
- 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.d.ts +2 -0
- package/dist/__tests__/cli.config.test.d.ts.map +1 -0
- package/dist/__tests__/cli.config.test.js +114 -0
- package/dist/__tests__/cli.config.test.js.map +1 -0
- package/dist/__tests__/cli.smoke.test.js +70 -17
- 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__/maxLuxInterpolation.test.js +4 -2
- package/dist/__tests__/maxLuxInterpolation.test.js.map +1 -1
- 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__/packageExports.test.d.ts +2 -0
- package/dist/__tests__/packageExports.test.d.ts.map +1 -0
- package/dist/__tests__/packageExports.test.js +33 -0
- package/dist/__tests__/packageExports.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/__tests__/simulateSchedule.test.js +7 -3
- package/dist/__tests__/simulateSchedule.test.js.map +1 -1
- 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 +19 -0
- package/dist/circadian.d.ts.map +1 -0
- package/dist/circadian.js +13 -0
- package/dist/circadian.js.map +1 -0
- package/dist/cli.d.ts +5 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +159 -33
- package/dist/cli.js.map +1 -1
- package/dist/commands/__tests__/integration.test.js +28 -0
- package/dist/commands/__tests__/integration.test.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 +8 -2
- package/dist/commands/cmdUtils.d.ts.map +1 -1
- package/dist/commands/cmdUtils.js +40 -2
- package/dist/commands/cmdUtils.js.map +1 -1
- package/dist/commands/daylightSimulation/autoCct.d.ts.map +1 -1
- package/dist/commands/daylightSimulation/autoCct.js +74 -172
- 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/graphSchedule.js +2 -0
- package/dist/commands/daylightSimulation/graphSchedule.js.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 +4 -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 +20 -10
- 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 +31 -13
- package/dist/commands/daylightSimulation/simulateSchedule.js.map +1 -1
- package/dist/commands/daylightSimulation/weather.d.ts.map +1 -1
- package/dist/commands/daylightSimulation/weather.js +19 -4
- package/dist/commands/daylightSimulation/weather.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 +47 -19
- 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 +16 -12
- 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 +94 -58
- 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 +6 -10
- 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 +38 -16
- 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 +21 -15
- 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 +19 -28
- 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 +29 -6
- package/dist/commands/deviceControl/status.js.map +1 -1
- package/dist/commands/parseUtils.d.ts +6 -0
- package/dist/commands/parseUtils.d.ts.map +1 -0
- package/dist/commands/parseUtils.js +32 -0
- package/dist/commands/parseUtils.js.map +1 -0
- package/dist/commands-api.d.ts +28 -0
- package/dist/commands-api.d.ts.map +1 -0
- package/dist/commands-api.js +27 -0
- package/dist/commands-api.js.map +1 -0
- 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 +165 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +125 -0
- package/dist/config.js.map +1 -0
- package/dist/daylightSimulation/cctUtil.d.ts.map +1 -1
- package/dist/daylightSimulation/cctUtil.js +23 -22
- 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/curves/realistic.d.ts +6 -6
- package/dist/daylightSimulation/curves/realistic.d.ts.map +1 -1
- package/dist/daylightSimulation/curves/realistic.js +36 -6
- package/dist/daylightSimulation/curves/realistic.js.map +1 -1
- 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/mathUtil.d.ts.map +1 -1
- package/dist/daylightSimulation/mathUtil.js +6 -2
- package/dist/daylightSimulation/mathUtil.js.map +1 -1
- 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 +6 -2
- package/dist/daylightSimulation/scheduleMaker.d.ts.map +1 -1
- package/dist/daylightSimulation/scheduleMaker.js +9 -11
- package/dist/daylightSimulation/scheduleMaker.js.map +1 -1
- package/dist/daylightSimulation/types.d.ts +15 -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/daylightSimulation/weatherUtil.d.ts.map +1 -1
- package/dist/daylightSimulation/weatherUtil.js +31 -0
- package/dist/daylightSimulation/weatherUtil.js.map +1 -1
- package/dist/device-control.d.ts +8 -0
- package/dist/device-control.d.ts.map +1 -0
- package/dist/device-control.js +8 -0
- package/dist/device-control.js.map +1 -0
- package/dist/deviceControl/autostart.d.ts.map +1 -1
- package/dist/deviceControl/autostart.js +15 -16
- package/dist/deviceControl/autostart.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 +9 -68
- package/dist/deviceControl/lightControl.d.ts.map +1 -1
- package/dist/deviceControl/lightControl.js +25 -255
- 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 +83 -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 +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- 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 +171 -0
- package/docs/DIRECT_BLE.md +359 -0
- package/package.json +39 -8
- 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,931 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { colorToHSI } from './colors.js';
|
|
3
|
+
import { readComposition } from './configuration.js';
|
|
4
|
+
import { desktopDeviceKeys } from './desktop.js';
|
|
5
|
+
import { COLOR_EFFECTS, effectName, effectPacket, TRIGGER_EFFECTS, WHITE_EFFECTS, } from './effects.js';
|
|
6
|
+
import { describeFan, FanSettingSchema, FanStateSchema } from './fan.js';
|
|
7
|
+
import { NativeGroups } from './nativeGroups.js';
|
|
8
|
+
import { brightnessPacket, cctPacket, FAN_MODES, fanPacket, hsiPacket, numberInRange, parseFanMode, powerPacket, validateFanRpm, } from './telink.js';
|
|
9
|
+
import { interpolateState } from './transitions.js';
|
|
10
|
+
export function capabilities(light) {
|
|
11
|
+
return {
|
|
12
|
+
cct_support: true,
|
|
13
|
+
cct_min: light.model === '150c' ? 2500 : 2700,
|
|
14
|
+
cct_max: light.model === '150c' ? 7500 : 6500,
|
|
15
|
+
cct_step: 100,
|
|
16
|
+
hsi_support: light.model === '150c',
|
|
17
|
+
rgb_support: false,
|
|
18
|
+
advanced_hsi_support: false,
|
|
19
|
+
gm_support: light.model === '150c',
|
|
20
|
+
gm_min: -100,
|
|
21
|
+
gm_max: 100,
|
|
22
|
+
gm_step: 10,
|
|
23
|
+
fan_support: true,
|
|
24
|
+
fan_policy: 'native-profile',
|
|
25
|
+
intensity_step: 10,
|
|
26
|
+
effects: light.model === '150c' ? COLOR_EFFECTS : WHITE_EFFECTS,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const ARGUMENTS = {
|
|
30
|
+
state: [],
|
|
31
|
+
on: [],
|
|
32
|
+
off: [],
|
|
33
|
+
toggle: [],
|
|
34
|
+
brightness: ['value'],
|
|
35
|
+
'increment-brightness': ['delta'],
|
|
36
|
+
'increment-cct': ['delta', 'brightness'],
|
|
37
|
+
cct: ['kelvin', 'brightness', 'gm'],
|
|
38
|
+
gm: ['value'],
|
|
39
|
+
hsi: ['hue', 'saturation', 'brightness'],
|
|
40
|
+
color: ['color', 'brightness'],
|
|
41
|
+
effect: ['name', 'brightness', 'frequency', 'speed', 'trigger', 'kelvin', 'gm', 'palette', 'hue', 'saturation'],
|
|
42
|
+
'effect-speed': ['value'],
|
|
43
|
+
'effect-animation-speed': ['value'],
|
|
44
|
+
'effect-intensity': ['value'],
|
|
45
|
+
'effect-stop': [],
|
|
46
|
+
'effect-trigger': [],
|
|
47
|
+
};
|
|
48
|
+
export function validateAction(light, action, body) {
|
|
49
|
+
if (!Object.hasOwn(ARGUMENTS, action))
|
|
50
|
+
throw new Error(`Unsupported BLE action: ${action}`);
|
|
51
|
+
for (const key of Object.keys(body))
|
|
52
|
+
if (!ARGUMENTS[action].includes(key))
|
|
53
|
+
throw new Error(`Unsupported ${action} argument: ${key}`);
|
|
54
|
+
const caps = capabilities(light);
|
|
55
|
+
if (action === 'brightness' || action === 'effect-intensity')
|
|
56
|
+
numberInRange(body.value, 'brightness', 0, 100);
|
|
57
|
+
if (action === 'increment-brightness')
|
|
58
|
+
numberInRange(body.delta, 'brightness delta', -100, 100);
|
|
59
|
+
if (action === 'increment-cct')
|
|
60
|
+
numberInRange(body.delta, 'CCT delta', -7500, 7500);
|
|
61
|
+
if (action === 'cct' || body.kelvin !== undefined)
|
|
62
|
+
numberInRange(body.kelvin, `${light.name} CCT`, caps.cct_min, caps.cct_max);
|
|
63
|
+
if (action === 'gm' || body.gm !== undefined) {
|
|
64
|
+
if (!caps.gm_support)
|
|
65
|
+
throw new Error(`${light.name} does not support G/M`);
|
|
66
|
+
numberInRange(action === 'gm' ? body.value : body.gm, 'G/M', -100, 100);
|
|
67
|
+
}
|
|
68
|
+
if (action === 'hsi' || action === 'color') {
|
|
69
|
+
if (!caps.hsi_support)
|
|
70
|
+
throw new Error(`${light.name} does not support HSI`);
|
|
71
|
+
if (action === 'color')
|
|
72
|
+
colorToHSI(body.color);
|
|
73
|
+
else {
|
|
74
|
+
numberInRange(body.hue, 'hue', 0, 360);
|
|
75
|
+
numberInRange(body.saturation, 'saturation', 0, 100);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (action === 'effect') {
|
|
79
|
+
const name = effectName(body.name);
|
|
80
|
+
if (!caps.effects.includes(name))
|
|
81
|
+
throw new Error(`${light.name} does not support ${name}`);
|
|
82
|
+
const tint = ['paparazzi', 'lightning', 'faulty-bulb', 'pulsing', 'strobe', 'explosion'].includes(name);
|
|
83
|
+
if (!tint && (body.kelvin !== undefined || body.gm !== undefined))
|
|
84
|
+
throw new Error(`${name} uses a palette, not CCT/G/M parameters`);
|
|
85
|
+
if (body.palette !== undefined && !['tv', 'fire', 'fireworks', 'cop-car'].includes(name))
|
|
86
|
+
throw new Error(`${name} has no palette parameter`);
|
|
87
|
+
if (body.hue !== undefined || (body.saturation !== undefined && name !== 'party-lights')) {
|
|
88
|
+
if (!caps.hsi_support || !['faulty-bulb', 'pulsing'].includes(name))
|
|
89
|
+
throw new Error(`${light.name} does not support HSI ${name}`);
|
|
90
|
+
numberInRange(body.hue, 'hue', 0, 360);
|
|
91
|
+
numberInRange(body.saturation, 'saturation', 0, 100);
|
|
92
|
+
if (body.kelvin !== undefined || body.gm !== undefined)
|
|
93
|
+
throw new Error('An HSI effect cannot also specify CCT/G/M');
|
|
94
|
+
}
|
|
95
|
+
if (body.saturation !== undefined)
|
|
96
|
+
numberInRange(body.saturation, 'saturation', 0, 100);
|
|
97
|
+
if (body.speed !== undefined) {
|
|
98
|
+
if (!['lightning', 'faulty-bulb', 'pulsing'].includes(name))
|
|
99
|
+
throw new Error(`${name} has no animation speed parameter`);
|
|
100
|
+
numberInRange(body.speed, 'animation speed', 0, 10);
|
|
101
|
+
}
|
|
102
|
+
if (body.trigger !== undefined) {
|
|
103
|
+
if (!TRIGGER_EFFECTS.includes(name))
|
|
104
|
+
throw new Error(`${name} has no trigger mode`);
|
|
105
|
+
numberInRange(body.trigger, 'trigger mode', 0, 2);
|
|
106
|
+
if (!Number.isInteger(body.trigger))
|
|
107
|
+
throw new Error('Trigger mode must be an integer');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (action === 'effect-speed')
|
|
111
|
+
numberInRange(body.value, 'effect frequency', 1, 10);
|
|
112
|
+
if (action === 'effect-animation-speed')
|
|
113
|
+
numberInRange(body.value, 'effect animation speed', 0, 10);
|
|
114
|
+
if (body.frequency !== undefined)
|
|
115
|
+
numberInRange(body.frequency, 'frequency', 1, 10);
|
|
116
|
+
if (body.palette !== undefined)
|
|
117
|
+
numberInRange(body.palette, 'palette', 0, 2);
|
|
118
|
+
if (body.brightness !== undefined)
|
|
119
|
+
numberInRange(body.brightness, 'brightness', 0, 100);
|
|
120
|
+
}
|
|
121
|
+
class FanThermalError extends Error {
|
|
122
|
+
constructor(light) {
|
|
123
|
+
super(`${light.name}: Thermal protection is active; allow the fixture to cool. No fan or light restart was attempted.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export class VerifiedController {
|
|
127
|
+
config;
|
|
128
|
+
link;
|
|
129
|
+
history;
|
|
130
|
+
queue = Promise.resolve();
|
|
131
|
+
queued = 0;
|
|
132
|
+
stopping = false;
|
|
133
|
+
steadyStates = new Map();
|
|
134
|
+
manualOverrides = new Map();
|
|
135
|
+
constructor(config, link, history) {
|
|
136
|
+
this.config = config;
|
|
137
|
+
this.link = link;
|
|
138
|
+
this.history = history;
|
|
139
|
+
}
|
|
140
|
+
async importDeviceKeys(database, persist) {
|
|
141
|
+
return this.serialize(async () => {
|
|
142
|
+
if (!this.link.configuration)
|
|
143
|
+
throw new Error('Native configuration transport is unavailable');
|
|
144
|
+
const candidate = desktopDeviceKeys(database, this.config);
|
|
145
|
+
const previous = this.config.lights;
|
|
146
|
+
this.config.lights = candidate.lights;
|
|
147
|
+
try {
|
|
148
|
+
const configuration = this.link.configuration.bind(this.link);
|
|
149
|
+
for (const light of candidate.lights)
|
|
150
|
+
await readComposition({ configuration }, light.address);
|
|
151
|
+
persist(this.config);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
this.config.lights = previous;
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
return candidate.lights.length;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async nativeGroup(library, key, action, value) {
|
|
161
|
+
return this.serialize(async () => {
|
|
162
|
+
if (!this.link.configuration)
|
|
163
|
+
throw new Error('Native configuration transport is unavailable');
|
|
164
|
+
const groups = new NativeGroups(this.config, { configuration: this.link.configuration.bind(this.link) }, library);
|
|
165
|
+
if (action === 'inspect')
|
|
166
|
+
return groups.inspect();
|
|
167
|
+
if (!key)
|
|
168
|
+
throw new Error('Native group ID is required');
|
|
169
|
+
if (action === 'enable')
|
|
170
|
+
return groups.enable(key, typeof value === 'number' ? value : undefined);
|
|
171
|
+
if (action === 'disable')
|
|
172
|
+
return groups.disable(key);
|
|
173
|
+
if (action === 'add' || action === 'remove') {
|
|
174
|
+
if (typeof value !== 'string')
|
|
175
|
+
throw new Error('Member key is required');
|
|
176
|
+
return groups.member(key, value, action === 'remove');
|
|
177
|
+
}
|
|
178
|
+
return groups.sync(key);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async discoverUnprovisioned() {
|
|
182
|
+
if (!this.link.discoverUnprovisioned)
|
|
183
|
+
throw new Error('Provisioning discovery is unavailable');
|
|
184
|
+
const scan = this.link.discoverUnprovisioned.bind(this.link);
|
|
185
|
+
return this.serialize(scan);
|
|
186
|
+
}
|
|
187
|
+
light(key) {
|
|
188
|
+
const light = this.config.lights.find((entry) => entry.key === key);
|
|
189
|
+
if (!light)
|
|
190
|
+
throw new Error(`Unknown fixture: ${key}`);
|
|
191
|
+
return light;
|
|
192
|
+
}
|
|
193
|
+
hold(keys, minutes = 30, replace = false) {
|
|
194
|
+
const until = minutes === 0 ? 0 : Date.now() + minutes * 60_000;
|
|
195
|
+
for (const key of keys) {
|
|
196
|
+
const current = this.history?.getOverride?.(key) ?? this.manualOverrides.get(key) ?? 0;
|
|
197
|
+
const expires = replace ? until : Math.max(until, current);
|
|
198
|
+
this.manualOverrides.set(key, expires);
|
|
199
|
+
this.history?.setOverride?.(key, expires);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
overrideStatus(keys) {
|
|
203
|
+
for (const key of keys)
|
|
204
|
+
this.light(key);
|
|
205
|
+
return Object.fromEntries(keys.map((key) => [
|
|
206
|
+
key,
|
|
207
|
+
Math.max(0, (this.history?.getOverride?.(key) ?? this.manualOverrides.get(key) ?? 0) - Date.now()),
|
|
208
|
+
]));
|
|
209
|
+
}
|
|
210
|
+
async override(keys, minutes, signal) {
|
|
211
|
+
if (!keys.length)
|
|
212
|
+
throw new Error('Override requires targets');
|
|
213
|
+
for (const key of keys)
|
|
214
|
+
this.light(key);
|
|
215
|
+
numberInRange(minutes, 'override minutes', 0, 1440);
|
|
216
|
+
return this.serialize(async () => {
|
|
217
|
+
this.hold(keys, minutes, true);
|
|
218
|
+
return this.overrideStatus(keys);
|
|
219
|
+
}, signal);
|
|
220
|
+
}
|
|
221
|
+
async reserveControl(keys, minutes, signal) {
|
|
222
|
+
if (!keys.length)
|
|
223
|
+
throw new Error('Control reservation requires targets');
|
|
224
|
+
for (const key of keys)
|
|
225
|
+
this.light(key);
|
|
226
|
+
numberInRange(minutes, 'control reservation minutes', 1, 1440);
|
|
227
|
+
await this.serialize(async () => {
|
|
228
|
+
this.hold(keys, minutes);
|
|
229
|
+
}, signal);
|
|
230
|
+
}
|
|
231
|
+
async automaticCct(key, body, signal) {
|
|
232
|
+
const light = this.light(key);
|
|
233
|
+
validateAction(light, 'cct', body);
|
|
234
|
+
return this.serialize(async () => {
|
|
235
|
+
if (this.overrideStatus([key])[key] > 0)
|
|
236
|
+
return { skipped: true, reason: 'manual-override' };
|
|
237
|
+
const state = await this.readWithReconnect(light.address, signal);
|
|
238
|
+
if (state.sleep)
|
|
239
|
+
return { skipped: true, reason: 'light-off' };
|
|
240
|
+
const fan = await this.readFanWithReconnect(light.address, signal);
|
|
241
|
+
if (fan.highTemperature)
|
|
242
|
+
return { skipped: true, reason: 'thermal-protection' };
|
|
243
|
+
if (fan.mode === FAN_MODES.off || (fan.mode === FAN_MODES.manual && fan.speed === 0))
|
|
244
|
+
return { skipped: true, reason: 'stopped-cooling' };
|
|
245
|
+
return { skipped: false, state: await this.apply(this.prepare(light, 'cct', body, state), signal) };
|
|
246
|
+
}, signal);
|
|
247
|
+
}
|
|
248
|
+
async productInfo(key) {
|
|
249
|
+
const light = this.light(key);
|
|
250
|
+
if (!this.link.readProductInfo)
|
|
251
|
+
throw new Error('Product information readback is unavailable');
|
|
252
|
+
const read = this.link.readProductInfo.bind(this.link);
|
|
253
|
+
return this.serialize(() => this.telemetryWithReconnect(() => read(light.address)));
|
|
254
|
+
}
|
|
255
|
+
serialize(work, signal) {
|
|
256
|
+
if (this.stopping)
|
|
257
|
+
return Promise.reject(new Error('BLE daemon is stopping'));
|
|
258
|
+
if (this.queued >= 16)
|
|
259
|
+
return Promise.reject(new Error('BLE command queue is full'));
|
|
260
|
+
const expires = Date.now() + 45_000;
|
|
261
|
+
this.queued++;
|
|
262
|
+
const operation = this.queue.then(async () => {
|
|
263
|
+
if (this.stopping || Date.now() > expires)
|
|
264
|
+
throw new Error('BLE command expired before execution');
|
|
265
|
+
signal?.throwIfAborted();
|
|
266
|
+
return work();
|
|
267
|
+
});
|
|
268
|
+
this.queue = operation.then(() => {
|
|
269
|
+
this.queued--;
|
|
270
|
+
}, () => {
|
|
271
|
+
this.queued--;
|
|
272
|
+
});
|
|
273
|
+
return operation;
|
|
274
|
+
}
|
|
275
|
+
async execute(key, action, body, signal) {
|
|
276
|
+
const light = this.light(key);
|
|
277
|
+
validateAction(light, action, body);
|
|
278
|
+
return this.serialize(async () => {
|
|
279
|
+
const previous = await this.readWithReconnect(light.address, signal);
|
|
280
|
+
if (action === 'state')
|
|
281
|
+
return previous;
|
|
282
|
+
if (action === 'effect-stop' && previous.mode !== 'effect')
|
|
283
|
+
return previous;
|
|
284
|
+
const prepared = this.prepare(light, action, body, previous);
|
|
285
|
+
this.hold([key]);
|
|
286
|
+
if (action === 'effect-trigger') {
|
|
287
|
+
signal?.throwIfAborted();
|
|
288
|
+
await this.link.send(light.address, prepared.payload);
|
|
289
|
+
try {
|
|
290
|
+
const observed = await this.readWithReconnect(light.address, signal);
|
|
291
|
+
if (!this.matches(observed, prepared.expected))
|
|
292
|
+
throw new Error('Effect settings readback mismatch');
|
|
293
|
+
return { ...observed, triggerRequest: { sent: true, eventConfirmed: false } };
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
throw new Error(`Trigger may have been delivered; not retried: ${error.message}`, {
|
|
297
|
+
cause: error,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const state = await this.apply(prepared, signal);
|
|
302
|
+
const steady = this.steadyStates.get(key) ?? this.history?.getSteady(key);
|
|
303
|
+
if (action === 'effect-stop' && steady?.sleep && !state.sleep) {
|
|
304
|
+
return this.apply({ light, payload: powerPacket(false), expected: { sleep: true } }, signal);
|
|
305
|
+
}
|
|
306
|
+
return state;
|
|
307
|
+
}, signal);
|
|
308
|
+
}
|
|
309
|
+
async batch(keys, action, body, broadcast = false, signal, groupAddress) {
|
|
310
|
+
if (!keys.length || new Set(keys).size !== keys.length)
|
|
311
|
+
throw new Error('Batch requires unique, nonempty fixture targets');
|
|
312
|
+
const lights = keys.map((key) => this.light(key));
|
|
313
|
+
for (const light of lights)
|
|
314
|
+
validateAction(light, action, body);
|
|
315
|
+
if (action === 'state')
|
|
316
|
+
throw new Error('Use a snapshot for grouped state reads');
|
|
317
|
+
if (action === 'effect-stop') {
|
|
318
|
+
if (broadcast)
|
|
319
|
+
throw new Error('Effect restoration cannot use one broadcast payload');
|
|
320
|
+
const states = await this.stopEffects(keys, signal);
|
|
321
|
+
return { delivery: 'batched-unicast', states };
|
|
322
|
+
}
|
|
323
|
+
if (broadcast && lights.length !== this.config.lights.length)
|
|
324
|
+
throw new Error('Broadcast addresses the whole mesh, not a subset group');
|
|
325
|
+
return this.serialize(async () => {
|
|
326
|
+
const prepared = [];
|
|
327
|
+
for (const light of lights)
|
|
328
|
+
prepared.push(this.prepare(light, action, body, await this.readWithReconnect(light.address, signal)));
|
|
329
|
+
if (broadcast && !prepared.every((item) => item.payload.equals(prepared[0].payload))) {
|
|
330
|
+
throw new Error('Broadcast needs identical applied settings; specify brightness and compatible parameters explicitly');
|
|
331
|
+
}
|
|
332
|
+
signal?.throwIfAborted();
|
|
333
|
+
this.hold(keys);
|
|
334
|
+
const native = groupAddress !== undefined && prepared.every((item) => item.payload.equals(prepared[0].payload));
|
|
335
|
+
if (groupAddress !== undefined &&
|
|
336
|
+
(!Number.isInteger(groupAddress) || groupAddress < 0xc000 || groupAddress > 0xfeff))
|
|
337
|
+
throw new Error('Invalid native group address');
|
|
338
|
+
try {
|
|
339
|
+
if (broadcast)
|
|
340
|
+
await this.link.send(0xffff, prepared[0].payload);
|
|
341
|
+
else if (native)
|
|
342
|
+
await this.link.send(groupAddress, prepared[0].payload);
|
|
343
|
+
else
|
|
344
|
+
for (const item of prepared) {
|
|
345
|
+
signal?.throwIfAborted();
|
|
346
|
+
await this.link.send(item.light.address, item.payload);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
throw new Error(`Batch delivery interrupted; some lights may have changed: ${error.message}`, {
|
|
351
|
+
cause: error,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
await delay(200);
|
|
355
|
+
const states = {};
|
|
356
|
+
const failures = [];
|
|
357
|
+
for (const item of prepared) {
|
|
358
|
+
try {
|
|
359
|
+
const observed = await this.readWithReconnect(item.light.address, signal);
|
|
360
|
+
if (action === 'effect-trigger' && !this.matches(observed, item.expected))
|
|
361
|
+
throw new Error('Trigger settings mismatch; event not retried');
|
|
362
|
+
states[item.light.key] = this.matches(observed, item.expected) ? observed : await this.apply(item, signal);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
failures.push(`${item.light.name}: ${error.message}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (failures.length)
|
|
369
|
+
throw new Error(`Partial batch failure (other lights may have changed): ${failures.join('; ')}`);
|
|
370
|
+
return {
|
|
371
|
+
delivery: broadcast ? 'mesh-broadcast' : native ? 'native-group' : 'batched-unicast',
|
|
372
|
+
states,
|
|
373
|
+
...(action === 'effect-trigger'
|
|
374
|
+
? { triggerRequest: { sent: true, eventConfirmed: false } }
|
|
375
|
+
: {}),
|
|
376
|
+
};
|
|
377
|
+
}, signal);
|
|
378
|
+
}
|
|
379
|
+
async snapshot(keys = this.config.lights.map((light) => light.key), includeFans = true) {
|
|
380
|
+
if (!keys.length || new Set(keys).size !== keys.length)
|
|
381
|
+
throw new Error('Snapshot requires unique, nonempty targets');
|
|
382
|
+
const lights = keys.map((key) => this.light(key));
|
|
383
|
+
return this.serialize(async () => {
|
|
384
|
+
const result = {};
|
|
385
|
+
for (const light of lights) {
|
|
386
|
+
const state = await this.readWithReconnect(light.address);
|
|
387
|
+
if (!capabilities(light).gm_support)
|
|
388
|
+
state.gm = undefined;
|
|
389
|
+
if (includeFans) {
|
|
390
|
+
const fan = await this.readFanWithReconnect(light.address);
|
|
391
|
+
const mode = parseFanMode(fan.mode);
|
|
392
|
+
if (mode === 'manual')
|
|
393
|
+
throw new Error(`${light.name}: cannot infer a manual fan setpoint from current RPM`);
|
|
394
|
+
state.fan = { mode };
|
|
395
|
+
}
|
|
396
|
+
result[light.key] = state;
|
|
397
|
+
}
|
|
398
|
+
return result;
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
async stopEffects(keys, signal) {
|
|
402
|
+
if (!keys.length || new Set(keys).size !== keys.length)
|
|
403
|
+
throw new Error('Effect stop requires unique targets');
|
|
404
|
+
const lights = keys.map((key) => this.light(key));
|
|
405
|
+
return this.serialize(async () => {
|
|
406
|
+
const states = {};
|
|
407
|
+
const restore = {};
|
|
408
|
+
for (const light of lights) {
|
|
409
|
+
const state = await this.readWithReconnect(light.address, signal);
|
|
410
|
+
states[light.key] = state;
|
|
411
|
+
if (state.mode !== 'effect')
|
|
412
|
+
continue;
|
|
413
|
+
const prior = this.steadyStates.get(light.key) ?? this.history?.getSteady(light.key);
|
|
414
|
+
if (!prior)
|
|
415
|
+
throw new Error(`${light.key}: no pre-effect state; explicitly select CCT or HSI`);
|
|
416
|
+
restore[light.key] = prior;
|
|
417
|
+
}
|
|
418
|
+
return Object.keys(restore).length ? { ...states, ...(await this.restoreNow(restore, signal)) } : states;
|
|
419
|
+
}, signal);
|
|
420
|
+
}
|
|
421
|
+
async restore(states, signal) {
|
|
422
|
+
return this.serialize(() => this.restoreNow(states, signal), signal);
|
|
423
|
+
}
|
|
424
|
+
async restoreNow(states, signal) {
|
|
425
|
+
const entries = Object.entries(states);
|
|
426
|
+
if (!entries.length)
|
|
427
|
+
throw new Error('Saved lighting state is empty');
|
|
428
|
+
const actions = entries.map(([key, state]) => {
|
|
429
|
+
const light = this.light(key);
|
|
430
|
+
if (state.fan) {
|
|
431
|
+
FanSettingSchema.parse(state.fan);
|
|
432
|
+
validateFanRpm(state.fan.mode, state.fan.rpm);
|
|
433
|
+
if ((state.fan.mode === 'off' || (state.fan.mode === 'manual' && state.fan.rpm === 0)) &&
|
|
434
|
+
!state.sleep &&
|
|
435
|
+
state.intensity > 0)
|
|
436
|
+
throw new Error(`${light.name}: saved stopped cooling is incompatible with emitting LEDs`);
|
|
437
|
+
}
|
|
438
|
+
if (!capabilities(light).gm_support && state.gm !== undefined && state.gm !== 0)
|
|
439
|
+
throw new Error(`${light.name} cannot reproduce a preset with non-neutral G/M`);
|
|
440
|
+
const action = state.mode === 'effect' ? 'effect' : state.mode;
|
|
441
|
+
const body = state.mode === 'hsi'
|
|
442
|
+
? { hue: state.hue, saturation: state.sat, brightness: state.intensity / 10 }
|
|
443
|
+
: state.mode === 'effect'
|
|
444
|
+
? {
|
|
445
|
+
name: state.effect,
|
|
446
|
+
brightness: state.intensity / 10,
|
|
447
|
+
frequency: state.frequency,
|
|
448
|
+
...(state.speed === undefined ? {} : { speed: state.speed }),
|
|
449
|
+
...(state.trigger === undefined ? {} : { trigger: state.trigger }),
|
|
450
|
+
...(state.cct === undefined ? {} : { kelvin: state.cct }),
|
|
451
|
+
...(state.palette === undefined ? {} : { palette: state.palette }),
|
|
452
|
+
...(state.hue === undefined ? {} : { hue: state.hue }),
|
|
453
|
+
...(state.sat === undefined ? {} : { saturation: state.sat }),
|
|
454
|
+
}
|
|
455
|
+
: { kelvin: state.cct, brightness: state.intensity / 10 };
|
|
456
|
+
if (light.model === '150c' && state.mode !== 'hsi' && state.gm !== undefined)
|
|
457
|
+
body.gm = state.gm;
|
|
458
|
+
validateAction(light, action, body);
|
|
459
|
+
return { light, action, body, state };
|
|
460
|
+
});
|
|
461
|
+
const result = {};
|
|
462
|
+
for (const { light, state } of actions) {
|
|
463
|
+
if (!state.fan)
|
|
464
|
+
continue;
|
|
465
|
+
const fan = await this.readFanWithReconnect(light.address, signal);
|
|
466
|
+
if (fan.highTemperature)
|
|
467
|
+
throw new FanThermalError(light);
|
|
468
|
+
if (!fan.supported[state.fan.mode])
|
|
469
|
+
throw new Error(`${light.name} does not advertise ${state.fan.mode} fan mode`);
|
|
470
|
+
}
|
|
471
|
+
this.hold(entries.map(([key]) => key));
|
|
472
|
+
for (const { light, action, body, state } of actions) {
|
|
473
|
+
const stoppedFan = state.fan?.mode === 'off' || (state.fan?.mode === 'manual' && state.fan.rpm === 0);
|
|
474
|
+
if (state.fan && !stoppedFan)
|
|
475
|
+
await this.applyFan(light, state.fan.mode, state.fan.rpm, signal);
|
|
476
|
+
const before = await this.readWithReconnect(light.address, signal);
|
|
477
|
+
const applied = await this.apply(this.prepare(light, action, state.sleep ? { ...body, brightness: 0 } : body, before), signal);
|
|
478
|
+
result[light.key] =
|
|
479
|
+
applied.sleep === state.sleep
|
|
480
|
+
? applied
|
|
481
|
+
: await this.apply({
|
|
482
|
+
light,
|
|
483
|
+
payload: powerPacket(!state.sleep),
|
|
484
|
+
expected: { sleep: state.sleep },
|
|
485
|
+
}, signal);
|
|
486
|
+
if (state.sleep && state.intensity !== 0)
|
|
487
|
+
result[light.key] = await this.apply({
|
|
488
|
+
light,
|
|
489
|
+
payload: brightnessPacket(state.intensity),
|
|
490
|
+
expected: { sleep: true, intensity: state.intensity },
|
|
491
|
+
}, signal);
|
|
492
|
+
if (state.fan && stoppedFan)
|
|
493
|
+
await this.applyFan(light, state.fan.mode, state.fan.rpm, signal);
|
|
494
|
+
if (state.fan)
|
|
495
|
+
result[light.key].fan = state.fan;
|
|
496
|
+
}
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
499
|
+
async fade(keys, brightness, seconds, signal) {
|
|
500
|
+
const target = Math.round(numberInRange(brightness, 'brightness', 0, 100)) * 10;
|
|
501
|
+
const duration = numberInRange(seconds, 'fade duration in seconds', 0.5, 20) * 1000;
|
|
502
|
+
if (!keys.length || new Set(keys).size !== keys.length)
|
|
503
|
+
throw new Error('Fade requires unique, nonempty targets');
|
|
504
|
+
const lights = keys.map((key) => this.light(key));
|
|
505
|
+
return this.serialize(async () => {
|
|
506
|
+
const starts = [];
|
|
507
|
+
for (const light of lights) {
|
|
508
|
+
const state = await this.readWithReconnect(light.address, signal);
|
|
509
|
+
if (state.mode === 'effect')
|
|
510
|
+
throw new Error('Stop native effects before starting a brightness fade');
|
|
511
|
+
starts.push({ light, state });
|
|
512
|
+
}
|
|
513
|
+
const steps = Math.ceil(duration / 500);
|
|
514
|
+
this.hold(keys);
|
|
515
|
+
const start = Date.now();
|
|
516
|
+
const states = {};
|
|
517
|
+
try {
|
|
518
|
+
for (let step = 1; step <= steps; step++) {
|
|
519
|
+
await delay(Math.max(0, start + (duration * step) / steps - Date.now()), undefined, { signal });
|
|
520
|
+
for (const { light, state } of starts) {
|
|
521
|
+
signal?.throwIfAborted();
|
|
522
|
+
const intensity = Math.round((state.intensity + ((target - state.intensity) * step) / steps) / 10) * 10;
|
|
523
|
+
await this.link.send(light.address, brightnessPacket(intensity));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
for (const { light, state } of starts) {
|
|
527
|
+
const expected = { intensity: target, sleep: state.sleep };
|
|
528
|
+
const observed = await this.readWithReconnect(light.address, signal);
|
|
529
|
+
states[light.key] = this.matches(observed, expected)
|
|
530
|
+
? observed
|
|
531
|
+
: await this.apply({ light, payload: brightnessPacket(target), expected }, signal);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
throw new Error(`Fade interrupted; lights may be at intermediate brightness: ${error.message}`, {
|
|
536
|
+
cause: error,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
return { delivery: 'batched-unicast', states };
|
|
540
|
+
}, signal);
|
|
541
|
+
}
|
|
542
|
+
async transition(keys, action, body, seconds, signal) {
|
|
543
|
+
if (!['cct', 'hsi', 'brightness'].includes(action))
|
|
544
|
+
throw new Error('Transitions support cct, hsi or brightness');
|
|
545
|
+
if (!keys.length || new Set(keys).size !== keys.length)
|
|
546
|
+
throw new Error('Transition requires unique targets');
|
|
547
|
+
const lights = keys.map((key) => this.light(key));
|
|
548
|
+
for (const light of lights)
|
|
549
|
+
validateAction(light, action, body);
|
|
550
|
+
return this.serialize(async () => {
|
|
551
|
+
const starts = {};
|
|
552
|
+
const targets = {};
|
|
553
|
+
for (const light of lights) {
|
|
554
|
+
const state = await this.readWithReconnect(light.address, signal);
|
|
555
|
+
starts[light.key] = state;
|
|
556
|
+
targets[light.key] = { ...state, ...this.prepare(light, action, body, state).expected, sleep: state.sleep };
|
|
557
|
+
if (!capabilities(light).gm_support)
|
|
558
|
+
targets[light.key].gm = undefined;
|
|
559
|
+
}
|
|
560
|
+
return this.transitionNow(starts, targets, seconds, signal);
|
|
561
|
+
}, signal);
|
|
562
|
+
}
|
|
563
|
+
async transitionScene(targets, seconds, signal) {
|
|
564
|
+
const lights = Object.keys(targets).map((key) => this.light(key));
|
|
565
|
+
if (!lights.length)
|
|
566
|
+
throw new Error('Scene transition requires targets');
|
|
567
|
+
return this.serialize(async () => {
|
|
568
|
+
const starts = {};
|
|
569
|
+
for (const light of lights)
|
|
570
|
+
starts[light.key] = await this.readWithReconnect(light.address, signal);
|
|
571
|
+
return this.transitionNow(starts, targets, seconds, signal);
|
|
572
|
+
}, signal);
|
|
573
|
+
}
|
|
574
|
+
steadyBody(light, state) {
|
|
575
|
+
if (state.mode === 'effect')
|
|
576
|
+
throw new Error('Stop native effects before a transition');
|
|
577
|
+
return state.mode === 'hsi'
|
|
578
|
+
? { hue: state.hue, saturation: state.sat, brightness: state.intensity / 10 }
|
|
579
|
+
: {
|
|
580
|
+
kelvin: state.cct,
|
|
581
|
+
brightness: state.intensity / 10,
|
|
582
|
+
...(capabilities(light).gm_support ? { gm: state.gm ?? 0 } : {}),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
async transitionNow(starts, targets, seconds, signal) {
|
|
586
|
+
const duration = numberInRange(seconds, 'transition seconds', 0.5, 20) * 1000;
|
|
587
|
+
const lights = Object.keys(targets).map((key) => this.light(key));
|
|
588
|
+
for (const light of lights) {
|
|
589
|
+
const from = starts[light.key], to = targets[light.key];
|
|
590
|
+
validateAction(light, to.mode, this.steadyBody(light, to));
|
|
591
|
+
this.steadyBody(light, from);
|
|
592
|
+
if (from.mode !== to.mode && duration < 1000)
|
|
593
|
+
throw new Error('Cross-mode transitions require at least one second');
|
|
594
|
+
if (!capabilities(light).gm_support && to.gm !== undefined && to.gm !== 0)
|
|
595
|
+
throw new Error('Target fixture does not support G/M');
|
|
596
|
+
const fan = await this.readFanWithReconnect(light.address, signal);
|
|
597
|
+
if (fan.highTemperature)
|
|
598
|
+
throw new FanThermalError(light);
|
|
599
|
+
if (to.fan) {
|
|
600
|
+
FanSettingSchema.parse(to.fan);
|
|
601
|
+
if (!fan.supported[to.fan.mode])
|
|
602
|
+
throw new Error(`${light.name} does not advertise ${to.fan.mode} fan mode`);
|
|
603
|
+
if ((to.fan.mode === 'off' || (to.fan.mode === 'manual' && to.fan.rpm === 0)) && !to.sleep && to.intensity > 0)
|
|
604
|
+
throw new Error('Stopped fan cannot accompany an emitting scene');
|
|
605
|
+
}
|
|
606
|
+
if ((fan.mode === FAN_MODES.off || (fan.mode === FAN_MODES.manual && fan.speed === 0)) &&
|
|
607
|
+
((!from.sleep && from.intensity > 0) || (!to.sleep && to.intensity > 0)) &&
|
|
608
|
+
(!to.fan || to.fan.mode === 'off' || (to.fan.mode === 'manual' && to.fan.rpm === 0)))
|
|
609
|
+
throw new Error(`${light.name}: select a cooling profile before increasing output`);
|
|
610
|
+
}
|
|
611
|
+
signal?.throwIfAborted();
|
|
612
|
+
this.hold(lights.map((light) => light.key));
|
|
613
|
+
for (const light of lights) {
|
|
614
|
+
const fan = targets[light.key].fan;
|
|
615
|
+
if (fan && fan.mode !== 'off' && !(fan.mode === 'manual' && fan.rpm === 0))
|
|
616
|
+
await this.applyFan(light, fan.mode, fan.rpm, signal);
|
|
617
|
+
}
|
|
618
|
+
const steps = Math.ceil(duration / 500), start = Date.now();
|
|
619
|
+
try {
|
|
620
|
+
for (let step = 1; step <= steps; step++) {
|
|
621
|
+
await delay(Math.max(0, start + (duration * step) / steps - Date.now()), undefined, { signal });
|
|
622
|
+
for (const light of lights) {
|
|
623
|
+
signal?.throwIfAborted();
|
|
624
|
+
const frame = interpolateState(starts[light.key], targets[light.key], step / steps);
|
|
625
|
+
const payload = this.prepare(light, frame.mode, this.steadyBody(light, frame), starts[light.key]).payload;
|
|
626
|
+
await this.link.send(light.address, payload);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return { delivery: 'batched-unicast', states: await this.restoreNow(targets, signal) };
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
throw new Error(`Transition interrupted; fixtures may be at intermediate settings: ${error.message}`, {
|
|
633
|
+
cause: error,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
async fan(key, mode, signal, rpm) {
|
|
638
|
+
const states = await this.fans([key], mode, signal, rpm);
|
|
639
|
+
return states[key];
|
|
640
|
+
}
|
|
641
|
+
async fans(keys, mode, signal, rpm) {
|
|
642
|
+
if (!keys.length || new Set(keys).size !== keys.length)
|
|
643
|
+
throw new Error('Fan control requires unique, nonempty targets');
|
|
644
|
+
const lights = keys.map((key) => this.light(key));
|
|
645
|
+
const desired = mode === undefined ? undefined : parseFanMode(mode);
|
|
646
|
+
if (desired === undefined && rpm !== undefined)
|
|
647
|
+
throw new Error('RPM requires manual fan mode');
|
|
648
|
+
const speed = desired === undefined ? undefined : validateFanRpm(desired, rpm);
|
|
649
|
+
return this.serialize(async () => {
|
|
650
|
+
const states = {};
|
|
651
|
+
for (const light of lights) {
|
|
652
|
+
states[light.key] = await this.readFanWithReconnect(light.address, signal);
|
|
653
|
+
if (desired !== undefined)
|
|
654
|
+
await this.validateFan(light, desired, states[light.key], speed, signal);
|
|
655
|
+
}
|
|
656
|
+
if (desired === undefined)
|
|
657
|
+
return states;
|
|
658
|
+
const applied = [];
|
|
659
|
+
for (const light of lights) {
|
|
660
|
+
try {
|
|
661
|
+
signal?.throwIfAborted();
|
|
662
|
+
states[light.key] = await this.applyFan(light, desired, speed, signal);
|
|
663
|
+
applied.push(light.key);
|
|
664
|
+
}
|
|
665
|
+
catch (error) {
|
|
666
|
+
if (lights.length === 1)
|
|
667
|
+
throw error;
|
|
668
|
+
throw new Error(`Fan batch stopped at ${light.key}; verified targets: ${applied.join(', ') || 'none'}. Some profiles may have changed. ${error.message}`, { cause: error });
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return states;
|
|
672
|
+
}, signal);
|
|
673
|
+
}
|
|
674
|
+
async validateFan(light, desired, state, rpm, signal) {
|
|
675
|
+
if (state.highTemperature)
|
|
676
|
+
throw new FanThermalError(light);
|
|
677
|
+
if (!state.supported[desired])
|
|
678
|
+
throw new Error(`${light.name} does not advertise ${desired} fan mode`);
|
|
679
|
+
if (desired === 'off' || (desired === 'manual' && rpm === 0)) {
|
|
680
|
+
const lighting = await this.readWithReconnect(light.address, signal);
|
|
681
|
+
if (!lighting.sleep && lighting.intensity > 0)
|
|
682
|
+
throw new Error(`${light.name}: switch the LEDs off or set brightness to zero before requesting stopped cooling`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
fanMatches(state, desired, rpm) {
|
|
686
|
+
return state.mode === FAN_MODES[desired] && (desired !== 'manual' || state.speed === rpm);
|
|
687
|
+
}
|
|
688
|
+
async applyFan(light, desired, rpm, signal) {
|
|
689
|
+
const before = await this.readFanWithReconnect(light.address, signal);
|
|
690
|
+
await this.validateFan(light, desired, before, rpm, signal);
|
|
691
|
+
if (desired !== 'manual' && this.fanMatches(before, desired))
|
|
692
|
+
return before;
|
|
693
|
+
signal?.throwIfAborted();
|
|
694
|
+
try {
|
|
695
|
+
await this.link.send(light.address, fanPacket(desired, rpm));
|
|
696
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
697
|
+
await delay(300, undefined, { signal });
|
|
698
|
+
const state = await this.readFanWithReconnect(light.address, signal);
|
|
699
|
+
if (state.highTemperature)
|
|
700
|
+
throw new FanThermalError(light);
|
|
701
|
+
if (this.fanMatches(state, desired, rpm))
|
|
702
|
+
return state;
|
|
703
|
+
}
|
|
704
|
+
throw new Error(`${light.name} fan ${desired === 'manual' ? 'mode/RPM' : 'mode'} did not verify`);
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
if (error instanceof FanThermalError)
|
|
708
|
+
throw error;
|
|
709
|
+
let recovery;
|
|
710
|
+
try {
|
|
711
|
+
recovery = await this.readFanWithReconnect(light.address);
|
|
712
|
+
if (recovery.highTemperature)
|
|
713
|
+
throw new FanThermalError(light);
|
|
714
|
+
if (this.fanMatches(recovery, desired, rpm) && !signal?.aborted)
|
|
715
|
+
return recovery;
|
|
716
|
+
if (!recovery.supported.smart)
|
|
717
|
+
throw new Error('Smart recovery is not advertised');
|
|
718
|
+
if (recovery.mode !== FAN_MODES.smart) {
|
|
719
|
+
await this.link.send(light.address, fanPacket('smart'));
|
|
720
|
+
await delay(300);
|
|
721
|
+
recovery = await this.readFanWithReconnect(light.address);
|
|
722
|
+
if (recovery.highTemperature)
|
|
723
|
+
throw new FanThermalError(light);
|
|
724
|
+
if (recovery.mode !== FAN_MODES.smart)
|
|
725
|
+
throw new Error('Smart cooling readback mismatch');
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
catch (failure) {
|
|
729
|
+
if (failure instanceof FanThermalError)
|
|
730
|
+
throw failure;
|
|
731
|
+
throw new AggregateError([error, failure], `${light.name}: fan change and recovery could not be verified; inspect the fixture`);
|
|
732
|
+
}
|
|
733
|
+
throw new Error(`${error.message}; Smart automatic cooling is confirmed`, { cause: error });
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
prepare(light, action, body, previous) {
|
|
737
|
+
if (previous.mode !== 'effect') {
|
|
738
|
+
this.steadyStates.set(light.key, previous);
|
|
739
|
+
this.history?.saveSteady(light.key, previous);
|
|
740
|
+
}
|
|
741
|
+
const caps = capabilities(light);
|
|
742
|
+
if (action === 'effect-stop') {
|
|
743
|
+
const steady = this.steadyStates.get(light.key) ?? this.history?.getSteady(light.key);
|
|
744
|
+
if (!steady)
|
|
745
|
+
throw new Error('Previous steady state is unavailable; explicitly select CCT or HSI to stop the effect');
|
|
746
|
+
return this.prepare(light, steady.mode, steady.mode === 'hsi'
|
|
747
|
+
? { hue: steady.hue, saturation: steady.sat, brightness: steady.intensity / 10 }
|
|
748
|
+
: {
|
|
749
|
+
kelvin: steady.cct,
|
|
750
|
+
brightness: steady.intensity / 10,
|
|
751
|
+
...(caps.gm_support ? { gm: steady.gm ?? 0 } : {}),
|
|
752
|
+
}, previous);
|
|
753
|
+
}
|
|
754
|
+
if (action === 'increment-brightness')
|
|
755
|
+
return this.prepare(light, 'brightness', {
|
|
756
|
+
value: Math.max(0, Math.min(100, previous.intensity / 10 + numberInRange(body.delta, 'delta', -100, 100))),
|
|
757
|
+
}, previous);
|
|
758
|
+
if (action === 'increment-cct') {
|
|
759
|
+
if (previous.mode !== 'cct' || previous.cct === undefined)
|
|
760
|
+
throw new Error('Relative CCT requires an active CCT setting');
|
|
761
|
+
return this.prepare(light, 'cct', {
|
|
762
|
+
kelvin: Math.max(caps.cct_min, Math.min(caps.cct_max, previous.cct + numberInRange(body.delta, 'delta', -7500, 7500))),
|
|
763
|
+
...(body.brightness === undefined ? {} : { brightness: body.brightness }),
|
|
764
|
+
}, previous);
|
|
765
|
+
}
|
|
766
|
+
if (action === 'gm') {
|
|
767
|
+
if (previous.mode !== 'cct')
|
|
768
|
+
throw new Error('G/M adjustment is supported in CCT mode; advanced HSI tint is not supported by the 150c');
|
|
769
|
+
return this.prepare(light, 'cct', { kelvin: previous.cct, gm: body.value }, previous);
|
|
770
|
+
}
|
|
771
|
+
if (action === 'color')
|
|
772
|
+
return this.prepare(light, 'hsi', { ...colorToHSI(body.color), ...(body.brightness === undefined ? {} : { brightness: body.brightness }) }, previous);
|
|
773
|
+
if (['effect-speed', 'effect-animation-speed', 'effect-intensity', 'effect-trigger'].includes(action)) {
|
|
774
|
+
if (previous.mode !== 'effect' || !previous.effect)
|
|
775
|
+
throw new Error('No native effect is active');
|
|
776
|
+
if (action === 'effect-trigger' && (previous.sleep || !TRIGGER_EFFECTS.includes(previous.effect)))
|
|
777
|
+
throw new Error('Trigger requires an awake lightning, faulty-bulb, pulsing, strobe or explosion effect');
|
|
778
|
+
if (action === 'effect-animation-speed' && !['lightning', 'faulty-bulb', 'pulsing'].includes(previous.effect))
|
|
779
|
+
throw new Error(`${previous.effect} has no animation speed parameter`);
|
|
780
|
+
return this.prepare(light, 'effect', {
|
|
781
|
+
name: previous.effect,
|
|
782
|
+
frequency: action === 'effect-speed' ? body.value : previous.frequency,
|
|
783
|
+
speed: action === 'effect-animation-speed' ? body.value : previous.speed,
|
|
784
|
+
brightness: action === 'effect-intensity' ? body.value : previous.intensity / 10,
|
|
785
|
+
...(previous.cct === undefined ? {} : { kelvin: previous.cct }),
|
|
786
|
+
...(previous.palette === undefined ? {} : { palette: previous.palette }),
|
|
787
|
+
...(previous.hue === undefined ? {} : { hue: previous.hue }),
|
|
788
|
+
...(previous.sat === undefined ? {} : { saturation: previous.sat }),
|
|
789
|
+
...(action === 'effect-trigger' ? { _trigger: true } : {}),
|
|
790
|
+
}, previous);
|
|
791
|
+
}
|
|
792
|
+
const expected = {};
|
|
793
|
+
let payload;
|
|
794
|
+
if (action === 'on' || action === 'off' || action === 'toggle') {
|
|
795
|
+
expected.sleep = action === 'toggle' ? !previous.sleep : action === 'off';
|
|
796
|
+
payload = powerPacket(!expected.sleep);
|
|
797
|
+
}
|
|
798
|
+
else if (action === 'brightness') {
|
|
799
|
+
expected.intensity = Math.round(numberInRange(body.value, 'brightness', 0, 100)) * 10;
|
|
800
|
+
payload = brightnessPacket(expected.intensity);
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
expected.sleep = false;
|
|
804
|
+
expected.intensity =
|
|
805
|
+
body.brightness === undefined
|
|
806
|
+
? previous.intensity
|
|
807
|
+
: Math.round(numberInRange(body.brightness, 'brightness', 0, 100)) * 10;
|
|
808
|
+
if (action === 'cct') {
|
|
809
|
+
expected.mode = 'cct';
|
|
810
|
+
expected.cct = Math.round(numberInRange(body.kelvin, 'CCT', caps.cct_min, caps.cct_max) / 100) * 100;
|
|
811
|
+
const gm = caps.gm_support
|
|
812
|
+
? Math.round(numberInRange(body.gm ?? previous.gm ?? 0, 'G/M', -100, 100) / 10) * 10
|
|
813
|
+
: 0;
|
|
814
|
+
if (caps.gm_support)
|
|
815
|
+
expected.gm = gm;
|
|
816
|
+
payload = cctPacket(expected.cct, expected.intensity, gm);
|
|
817
|
+
}
|
|
818
|
+
else if (action === 'hsi') {
|
|
819
|
+
expected.mode = 'hsi';
|
|
820
|
+
expected.hue = Math.round(numberInRange(body.hue, 'hue', 0, 360)) % 360;
|
|
821
|
+
expected.sat = Math.round(numberInRange(body.saturation, 'saturation', 0, 100));
|
|
822
|
+
payload = hsiPacket(expected.hue, expected.sat, expected.intensity);
|
|
823
|
+
}
|
|
824
|
+
else if (action === 'effect') {
|
|
825
|
+
const effect = effectName(body.name);
|
|
826
|
+
const options = {
|
|
827
|
+
effect,
|
|
828
|
+
intensity: expected.intensity,
|
|
829
|
+
frequency: Math.round(numberInRange(body.frequency ?? previous.frequency ?? 1, 'frequency', 1, 10)),
|
|
830
|
+
speed: Math.round(numberInRange(body.speed ?? previous.speed ?? 0, 'animation speed', 0, 10)),
|
|
831
|
+
...(body._trigger === true
|
|
832
|
+
? { trigger: 1 }
|
|
833
|
+
: body.trigger === undefined
|
|
834
|
+
? {}
|
|
835
|
+
: { trigger: Math.round(numberInRange(body.trigger, 'trigger mode', 0, 2)) }),
|
|
836
|
+
cct: Math.round(numberInRange(body.kelvin ?? previous.cct ?? 3200, 'CCT', caps.cct_min, caps.cct_max) / 100) *
|
|
837
|
+
100,
|
|
838
|
+
gm: caps.gm_support ? Math.round(numberInRange(body.gm ?? previous.gm ?? 0, 'G/M', -100, 100) / 10) * 10 : 0,
|
|
839
|
+
palette: Math.round(numberInRange(body.palette ?? previous.palette ?? 0, 'palette', 0, 2)),
|
|
840
|
+
...(body.hue === undefined ? {} : { hue: Math.round(numberInRange(body.hue, 'hue', 0, 360)) % 360 }),
|
|
841
|
+
...(body.saturation === undefined
|
|
842
|
+
? {}
|
|
843
|
+
: { saturation: Math.round(numberInRange(body.saturation, 'saturation', 0, 100)) }),
|
|
844
|
+
};
|
|
845
|
+
payload = effectPacket(options);
|
|
846
|
+
Object.assign(expected, { mode: 'effect', effect, frequency: options.frequency });
|
|
847
|
+
if (options.hue !== undefined) {
|
|
848
|
+
expected.hue = options.hue;
|
|
849
|
+
expected.sat = options.saturation;
|
|
850
|
+
}
|
|
851
|
+
else if (['paparazzi', 'lightning', 'faulty-bulb', 'pulsing', 'strobe', 'explosion'].includes(effect)) {
|
|
852
|
+
expected.cct = options.cct;
|
|
853
|
+
if (caps.gm_support)
|
|
854
|
+
expected.gm = options.gm;
|
|
855
|
+
}
|
|
856
|
+
if (['lightning', 'faulty-bulb', 'pulsing'].includes(effect))
|
|
857
|
+
expected.speed = options.speed;
|
|
858
|
+
if (options.trigger !== undefined)
|
|
859
|
+
expected.trigger = options.trigger;
|
|
860
|
+
if (['tv', 'fire', 'fireworks', 'cop-car'].includes(effect))
|
|
861
|
+
expected.palette = options.palette;
|
|
862
|
+
if (effect === 'party-lights')
|
|
863
|
+
expected.sat = options.saturation ?? 100;
|
|
864
|
+
}
|
|
865
|
+
else
|
|
866
|
+
throw new Error(`Unsupported action: ${action}`);
|
|
867
|
+
}
|
|
868
|
+
return { light, payload, expected };
|
|
869
|
+
}
|
|
870
|
+
matches(state, expected) {
|
|
871
|
+
return Object.entries(expected).every(([field, value]) => state[field] === value);
|
|
872
|
+
}
|
|
873
|
+
async apply(item, signal) {
|
|
874
|
+
let lastError;
|
|
875
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
876
|
+
signal?.throwIfAborted();
|
|
877
|
+
try {
|
|
878
|
+
if (!this.link.ready)
|
|
879
|
+
await this.link.connect();
|
|
880
|
+
signal?.throwIfAborted();
|
|
881
|
+
await this.link.send(item.light.address, item.payload);
|
|
882
|
+
await delay(200);
|
|
883
|
+
const state = await this.link.readState(item.light.address);
|
|
884
|
+
if (this.matches(state, item.expected))
|
|
885
|
+
return state;
|
|
886
|
+
throw new Error(`Fixture readback mismatch: wanted ${JSON.stringify(item.expected)}, observed ${JSON.stringify(state)}`);
|
|
887
|
+
}
|
|
888
|
+
catch (error) {
|
|
889
|
+
lastError = error;
|
|
890
|
+
console.error(`${item.light.name} attempt ${attempt + 1}/3 failed: ${error.message}`);
|
|
891
|
+
if (attempt < 2) {
|
|
892
|
+
await this.link.disconnect();
|
|
893
|
+
await delay(300);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
throw new Error(`Unable to verify ${item.light.name}: ${lastError.message}`);
|
|
898
|
+
}
|
|
899
|
+
async readWithReconnect(address, signal) {
|
|
900
|
+
return this.telemetryWithReconnect(() => this.link.readState(address), signal);
|
|
901
|
+
}
|
|
902
|
+
async readFanWithReconnect(address, signal) {
|
|
903
|
+
if (!this.link.readFan)
|
|
904
|
+
throw new Error('Fan readback is unavailable');
|
|
905
|
+
const readFan = this.link.readFan.bind(this.link);
|
|
906
|
+
return this.telemetryWithReconnect(async () => describeFan(FanStateSchema.parse(await readFan(address))), signal);
|
|
907
|
+
}
|
|
908
|
+
async telemetryWithReconnect(read, signal) {
|
|
909
|
+
for (let attempt = 0;; attempt++) {
|
|
910
|
+
signal?.throwIfAborted();
|
|
911
|
+
try {
|
|
912
|
+
if (!this.link.ready)
|
|
913
|
+
await this.link.connect();
|
|
914
|
+
signal?.throwIfAborted();
|
|
915
|
+
return await read();
|
|
916
|
+
}
|
|
917
|
+
catch (error) {
|
|
918
|
+
if (attempt === 1 || signal?.aborted)
|
|
919
|
+
throw error;
|
|
920
|
+
console.error(`BLE telemetry read failed; reconnecting: ${error.message}`);
|
|
921
|
+
await this.link.disconnect();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
async stop() {
|
|
926
|
+
this.stopping = true;
|
|
927
|
+
await this.queue;
|
|
928
|
+
await this.link.disconnect();
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
//# sourceMappingURL=controller.js.map
|