diff --git a/config.schema.json b/config.schema.json new file mode 100644 index 0000000..47094aa --- /dev/null +++ b/config.schema.json @@ -0,0 +1,119 @@ +{ + "pluginAlias": "Flux", + "pluginType": "platform", + "singular": true, + "schema": { + "type": "object", + "properties": { + "ipAddress": { + "title": "IP Address", + "type": "string", + "required": true + }, + "userName": { + "title": "User Name", + "type": "string", + "required": false + }, + "clientKey": { + "title": "Client Key", + "type": "string", + "required": false + }, + "latitude": { + "title": "Latitude", + "type": "number", + "required": true + }, + "longitude": { + "title": "Longitude", + "type": "string", + "required": true + }, + "hueLights": { + "title": "Hue Lights", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "ip": { + "title": "Name", + "type": "string", + "required": true + }, + "cron": { + "title": "Poll Delay in MS", + "type": "string", + "required": false + } + } + }, + "required": true + }, + "wizLights": { + "title": "Wiz Lights", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "ip": { + "title": "Ip Address", + "type": "string", + "required": true + }, + "mac": { + "title": "Mac Address", + "type": "string", + "required": true + }, + "cron": { + "title": "Poll Delay in MS", + "type": "string", + "required": false + } + } + }, + "required": false + }, + "wizDiscoveryEnabled": { + "title": "Wiz Discovery Enabled", + "type": "boolean", + "required": true + }, + "ceilingColorTemp": { + "title": "Ceiling Color Temperature", + "type": "number", + "required": true + }, + "sunsetColorTemp": { + "title": "Sunset Color Temperature", + "type": "number", + "required": true + }, + "floorColorTemp": { + "title": "Floor Color Temperature", + "type": "number", + "required": true + }, + "sunsetDuration": { + "title": "Sunset Duration", + "type": "number", + "required": true + }, + "transition": { + "title": "Transition Time", + "type": "number", + "required": true + }, + "delay": { + "title": "Poll Delay in MS", + "type": "number", + "required": true + } + } + }, + "form": null, + "display": null +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0f16a45..bee11ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -119,9 +119,9 @@ "integrity": "sha512-XLD/llTSB6EBe3thkN+/I0L+yCTB6sjrcVovQdx2Cnl6N6bTzHmwe/J8mWnsXFgxLrj/emzdv8IR4evKYG2qxQ==" }, "node_modules/@watsonb8/wiz-lib": { - "version": "1.0.1-1c687.0", - "resolved": "http://10.44.1.6:4873/@watsonb8%2fwiz-lib/-/wiz-lib-1.0.1-1c687.0.tgz", - "integrity": "sha1-dag89Ke1YURYSIwyWkQM/lCbvPI=", + "version": "1.0.1-9548b.0", + "resolved": "http://10.44.1.6:4873/@watsonb8%2fwiz-lib/-/wiz-lib-1.0.1-9548b.0.tgz", + "integrity": "sha1-PL/Phi0KQ5DJGRspSHA+i1lprp4=", "dependencies": { "dgram": "^1.0.1", "getmac": "^5.20.0", @@ -1967,9 +1967,9 @@ "integrity": "sha512-XLD/llTSB6EBe3thkN+/I0L+yCTB6sjrcVovQdx2Cnl6N6bTzHmwe/J8mWnsXFgxLrj/emzdv8IR4evKYG2qxQ==" }, "@watsonb8/wiz-lib": { - "version": "1.0.1-1c687.0", - "resolved": "http://10.44.1.6:4873/@watsonb8%2fwiz-lib/-/wiz-lib-1.0.1-1c687.0.tgz", - "integrity": "sha1-dag89Ke1YURYSIwyWkQM/lCbvPI=", + "version": "1.0.1-9548b.0", + "resolved": "http://10.44.1.6:4873/@watsonb8%2fwiz-lib/-/wiz-lib-1.0.1-9548b.0.tgz", + "integrity": "sha1-PL/Phi0KQ5DJGRspSHA+i1lprp4=", "requires": { "dgram": "^1.0.1", "getmac": "^5.20.0", diff --git a/package.json b/package.json index dc10c60..3e00975 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ }, "files": [ "bin", - "src" + "src", + "config.schema.json" ], "scripts": { "build": "tsc", diff --git a/src/fluxAccessory.ts b/src/fluxAccessory.ts index 329c87a..f4ec616 100644 --- a/src/fluxAccessory.ts +++ b/src/fluxAccessory.ts @@ -6,11 +6,12 @@ import { IConfig } from "./models/iConfig"; //@ts-ignore import { GetTimesResult, getTimes } from "suncalc"; import HueError = require("node-hue-api/lib/HueError"); -import cron from "node-cron"; +import cron, { ScheduledTask } from "node-cron"; import { WizBulb } from "@watsonb8/wiz-lib/build/wizBulb"; import { colorTemperature2rgb, RGB } from "@watsonb8/wiz-lib"; import { PlatformAccessory } from "homebridge"; import { Platform } from "./platform"; +import { colorTempToRgb } from "./util/colorUtil"; const SECONDS_IN_DAY = 86400000; const MINUTES_IN_MILLISECOND = 60000; @@ -29,6 +30,9 @@ export class FluxAccessory { private readonly _accessory: PlatformAccessory; private _config: IConfig; private _isActive: boolean; + private _hueRGB: RGB; + private _wizRGB: RGB; + private _fade: number; //Service fields private _switchService; @@ -39,6 +43,7 @@ export class FluxAccessory { private _wizLights: Array = []; private _times: GetTimesResult; + private _tasks: Array = []; constructor(props: IFluxProps) { //Assign class variables @@ -47,6 +52,11 @@ export class FluxAccessory { this._config = props.config; this._isActive = false; this._wizLights = props.wizBulbs; + this._hue = props.hue; + this.name = this._config.name; + this._hueRGB = { r: 0, g: 0, b: 0 }; + this._wizRGB = { r: 0, g: 0, b: 0 }; + this._fade = this._config.transition ?? 30000; this._times = getTimes( new Date(), @@ -70,8 +80,20 @@ export class FluxAccessory { } ).start(); - this._hue = props.hue; - this.name = this._config.name; + //Schedule job to refresh hues every minute + this.updateRGB(); + cron.schedule( + "* * * * *", + () => { + this.updateRGB(); + this._platform.log.info("Updated hues"); + }, + { + scheduled: true, + } + ).start(); + + this.scheduleLights(); this._accessory .getService(this._platform.api.hap.Service.AccessoryInformation)! @@ -126,9 +148,11 @@ export class FluxAccessory { this._config.latitude, this._config.longitude ); - this.update(); + this._isActive = true; + this.enable(); } else { this._isActive = false; + this.disable(); } return callback(); }; @@ -148,112 +172,19 @@ export class FluxAccessory { * Populates internal lights array using the configuration values */ private getLights = async (): Promise => { - for (const value of this._config.lights) { + for (const value of this._config.hueLights) { //@ts-ignore - const light: Light = await this._hue.lights.getLightByName(value); + const light: Light = await this._hue.lights.getLightByName( + value.name + ); this._lights.push(light); } }; - private colorTempToRgb = (kelvin: number): RGB => { - var temp = kelvin / 100; - var red, green, blue; - if (temp <= 66) { - red = 255; - green = temp; - green = 99.4708025861 * Math.log(green) - 161.1195681661; - - if (temp <= 19) { - blue = 0; - } else { - blue = temp - 10; - blue = 138.5177312231 * Math.log(blue) - 305.0447927307; - } - } else { - red = temp - 60; - red = 329.698727446 * Math.pow(red, -0.1332047592); - - green = temp - 60; - green = 288.1221695283 * Math.pow(green, -0.0755148492); - - blue = 255; - } - return { - r: this.clamp(red, 0, 255), - g: this.clamp(green, 0, 255), - b: this.clamp(blue, 0, 255), - }; - }; - - private clamp(x: number, min: number, max: number) { - if (x < min) { - return min; - } - if (x > max) { - return max; - } - return x; - } - private isHueError = (object: any): object is HueError => { return "_hueError" in object; }; - private setHueLights = async (state: LightState) => { - const promises: Array | PromiseLike> = []; - this._lights.map(async (light: Light) => { - try { - await this._hue.lights.setLightState(light.id, state); - } catch (err) { - if ( - this.isHueError(err) && - err.message === - "parameter, xy, is not modifiable. Device is set to off." - ) { - //Eat this - } else { - this._platform.log.info( - `Error while setting lights: ${err}` - ); - } - } - }); - - await Promise.all(promises); - }; - - private setWizLights = async (rgb: RGB, fade: number): Promise => { - await Promise.all( - this._wizLights.map(async (bulb) => { - const pilot = await bulb.get(); - bulb.set(rgb, pilot?.dimming, fade); - }) - ); - return; - }; - - /** - * Helper function to generate a UUID - */ - private generateUUID(): string { - // Public Domain/MIT - var d = new Date().getTime(); - if ( - typeof performance !== "undefined" && - typeof performance.now === "function" - ) { - d += performance.now(); //use high-precision timer if available - } - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace( - /[xy]/g, - function (c) { - var r = (d + Math.random() * 16) % 16 | 0; - d = Math.floor(d / 16); - return (c === "x" ? r : (r & 0x3) | 0x8).toString(16); - } - ); - } - /** * Gets adjusted color temperature. */ @@ -283,84 +214,142 @@ export class FluxAccessory { } } - private update = async (): Promise => { - this._isActive = true; - while (this._isActive) { - if (this._lights.length === 0) { - await this.getLights(); + private updateRGB = (): void => { + const now = this.getNow(); + //Pad start time by an hour before sunset + const start = new Date( + this._times.sunset.getTime() - 60 * MINUTES_IN_MILLISECOND + ); + const sunsetStart = this._times.sunsetStart; + const sunsetEnd = new Date( + this._times.sunset.getTime() + this._config.sunsetDuration + ); + const nightStart = new Date( + sunsetEnd.getTime() + 60 * MINUTES_IN_MILLISECOND + ); + const sunrise = new Date( + this._times.sunrise.getTime() + 1 * SECONDS_IN_DAY + ); + + const startColorTemp = this._config.ceilingColorTemp ?? 4000; + const sunsetColorTemp = this._config.sunsetColorTemp ?? 2800; + const floorColorTemp = this._config.floorColorTemp ?? 1900; + + let newTemp = 0; + + if (start < now && now < sunsetStart) { + newTemp = this.getTempOffset( + startColorTemp, + sunsetColorTemp, + start, + sunsetStart + ); + } else if (sunsetStart < now && now < sunsetEnd) { + newTemp = this._config.sunsetColorTemp; + } else if (sunsetEnd < now && now < nightStart) { + newTemp = this.getTempOffset( + sunsetColorTemp, + floorColorTemp, + sunsetEnd, + nightStart + ); + } else if (nightStart < now && now < sunrise) { + newTemp = this._config.floorColorTemp; + } + + //Set RGB + this._hueRGB = colorTempToRgb(newTemp); + this._wizRGB = colorTemperature2rgb(newTemp); + }; + + private scheduleLights = async (): Promise => { + if (this._lights.length === 0) { + await this.getLights(); + } + this._tasks = [...this.getHueTasks(), ...this.getWizTasks()]; + }; + + private getHueTasks(): Array { + return this._config.hueLights.map((hueLightConfig) => { + let light = this._lights.find((x) => x.name == hueLightConfig.name); + let schedule: string = hueLightConfig.cron ?? "*/6 * * * *"; + this._platform.log.info( + `Scheduling task for ${light?.name}: ${schedule}` + ); + return cron.schedule( + schedule, + async () => { + await this.updateHueLight(light); + this._platform.log.info("Updated hues"); + }, + { + scheduled: false, + } + ); + }); + } + + private getWizTasks(): Array { + return this._wizLights.map((wizBulb) => { + let wizLightConfig = this._config.wizLights.find( + (x) => x.ip == wizBulb.getIp() + ); + let schedule: string = wizLightConfig?.cron ?? "*/6 * * * *"; + this._platform.log.info( + `Scheduling task for ${wizBulb.getMac()}: ${schedule}` + ); + return cron.schedule( + schedule, + async () => { + await this.updateWizLight(wizBulb); + this._platform.log.info("Updated hues"); + }, + { + scheduled: false, + } + ); + }); + } + + private updateWizLight = async (wizBulb: WizBulb): Promise => { + const pilot = await wizBulb.get(); + this._platform.log.info(`Adjusting wiz bulb: ${wizBulb.getMac()}`); + wizBulb.set(this._wizRGB, pilot?.dimming, this._fade); + }; + + private updateHueLight = async ( + hueLight: Light | undefined + ): Promise => { + if (!hueLight) { + return; + } + + this._platform.log.info(`Adjusting wiz bulb: ${hueLight.name}`); + + const lightState = new LightState(); + lightState + .transitionInMillis(this._fade) + .rgb(this._hueRGB.r ?? 0, this._hueRGB.g ?? 0, this._hueRGB.b ?? 0); + try { + await this._hue.lights.setLightState(hueLight.id, lightState); + } catch (err) { + if ( + this.isHueError(err) && + err.message === + "parameter, xy, is not modifiable. Device is set to off." + ) { + //Eat this + } else { + this._platform.log.info(`Error while setting lights: ${err}`); } - - const now = this.getNow(); - //Pad start time by an hour before sunset - const start = new Date( - this._times.sunset.getTime() - 60 * MINUTES_IN_MILLISECOND - ); - const sunsetStart = this._times.sunsetStart; - const sunsetEnd = new Date( - this._times.sunset.getTime() + this._config.sunsetDuration - ); - const nightStart = new Date( - sunsetEnd.getTime() + 60 * MINUTES_IN_MILLISECOND - ); - const sunrise = new Date( - this._times.sunrise.getTime() + 1 * SECONDS_IN_DAY - ); - - const startColorTemp = this._config.ceilingColorTemp - ? this._config.ceilingColorTemp - : 4000; - const sunsetColorTemp = this._config.sunsetColorTemp - ? this._config.sunsetColorTemp - : 2800; - const floorColorTemp = this._config.floorColorTemp - ? this._config.floorColorTemp - : 1900; - - let newTemp = 0; - - if (start < now && now < sunsetStart) { - newTemp = this.getTempOffset( - startColorTemp, - sunsetColorTemp, - start, - sunsetStart - ); - } else if (sunsetStart < now && now < sunsetEnd) { - newTemp = this._config.sunsetColorTemp; - } else if (sunsetEnd < now && now < nightStart) { - newTemp = this.getTempOffset( - sunsetColorTemp, - floorColorTemp, - sunsetEnd, - nightStart - ); - } else if (nightStart < now && now < sunrise) { - newTemp = this._config.floorColorTemp; - } - - //Set lights - const hueRGB = this.colorTempToRgb(newTemp); - const wizRGB = colorTemperature2rgb(newTemp); - if (hueRGB && newTemp !== 0) { - const lightState = new LightState(); - lightState - .transitionInMillis( - this._config.transition ? this._config.transition : 5000 - ) - .rgb(hueRGB.r ?? 0, hueRGB.g ?? 0, hueRGB.b ?? 0); - await this.setHueLights(lightState); - await this.setWizLights( - wizRGB, - this._config.transition ? this._config.transition / 1000 : 5 - ); - this._platform.log.info( - `Adjusting light temp to ${newTemp}, ${JSON.stringify( - hueRGB - )}` - ); - } - - await Sleep(this._config.delay ? this._config.delay : 60000); } }; + + private enable() { + this._tasks.forEach((task) => task.start()); + } + + private disable() { + this._tasks.forEach((task) => task.stop()); + } } diff --git a/src/models/iConfig.ts b/src/models/iConfig.ts index c9cd686..f7116f8 100644 --- a/src/models/iConfig.ts +++ b/src/models/iConfig.ts @@ -17,7 +17,14 @@ export interface IConfig { /** * The list of lights to affect */ - lights: Array; + hueLights: Array<{ name: string; cron?: string }>; + + /** + * The list of wiz lights to affect + */ + wizLights: Array<{ ip: string; mac: string; cron?: string }>; + + wizDiscoveryEnabled: boolean; /** * The name of the enable switch in homekit @@ -58,4 +65,4 @@ export interface IConfig { * The current formatted date and time to use with testing */ testNowDateString?: string; -} \ No newline at end of file +} diff --git a/src/platform.ts b/src/platform.ts index ac33283..c764ce1 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -1,4 +1,5 @@ -import discover from "@watsonb8/wiz-lib/build/discovery"; +import { Discover } from "@watsonb8/wiz-lib/build/discovery"; +import { WizBulb } from "@watsonb8/wiz-lib/build/wizBulb"; import { API, Characteristic, @@ -19,6 +20,7 @@ import { Sleep } from "./sleep"; export class Platform implements DynamicPlatformPlugin { private hue: Api | undefined; + private wiz: Discover; private accessory: PlatformAccessory | undefined = undefined; private config: IConfig; constructor( @@ -27,6 +29,7 @@ export class Platform implements DynamicPlatformPlugin { public readonly api: API ) { this.config = config as unknown as IConfig; + this.wiz = new Discover(); this.log.info("INFO - Registering Flux platform"); this.api.on("didFinishLaunching", this.didFinishLaunching.bind(this)); } @@ -77,7 +80,26 @@ export class Platform implements DynamicPlatformPlugin { return; } - return await discover(); + let bulbs: Array = await this.wiz.createWizBulbs( + this.config.wizLights + ); + + if (this.config.wizDiscoveryEnabled) { + let discoveredBulbs: Array = await this.wiz.discover(); + let filtered = []; + for (const bulb of discoveredBulbs) { + if ( + !bulbs.some( + (manualBulb) => manualBulb.getIp() === bulb.getIp() + ) + ) { + filtered.push(bulb); + } + } + bulbs.push(...filtered); + } + + return bulbs; }; private connectHue = async () => { diff --git a/src/util/colorUtil.ts b/src/util/colorUtil.ts new file mode 100644 index 0000000..5fe917e --- /dev/null +++ b/src/util/colorUtil.ts @@ -0,0 +1,41 @@ +import { RGB } from "@watsonb8/wiz-lib"; + +export const colorTempToRgb = (kelvin: number): RGB => { + var temp = kelvin / 100; + var red, green, blue; + if (temp <= 66) { + red = 255; + green = temp; + green = 99.4708025861 * Math.log(green) - 161.1195681661; + + if (temp <= 19) { + blue = 0; + } else { + blue = temp - 10; + blue = 138.5177312231 * Math.log(blue) - 305.0447927307; + } + } else { + red = temp - 60; + red = 329.698727446 * Math.pow(red, -0.1332047592); + + green = temp - 60; + green = 288.1221695283 * Math.pow(green, -0.0755148492); + + blue = 255; + } + return { + r: clamp(red, 0, 255), + g: clamp(green, 0, 255), + b: clamp(blue, 0, 255), + }; +}; + +const clamp = (x: number, min: number, max: number) => { + if (x < min) { + return min; + } + if (x > max) { + return max; + } + return x; +};