diff --git a/plugins/mqtt/package-lock.json b/plugins/mqtt/package-lock.json index 3ca04aa13d..76febc6adb 100644 --- a/plugins/mqtt/package-lock.json +++ b/plugins/mqtt/package-lock.json @@ -1,14 +1,13 @@ { "name": "@scrypted/mqtt", - "version": "0.0.76", + "version": "0.0.77", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@scrypted/mqtt", - "version": "0.0.76", + "version": "0.0.77", "dependencies": { - "@types/node": "^16.6.1", "aedes": "^0.46.1", "axios": "^0.23.0", "mqtt": "^4.2.8", @@ -18,6 +17,7 @@ "devDependencies": { "@scrypted/common": "file:../../common", "@scrypted/sdk": "file:../../sdk", + "@types/node": "^18.4.2", "@types/nunjucks": "^3.2.0" } }, @@ -29,49 +29,50 @@ "dependencies": { "@scrypted/sdk": "file:../sdk", "@scrypted/server": "file:../server", - "http-auth-utils": "^3.0.2", - "node-fetch-commonjs": "^3.1.1", - "typescript": "^4.4.3" + "http-auth-utils": "^5.0.1", + "typescript": "^5.3.3" }, "devDependencies": { - "@types/node": "^16.9.0" + "@types/node": "^20.11.0", + "ts-node": "^10.9.2" } }, "../../sdk": { "name": "@scrypted/sdk", - "version": "0.0.206", + "version": "0.3.5", "dev": true, "license": "ISC", "dependencies": { - "@babel/preset-typescript": "^7.16.7", + "@babel/preset-typescript": "^7.18.6", "adm-zip": "^0.4.13", - "axios": "^0.21.4", - "babel-loader": "^8.2.3", + "axios": "^1.6.5", + "babel-loader": "^9.1.0", "babel-plugin-const-enum": "^1.1.0", - "esbuild": "^0.13.8", + "esbuild": "^0.15.9", "ncp": "^2.0.0", "raw-loader": "^4.0.2", "rimraf": "^3.0.2", "tmp": "^0.2.1", - "webpack": "^5.59.0" + "ts-loader": "^9.4.2", + "typescript": "^4.9.4", + "webpack": "^5.75.0", + "webpack-bundle-analyzer": "^4.5.0" }, "bin": { + "scrypted-changelog": "bin/scrypted-changelog.js", "scrypted-debug": "bin/scrypted-debug.js", "scrypted-deploy": "bin/scrypted-deploy.js", "scrypted-deploy-debug": "bin/scrypted-deploy-debug.js", "scrypted-package-json": "bin/scrypted-package-json.js", - "scrypted-readme": "bin/scrypted-readme.js", "scrypted-setup-project": "bin/scrypted-setup-project.js", "scrypted-webpack": "bin/scrypted-webpack.js" }, "devDependencies": { - "@types/node": "^16.11.1", + "@types/node": "^18.11.18", "@types/stringify-object": "^4.0.0", "stringify-object": "^3.3.0", "ts-node": "^10.4.0", - "typedoc": "^0.22.8", - "typescript-json-schema": "^0.50.1", - "webpack-bundle-analyzer": "^4.5.0" + "typedoc": "^0.23.21" } }, "../sdk": { @@ -86,9 +87,13 @@ "link": true }, "node_modules/@types/node": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.6.1.tgz", - "integrity": "sha512-Sr7BhXEAer9xyGuCN3Ek9eg9xPviCF2gfu9kTfuU2HkTVAMYSDeX40fvpmo72n5nansg3nsBjuQBrsS28r+NUw==" + "version": "18.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.15.tgz", + "integrity": "sha512-AMZ2UWx+woHNfM11PyAEQmfSxi05jm9OlkxczuHeEqmvwPkYj6MWv44gbzDPefYOLysTOFyI3ziiy2ONmUZfpA==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/nunjucks": { "version": "3.2.0", @@ -359,9 +364,9 @@ "integrity": "sha512-XBU9RXeoYc2/VnvMhplAxEmZLfIk7cvTBu+xwoBuTI8pL19E03cmca17QQycKIdxgwCeFA/a4u27gv1h3ya5LQ==" }, "node_modules/follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "funding": [ { "type": "individual", @@ -502,9 +507,9 @@ } }, "node_modules/minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -574,9 +579,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nunjucks": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.3.tgz", - "integrity": "sha512-psb6xjLj47+fE76JdZwskvwG4MYsQKXUtMsPh6U0YMvmyjRtKRFcxnlXGWglNybtNTNVmGdp94K62/+NjF5FDQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", "dependencies": { "a-sync-waterfall": "^1.0.0", "asap": "^2.0.3", @@ -717,6 +722,12 @@ "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz", "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==" }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -836,39 +847,44 @@ "requires": { "@scrypted/sdk": "file:../sdk", "@scrypted/server": "file:../server", - "@types/node": "^16.9.0", - "http-auth-utils": "^3.0.2", - "node-fetch-commonjs": "^3.1.1", - "typescript": "^4.4.3" + "@types/node": "^20.11.0", + "http-auth-utils": "^5.0.1", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" } }, "@scrypted/sdk": { "version": "file:../../sdk", "requires": { - "@babel/preset-typescript": "^7.16.7", - "@types/node": "^16.11.1", + "@babel/preset-typescript": "^7.18.6", + "@types/node": "^18.11.18", "@types/stringify-object": "^4.0.0", "adm-zip": "^0.4.13", - "axios": "^0.21.4", - "babel-loader": "^8.2.3", + "axios": "^1.6.5", + "babel-loader": "^9.1.0", "babel-plugin-const-enum": "^1.1.0", - "esbuild": "^0.13.8", + "esbuild": "^0.15.9", "ncp": "^2.0.0", "raw-loader": "^4.0.2", "rimraf": "^3.0.2", "stringify-object": "^3.3.0", "tmp": "^0.2.1", + "ts-loader": "^9.4.2", "ts-node": "^10.4.0", - "typedoc": "^0.22.8", - "typescript-json-schema": "^0.50.1", - "webpack": "^5.59.0", + "typedoc": "^0.23.21", + "typescript": "^4.9.4", + "webpack": "^5.75.0", "webpack-bundle-analyzer": "^4.5.0" } }, "@types/node": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.6.1.tgz", - "integrity": "sha512-Sr7BhXEAer9xyGuCN3Ek9eg9xPviCF2gfu9kTfuU2HkTVAMYSDeX40fvpmo72n5nansg3nsBjuQBrsS28r+NUw==" + "version": "18.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.15.tgz", + "integrity": "sha512-AMZ2UWx+woHNfM11PyAEQmfSxi05jm9OlkxczuHeEqmvwPkYj6MWv44gbzDPefYOLysTOFyI3ziiy2ONmUZfpA==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } }, "@types/nunjucks": { "version": "3.2.0", @@ -1087,9 +1103,9 @@ "integrity": "sha512-XBU9RXeoYc2/VnvMhplAxEmZLfIk7cvTBu+xwoBuTI8pL19E03cmca17QQycKIdxgwCeFA/a4u27gv1h3ya5LQ==" }, "follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==" + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==" }, "from2": { "version": "2.3.0", @@ -1195,9 +1211,9 @@ "integrity": "sha1-wuep93IJTe6dNCAq6KzORoeHVYA=" }, "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "requires": { "brace-expansion": "^1.1.7" } @@ -1253,9 +1269,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "nunjucks": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.3.tgz", - "integrity": "sha512-psb6xjLj47+fE76JdZwskvwG4MYsQKXUtMsPh6U0YMvmyjRtKRFcxnlXGWglNybtNTNVmGdp94K62/+NjF5FDQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", "requires": { "a-sync-waterfall": "^1.0.0", "asap": "^2.0.3", @@ -1355,6 +1371,12 @@ "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz", "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==" }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/plugins/mqtt/package.json b/plugins/mqtt/package.json index e32baf5c15..9f6458c336 100644 --- a/plugins/mqtt/package.json +++ b/plugins/mqtt/package.json @@ -29,7 +29,6 @@ ] }, "dependencies": { - "@types/node": "^16.6.1", "aedes": "^0.46.1", "axios": "^0.23.0", "mqtt": "^4.2.8", @@ -37,9 +36,10 @@ "websocket-stream": "^5.5.2" }, "devDependencies": { - "@scrypted/sdk": "file:../../sdk", "@scrypted/common": "file:../../common", + "@scrypted/sdk": "file:../../sdk", + "@types/node": "^18.4.2", "@types/nunjucks": "^3.2.0" }, - "version": "0.0.76" + "version": "0.0.77" } diff --git a/plugins/mqtt/src/autodiscovery.ts b/plugins/mqtt/src/autodiscovery.ts index 91cc4ff5ba..ff130638a7 100644 --- a/plugins/mqtt/src/autodiscovery.ts +++ b/plugins/mqtt/src/autodiscovery.ts @@ -1,10 +1,11 @@ import crypto from 'crypto'; -import { Brightness, DeviceProvider, Lock, LockState, MixinDeviceBase, OnOff, ScryptedDevice, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedInterfaceProperty, Setting, Settings } from "@scrypted/sdk"; +import { Online, Brightness, ColorSettingHsv, ColorSettingTemperature, DeviceProvider, Lock, LockState, MixinDeviceBase, OnOff, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedInterfaceProperty } from "@scrypted/sdk"; import { Client, MqttClient, connect } from "mqtt"; import { MqttDeviceBase } from "./api/mqtt-device-base"; import nunjucks from 'nunjucks'; import sdk from "@scrypted/sdk"; import type { MqttProvider } from './main'; +import { getHsvFromXyColor, getXyYFromHsvColor } from './color-util'; const { deviceManager } = sdk; @@ -20,7 +21,20 @@ typeMap.set('switch', { type: ScryptedDeviceType.Switch, }); typeMap.set('light', { - interfaces: [ScryptedInterface.OnOff, ScryptedInterface.Brightness], + getInterfaces(config: any) { + const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Brightness]; + if (config.color_mode) { + config.supported_color_modes.forEach(color_mode => { + if (color_mode === 'xy') + interfaces.push(ScryptedInterface.ColorSettingHsv); + else if (color_mode === 'hs') + interfaces.push(ScryptedInterface.ColorSettingHsv); + else if (color_mode === 'color_temp') + interfaces.push(ScryptedInterface.ColorSettingTemperature); + }); + } + return interfaces; + }, type: ScryptedDeviceType.Light, }); typeMap.set('lock', { @@ -102,7 +116,7 @@ export class MqttAutoDiscoveryProvider extends MqttDeviceBase implements DeviceP const nativeId = 'autodiscovered:' + this.nativeId + ':' + nativeIdSuffix; - let deviceInterfaces: string[]; + let deviceInterfaces: string[] if (type.interfaces) deviceInterfaces = type.interfaces; else @@ -111,8 +125,10 @@ export class MqttAutoDiscoveryProvider extends MqttDeviceBase implements DeviceP if (!deviceInterfaces) return; + deviceInterfaces.push(ScryptedInterface.Online); + let interfaces = [ - '@scrypted/mqtt', + '@scrypted/mqtt' ]; interfaces.push(...deviceInterfaces); // try combine into existing device if this mqtt device presents @@ -196,13 +212,26 @@ function scaleBrightness(scryptedBrightness: number, brightnessScale: number) { return Math.round(scryptedBrightness * brightnessScale / 100); } +function getMiredFromKelvin(kelvin: number) { + return Math.round(1000000 / kelvin); +} + +function getKelvinFromMired(mired: number) { + return Math.round(1000000 / mired); +} + function unscaleBrightness(mqttBrightness: number, brightnessScale: number) { brightnessScale = brightnessScale || 255; return Math.round(mqttBrightness * 100 / brightnessScale); } -export class MqttAutoDiscoveryDevice extends ScryptedDeviceBase implements OnOff, Brightness, Lock { +export class MqttAutoDiscoveryDevice extends ScryptedDeviceBase implements Online, OnOff, Brightness, Lock, ColorSettingTemperature, ColorSettingHsv { messageListeners: ((topic: string, payload: Buffer) => void)[] = []; + debounceCallbacks: Map void>>; + modelId: any; + xyY: { x: number; y: number; brightness: number; }; + colorMode: string; + constructor(nativeId: string, public provider: MqttAutoDiscoveryProvider, noBind?: boolean) { super(nativeId); @@ -216,6 +245,13 @@ export class MqttAutoDiscoveryDevice extends ScryptedDeviceBase implements OnOff this.console.warn('delayed bind') return; } + + this.debounceCallbacks = new Map void>>(); + + const { client } = provider; + client.on('message', this.listener.bind(this)); + this.messageListeners.push(this.listener.bind(this)); + this.bind(); } @@ -227,25 +263,135 @@ export class MqttAutoDiscoveryDevice extends ScryptedDeviceBase implements OnOff } bindMessage(topic: string, cb: (payload: Buffer) => void) { - this.console.log('subscribing', topic); - const listener = (messageTopic: string, payload: Buffer) => { - if (topic !== messageTopic) - return; - this.console.log('message', topic, payload?.toString()); - try { - cb(payload); - } - catch (e) { - this.console.error('callback error', e); - } - }; - this.provider.client.on('message', listener); - this.messageListeners.push(listener); + let set: Set<(payload: Buffer) => void> = this.debounceCallbacks.get(topic); + if (set) { + this.console.log('subscribing', topic); + + set.add(cb); + } else { + this.console.log('subscribing to new topic', topic); + + set = new Set([cb]); + this.debounceCallbacks.set(topic, set); + } + } + + listener(topic: string, payload: Buffer) { + let set = this.debounceCallbacks?.get(topic); + + if (!set) + return; + + this.console.log('message', topic, payload?.toString()); + try { + set.forEach(callback => { + callback(payload); + }); + } + catch (e) { + this.console.error('callback error', e); + } } bind() { this.console.log('binding...'); const { client } = this.provider; + + this.debounceCallbacks = new Map void>>(); + + if (this.providedInterfaces.includes(ScryptedInterface.Online)) { + const config = this.loadComponentConfig(ScryptedInterface.Online); + if (config.availability && config.availability.length > 0) { + const availabilityTopic = config.availability[0].topic; + client.subscribe(availabilityTopic); + this.bindMessage(availabilityTopic, + payload => this.online = + (config.payload_on || 'online') === this.eval(config.availability[0].value_template || '{{ value_json.state }}', payload)); + } + } + if (this.providedInterfaces.includes(ScryptedInterface.ColorSettingHsv)) { + const config = this.loadComponentConfig(ScryptedInterface.ColorSettingHsv); + const colorStateTopic = config.hs_state_topic || config.state_topic; + client.subscribe(colorStateTopic); + this.bindMessage(colorStateTopic, + payload => { + let obj = JSON.parse(payload.toString()); + + // exit updating the below because the user set the color_temp + if (obj.color_mode !== "xy" && obj.color_mode !== "hs") { + this.hsv = undefined; + return; + } + + // handle hs_value_template if present + if (config.hs_value_template) { + this.hsv = this.eval(config.hs_value_template, payload); + return; + } + + // handle xy_value_template if present + if (config.xy_value_template) { + var xy = this.eval(config.xy_value_template, payload); + this.hsv = getHsvFromXyColor(xy.x, xy.y, this.xyY?.brightness ?? 1); + return; + } + + let color = obj.color; + this.modelId = obj.device?.model; + + // handle color_mode hs if present + if (color.h !== undefined && color.s !== undefined) { + this.colorMode = "hs"; + + // skip update if the colors match + if (color.h === this.hsv.h && color.s === this.hsv.s) + return; + + const brightness = unscaleBrightness(obj.brightness, config.brightness_scale); + this.hsv = { + h: color.h, + s: color.s, + v: brightness + }; + return; + } + + // handle color_mode xy if present + if (color.x !== undefined && color.y !== undefined) { + this.colorMode = "xy"; + + const hsv = getHsvFromXyColor(color.x, color.y, this.xyY?.brightness ?? 100); + this.hsv = { + h: hsv.h, + s: hsv.s, + v: hsv.v + }; + return; + } + }); + } + if (this.providedInterfaces.includes(ScryptedInterface.ColorSettingTemperature)) { + const config = this.loadComponentConfig(ScryptedInterface.ColorSettingTemperature); + const colorTempStateTopic = config.color_temp_command_topic || config.state_topic; + client.subscribe(colorTempStateTopic); + this.bindMessage(colorTempStateTopic, + payload => { + let obj = JSON.parse(payload.toString()); + + // exit updating the below because the user set the color_temp + if (obj.color_mode !== "color_temp") { + this.colorTemperature = undefined; + return; + } + + if (config.color_temp_value_template) { + this.colorTemperature = this.eval(config.color_temp_value_template, payload); + return; + } + + this.colorTemperature = getKelvinFromMired(obj.color_temp); + }); + } if (this.providedInterfaces.includes(ScryptedInterface.Brightness)) { const config = this.loadComponentConfig(ScryptedInterface.Brightness); const brightnessStateTopic = config.brightness_state_topic || config.state_topic; @@ -292,11 +438,13 @@ export class MqttAutoDiscoveryDevice extends ScryptedDeviceBase implements OnOff publishValue(command_topic: string, template: string, value: any, defaultValue: any) { if (value == null) value = defaultValue; + const payload = template ? nunjucks.renderString(template, { value_json: { value, } - }) : value.toString(); + }) : JSON.stringify(value); + this.provider.client.publish(command_topic, Buffer.from(payload), { qos: 1, retain: true, @@ -305,32 +453,148 @@ export class MqttAutoDiscoveryDevice extends ScryptedDeviceBase implements OnOff async turnOff(): Promise { const config = this.loadComponentConfig(ScryptedInterface.OnOff); - if (config.on_command_type === 'brightness') - return this.publishValue(config.brightness_command_topic, - config.brightness_value_template, 0, 0); - return this.publishValue(config.command_topic, - config.brightness_value_template, - config.payload_off, 'OFF'); - } + if (config.on_command_type === 'brightness') { + await this.setBrightnessInternal(0, config); + return; + } + + let command = { + state: "OFF" + }; + + if (config.command_off_template) { + this.publishValue(config.command_topic, + config.command_off_template, + command, "ON"); + } else { + this.publishValue(config.command_topic, + undefined, command, command); + } + } async turnOn(): Promise { const config = this.loadComponentConfig(ScryptedInterface.OnOff); - if (config.on_command_type === 'brightness') - return this.publishValue(config.brightness_command_topic, - config.brightness_value_template, - config.brightness_scale || 255, - config.brightness_scale || 255); - return this.publishValue(config.command_topic, - config.brightness_value_template, - config.payload_on, 'ON'); + + if (config.on_command_type === 'brightness') { + await this.setBrightnessInternal(config.brightness_scale || 255, config); + return; + } + + let command = { + state: "ON" + }; + + if (config.command_on_template) { + this.publishValue(config.command_topic, + config.command_on_template, + command, "ON"); + } else { + this.publishValue(config.command_topic, + undefined, command, command); + } } async setBrightness(brightness: number): Promise { const config = this.loadComponentConfig(ScryptedInterface.Brightness); + await this.setBrightnessInternal(brightness, config); + } + async setBrightnessInternal(brightness: number, config: any): Promise { const scaledBrightness = scaleBrightness(brightness, config.brightness_scale); - this.publishValue(config.brightness_command_topic, - config.brightness_value_template, - scaledBrightness, scaledBrightness); + + // use brightness_command_topic and fallback to JSON if not provided + if (config.brightness_value_template) { + this.publishValue(config.brightness_command_topic, + config.brightness_value_template, + scaledBrightness, scaledBrightness); + } else { + this.publishValue(config.command_topic, + `{ "state": "${ scaledBrightness === 0 ? 'OFF' : 'ON'}", "brightness": ${scaledBrightness} }`, + scaledBrightness, 255); + } + } + async getTemperatureMaxK(): Promise { + const config = this.loadComponentConfig(ScryptedInterface.ColorSettingTemperature); + return getKelvinFromMired(Math.min(config.min_mireds, config.max_mireds)); + } + async getTemperatureMinK(): Promise { + const config = this.loadComponentConfig(ScryptedInterface.ColorSettingTemperature); + return getKelvinFromMired(Math.max(config.min_mireds, config.max_mireds)); + } + async setColorTemperature(kelvin: number): Promise { + const config = this.loadComponentConfig(ScryptedInterface.ColorSettingTemperature); + + if (kelvin >= 0 || kelvin <= 100) { + const min = await this.getTemperatureMinK(); + const max = await this.getTemperatureMaxK(); + const diff = (max - min) * (kelvin/100); + kelvin = Math.round(min + diff); + } + + const mired = getMiredFromKelvin(kelvin); + const color = { + state: "ON", + //color_mode: "color_temp", + color_temp: mired ?? 370 + }; + + // use color_temp_command_topic and fallback to JSON if not provided + if (config.color_temp_command_template) { + this.publishValue(config.color_temp_command_topic, + config.color_temp_command_template, + color, color); + } else { + this.publishValue(config.command_topic, + undefined, color, color); + } + } + async setHsv(hue: number, saturation: number, value: number): Promise { + const config = this.loadComponentConfig(ScryptedInterface.ColorSettingHsv); + + this.colorMode = this.colorMode ?? (config.supported_color_modes.includes("hs") ? "hs" : "xy"); + + if (this.colorMode === "hs") { + const color = { + state: "ON", + //color_mode: "hs", + color: { + h: hue ?? 0, + s: (saturation ?? 1) * 100 + } + }; + + // use hs_command_topic and fallback to JSON if not provided + if (config.hs_command_template) { + this.publishValue(config.hs_command_topic, + config.hs_command_template, + color, color); + } else { + this.publishValue(config.command_topic, + undefined, color, color); + } + } else if (this.colorMode === "xy") { + const xy = getXyYFromHsvColor(hue, saturation, value, this.modelId); + const color = { + state: "ON", + //color_mode: "xy", + color: { + x: xy.x, + y: xy.y + } + }; + + this.xyY = xy; + + // use xy_command_template and fallback to JSON if not provided + if (config.xy_command_template) { + this.publishValue(config.xy_command_topic, + config.xy_command_template, + color, color); + } else { + this.publishValue(config.command_topic, + undefined, color, color); + } + } } + async lock(): Promise { const config = this.loadComponentConfig(ScryptedInterface.Lock); return this.publishValue(config.command_topic, diff --git a/plugins/mqtt/src/color-util.ts b/plugins/mqtt/src/color-util.ts new file mode 100644 index 0000000000..3fced0a8a4 --- /dev/null +++ b/plugins/mqtt/src/color-util.ts @@ -0,0 +1,345 @@ +export function getXyYFromHsvColor(h: number, s: number, v: number, hueModelId: string = null) { + if (s > 1 || v > 1 || h > 360) + throw new Error('invalid hsv color, h must not be greater than 360, and s and v must not be greater than 1'); + + const rgb = hsvToRgb(h, s, v); + const xyz = rgbToXyz(rgb.r, rgb.g, rgb.b); + const { x, y, z } = xyz; + + let xyY = { + x: x / (x + y + z), + y: y / (x + y + z), + brightness: y + }; + + if (!xyIsInGamutRange(xyY, hueModelId)) { + xyY = getClosestColor(xyY, hueModelId); + } + + return xyY; +} + +export function getHsvFromXyColor(x: number, y: number, brightness: number) { + if (x > 1 || y > 1 || brightness > 1) + throw new Error('invalid xy color, x, y, and brightness must not be greater than 1'); + + const Y = brightness; + const z = 1 - x - Y; + const X = (Y / y) * x; + const Z = (Y / y) * z; + const rgb = xyzToRgb(X, Y, Z); + const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b); + + const h: number = hsv[0]; + const s: number = hsv[1]; + const v: number = hsv[2]; + + return { + h, s, v + }; +} + +export function getRgbFromXyColor(x: number, y: number, brightness: number) { + if (x > 1 || y > 1 || brightness > 1) + throw new Error('invalid xy color, x, y, and brightness must not be greater than 1'); + + const Y = brightness; + const z = 1 - x - Y; + const X = (Y / y) * x; + const Z = (Y / y) * z; + const rgb = xyzToRgb(X, Y, Z); + + return rgb; +} + +export function getXyFromRgbColor(r: number, g: number, b: number, hueModelId: string = null) { + if (r > 255 || g > 255 || b > 255) + throw new Error('invalid rgb color, r, g, and b must not be greater than 255'); + + const xyz = rgbToXyz(r, g, b); + const { x, y, z } = xyz; + + let xyY = { + x: x / (x + y + z), + y: y / (x + y + z), + brightness: y + }; + + if (!xyIsInGamutRange(xyY, hueModelId)) { + xyY = getClosestColor(xyY, hueModelId); + } + + return xyY; +} + +function xyzToRgb (x: number, y: number, z: number) { + let r = (x * 3.2406) + (y * -1.5372) + (z * -0.4986); + let g = (x * -0.9689) + (y * 1.8758) + (z * 0.0415); + let b = (x * 0.0557) + (y * -0.2040) + (z * 1.0570); + + // Assume sRGB + r = r > 0.0031308 + ? ((1.055 * (r ** (1.0 / 2.4))) - 0.055) + : r * 12.92; + + g = g > 0.0031308 + ? ((1.055 * (g ** (1.0 / 2.4))) - 0.055) + : g * 12.92; + + b = b > 0.0031308 + ? ((1.055 * (b ** (1.0 / 2.4))) - 0.055) + : b * 12.92; + + r = Math.min(Math.max(0, r), 1); + g = Math.min(Math.max(0, g), 1); + b = Math.min(Math.max(0, b), 1); + + return { + r: r * 255, + g: g * 255, + b: b * 255 + }; +} + +function hsvToRgb (h: number, s: number, v: number) { + h /= 60; + + const hi = Math.floor(h) % 6; + + const f = h - Math.floor(h); + const p = 255 * v * (1 - s); + const q = 255 * v * (1 - (s * f)); + const t = 255 * v * (1 - (s * (1 - f))); + v *= 255; + + switch (hi) { + case 0: + return { r:v, g:t, b:p }; + case 1: + return { r:q, g:v, b:p }; + case 2: + return { r:p, g:v, b:t }; + case 3: + return { r:p, g:q, b:v }; + case 4: + return { r:t, g:p, b:v }; + case 5: + return { r:v, g:p, b:q }; + } +} + +function rgbToXyz(r: number, g: number, b: number) { + r /= 255; + g /= 255; + b /= 255; + + r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92; + g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92; + b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92; + + const x = r * 0.4124 + g * 0.3576 + b * 0.1805; + const y = r * 0.2126 + g * 0.7152 + b * 0.0722; + const z = r * 0.0193 + g * 0.1192 + b * 0.9505; + + return { + x, y, z + }; +} + +function rgbToHsv (r: number, g: number, b: number) { + r /= 255; + g /= 255; + b /= 255; + + const v = Math.max(r, g, b); + const diff = v - Math.min(r, g, b); + const diffc = function (c) { + return (v - c) / 6 / diff + 1 / 2; + }; + + let rdif: number; + let gdif: number; + let bdif: number; + let h: number; + let s: number; + + if (diff === 0) { + h = 0; + s = 0; + } else { + s = diff / v; + rdif = diffc(r); + gdif = diffc(g); + bdif = diffc(b); + + if (r === v) { + h = bdif - gdif; + } else if (g === v) { + h = (1 / 3) + rdif - bdif; + } else if (b === v) { + h = (2 / 3) + gdif - rdif; + } + + if (h < 0) { + h += 1; + } else if (h > 1) { + h -= 1; + } + } + + return [ + h * 360, s, v + ]; +} + +export function xyIsInGamutRange(xy: any, hueModelId: string = null) { + let gamut = getLightColorGamutRange(hueModelId); + if (Array.isArray(xy)) { + xy = { + x: xy[0], + y: xy[1] + }; + } + + let v0 = [gamut.blue[0] - gamut.red[0], gamut.blue[1] - gamut.red[1]]; + let v1 = [gamut.green[0] - gamut.red[0], gamut.green[1] - gamut.red[1]]; + let v2 = [xy.x - gamut.red[0], xy.y - gamut.red[1]]; + + let dot00 = (v0[0] * v0[0]) + (v0[1] * v0[1]); + let dot01 = (v0[0] * v1[0]) + (v0[1] * v1[1]); + let dot02 = (v0[0] * v2[0]) + (v0[1] * v2[1]); + let dot11 = (v1[0] * v1[0]) + (v1[1] * v1[1]); + let dot12 = (v1[0] * v2[0]) + (v1[1] * v2[1]); + + let invDenom = 1 / (dot00 * dot11 - dot01 * dot01); + + let u = (dot11 * dot02 - dot01 * dot12) * invDenom; + let v = (dot00 * dot12 - dot01 * dot02) * invDenom; + + return ((u >= 0) && (v >= 0) && (u + v < 1)); +} + +export function getLightColorGamutRange(hueModelId: string = null): any { + + // legacy LivingColors Bloom, Aura, Light Strips and Iris (Gamut A) + let gamutA = { + red: [0.704, 0.296], + green: [0.2151, 0.7106], + blue: [0.138, 0.08] + }; + + // older model hue bulb (Gamut B) + let gamutB = { + red: [0.675, 0.322], + green: [0.409, 0.518], + blue: [0.167, 0.04] + }; + + // newer model Hue lights (Gamut C) + let gamutC = { + red: [0.692, 0.308], + green: [0.17, 0.7], + blue: [0.153, 0.048] + }; + + let defaultGamut ={ + red: [1.0, 0], + green: [0.0, 1.0], + blue: [0.0, 0.0] + }; + + let philipsModels = { + "9290012573A": gamutB + }; + + if(!!philipsModels[hueModelId]){ + return philipsModels[hueModelId]; + } + + return defaultGamut; +} + +export function getClosestColor(xy: any, hueModelId: string = null) { + function getLineDistance(pointA, pointB){ + return Math.hypot(pointB.x - pointA.x, pointB.y - pointA.y); + } + + function getClosestPoint(xy, pointA, pointB) { + let xy2a = [xy.x - pointA.x, xy.y - pointA.y]; + let a2b = [pointB.x - pointA.x, pointB.y - pointA.y]; + let a2bSqr = Math.pow(a2b[0],2) + Math.pow(a2b[1],2); + let xy2a_dot_a2b = xy2a[0] * a2b[0] + xy2a[1] * a2b[1]; + let t = xy2a_dot_a2b /a2bSqr; + + return { + x: pointA.x + a2b[0] * t, + y: pointA.y + a2b[1] * t, + brightness: xy.brightness + } + } + + let gamut = getLightColorGamutRange(hueModelId); + + let greenBlue = { + a: { + x: gamut.green[0], + y: gamut.green[1] + }, + b: { + x: gamut.blue[0], + y: gamut.blue[1] + } + }; + + let greenRed = { + a: { + x: gamut.green[0], + y: gamut.green[1] + }, + b: { + x: gamut.red[0], + y: gamut.red[1] + } + }; + + let blueRed = { + a: { + x: gamut.red[0], + y: gamut.red[1] + }, + b: { + x: gamut.blue[0], + y: gamut.blue[1] + } + }; + + let closestColorPoints = { + greenBlue : getClosestPoint(xy,greenBlue.a,greenBlue.b), + greenRed : getClosestPoint(xy,greenRed.a,greenRed.b), + blueRed : getClosestPoint(xy,blueRed.a,blueRed.b) + }; + + let distance = { + greenBlue : getLineDistance(xy,closestColorPoints.greenBlue), + greenRed : getLineDistance(xy,closestColorPoints.greenRed), + blueRed : getLineDistance(xy,closestColorPoints.blueRed) + }; + + let closestDistance; + let closestColor; + for (let i in distance){ + if(distance.hasOwnProperty(i)){ + if(!closestDistance){ + closestDistance = distance[i]; + closestColor = i; + } + + if(closestDistance > distance[i]){ + closestDistance = distance[i]; + closestColor = i; + } + } + + } + return closestColorPoints[closestColor]; +} \ No newline at end of file diff --git a/plugins/mqtt/tsconfig.json b/plugins/mqtt/tsconfig.json index 34a847ad82..ba9b4d395b 100644 --- a/plugins/mqtt/tsconfig.json +++ b/plugins/mqtt/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "commonjs", + "module": "Node16", "target": "ES2021", "resolveJsonModule": true, "moduleResolution": "Node16",