From ab070e5e48c2d8c70fcc32d37c3e0d7d72e7ee9d Mon Sep 17 00:00:00 2001 From: Jan Scheiper Date: Tue, 17 May 2022 02:20:15 +0200 Subject: [PATCH] added support for addressable strips and subsections of addressable strips --- .jshintrc | 2 +- HCMHAddressableSubsection.js | 102 +++++++++++ HCMagicHome.js | 76 +------- HCMagicHomeAddressable.js | 334 +++++++++++++++++++++++++++++++++++ README.md | 38 +++- entry.js | 19 ++ package-lock.json | 10 +- package.json | 6 +- 8 files changed, 505 insertions(+), 82 deletions(-) create mode 100644 HCMHAddressableSubsection.js create mode 100644 HCMagicHomeAddressable.js create mode 100644 entry.js diff --git a/.jshintrc b/.jshintrc index 607a36b..bc89ad8 100644 --- a/.jshintrc +++ b/.jshintrc @@ -1,3 +1,3 @@ { - "esversion": 6 + "esversion": 9 } \ No newline at end of file diff --git a/HCMHAddressableSubsection.js b/HCMHAddressableSubsection.js new file mode 100644 index 0000000..aa8e7bb --- /dev/null +++ b/HCMHAddressableSubsection.js @@ -0,0 +1,102 @@ +const { + HCColorLamp, + StateUpdateManager, + utils, +} = require('homecontrol-control-base'); + +class HCMHAddressableSubsection extends HCColorLamp { + constructor(config, registry) { + super(config); + + if (!("parent" in this._configuration)) { + throw new Error(`Required configuration field "parent" is missing"`); + } + if (!("section_id" in this._configuration)) { + throw new Error(`Required configuration field "section_id" is missing"`); + } + if (!("section_start" in this._configuration)) { + throw new Error(`Required configuration field "section_start" is missing"`); + } + if (!("section_end" in this._configuration)) { + throw new Error(`Required configuration field "section_end" is missing"`); + } + + this._registry = registry; + this._parent = null; + + // the default state should be white and 100% brightness + this._state.brightness = 100; + this._state.color.l = 100; + } + + async init() { + for (let i = 0; i < 20; i++) { + if (this._configuration.parent in this._registry) { + this._parent = this._registry[this._configuration.parent]; + + this._parent.on("state change", state => { + this.pullState(); + }); + + return; + } else { + await utils.asyncTimeout(100); + } + } + + throw new Error("Could not connect to parent after 2 seconds"); + } + + turnOn() { + let { red, green, blue } = utils.HSL_to_RGB(this.state.color); + + red = Math.round(red * this.state.brightness / 100); + green = Math.round(green * this.state.brightness / 100); + blue = Math.round(blue * this.state.brightness / 100); + + this._state.on = true; + this.emit("state change", this.state); + + return this._parent.setSubsectionColor( + this._configuration.section_start, + this._configuration.section_end, + this._configuration.section_id, + { red, green, blue } + ); + } + + turnOff() { + this._state.on = false; + this.emit("state change", this.state); + return this._parent.removeSubsection(this._configuration.section_id); + } + + setColor(color) { + this._state.color = utils.fillPartialHSL(color, this._state.color); + + return this.turnOn(); + } + + setBrightness(brightness) { + this._state.brightness = brightness; + + return this.turnOn(); + } + + pullState() { + if (this._parent == null) return Promise.resolve(); + + const futureState = this.state; + + futureState.on = this._parent.isSubsectionActive(this._configuration.section_id); + + if (this.state.hash != futureState.hash) { + this._state = futureState; + this.emit("state change", futureState); + } + + return Promise.resolve(); + } +} + +module.exports = HCMHAddressableSubsection; \ No newline at end of file diff --git a/HCMagicHome.js b/HCMagicHome.js index 6eaa48e..e82064b 100644 --- a/HCMagicHome.js +++ b/HCMagicHome.js @@ -1,6 +1,7 @@ const { HCColorLamp, - StateUpdateManager + StateUpdateManager, + utils, } = require('homecontrol-control-base'); const { Control } = require('magic-home'); @@ -103,7 +104,7 @@ class HCMagicHome extends HCColorLamp { futureState.effect = "none"; let suid = this._sumanager.registerUpdate(futureState); - let { red, green, blue } = HSL_to_RGB(futureState.color); + let { red, green, blue } = utils.HSL_to_RGB(futureState.color); return this._control.setColorWithBrightness(red, green, blue, futureState.brightness).then(success => { this._sumanager.confirmUpdate(suid); @@ -124,7 +125,7 @@ class HCMagicHome extends HCColorLamp { futureState.effect = "none"; let suid = this._sumanager.registerUpdate(futureState); - let { red, green, blue } = HSL_to_RGB(futureState.color); + let { red, green, blue } = utils.HSL_to_RGB(futureState.color); return this._control.setColorWithBrightness(red, green, blue, futureState.brightness).then(success => { this._sumanager.confirmUpdate(suid); @@ -166,7 +167,7 @@ class HCMagicHome extends HCColorLamp { if(effect == 'none') { futureState.effect = "none"; - let { red, green, blue } = HSL_to_RGB(futureState.color); + let { red, green, blue } = utils.HSL_to_RGB(futureState.color); promise = this._control.setColorWithBrightness(red, green, blue, futureState.brightness); } else { @@ -209,7 +210,7 @@ class HCMagicHome extends HCColorLamp { if (futureState.effect == 'none') { // only update color when no effects are playing futureState.brightness = extractBrightness(status.color); - futureState.color = RGB_to_HSL(removeBrightness(status.color)); + futureState.color = utils.RGB_to_HSL(removeBrightness(status.color)); } if (currentState.hash != futureState.hash) { @@ -223,71 +224,6 @@ class HCMagicHome extends HCColorLamp { module.exports = HCMagicHome; -function HSL_to_RGB(hsl) { - let h = hsl.hue / 360; - let s = hsl.sat / 100; - let l = hsl.l / 100; - - let r, g, b; - - if (s == 0) { - r = g = b = l; // achromatic - } else { - var q = l < 0.5 ? l * (1 + s) : l + s - l * s; - var p = 2 * l - q; - - r = hue2rgb(p, q, h + 1/3); - g = hue2rgb(p, q, h); - b = hue2rgb(p, q, h - 1/3); - } - - return { - red: Math.round(r * 255), - green: Math.round(g * 255), - blue: Math.round(b * 255) - }; -} - -function hue2rgb(p, q, t) { - if (t < 0) t += 1; - if (t > 1) t -= 1; - if (t < 1/6) return p + (q - p) * 6 * t; - if (t < 1/2) return q; - if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; - return p; -} - -function RGB_to_HSL(rgb) { - let r = rgb.red / 255; - let g = rgb.green / 255; - let b = rgb.blue / 255; - - let max = Math.max(r, g, b), min = Math.min(r, g, b); - - if (max == min || max - min < 0.01) { - h = s = 0; // achromatic - } else { - var d = max - min; - s = d / (2 - max - min); - - switch (max) { - case r: h = (g - b) / d + (g < b ? 6 : 0); break; - case g: h = (b - r) / d + 2; break; - case b: h = (r - g) / d + 4; break; - } - - h /= 6; - } - - let l = 1 - 0.5 * s; - - return { - hue: Math.round(h * 360), - sat: Math.round(s * 100), - l: Math.round(l * 100) - }; -} - function extractBrightness(rgb) { let max = Math.max(rgb.red, rgb.green, rgb.blue); return (max / 255) * 100; diff --git a/HCMagicHomeAddressable.js b/HCMagicHomeAddressable.js new file mode 100644 index 0000000..9352c64 --- /dev/null +++ b/HCMagicHomeAddressable.js @@ -0,0 +1,334 @@ +const { + HCColorLamp, + StateUpdateManager, + utils, +} = require('homecontrol-control-base'); + +const { ControlAddressable, AddressableColorStopMode } = require('magic-home'); + +const RBM_EFFECTS = new Array(100).fill(null).map((_,i) => { + return { name: `Random Effect ${i}`, id: `rbm-${i}` }; +}); + +class HCMagicHomeAddressable extends HCColorLamp { + constructor(config, registry) { + super(config); + + if (!("address" in this._configuration)) { + throw new Error(`Required configuration field "address" is missing"`); + } + if (!("strip_length" in this._configuration)) { + throw new Error(`Required configuration field "strip_length" is missing"`); + } + + const opts = { + log_all_received: (this._configuration.logall !== undefined) ? this._configuration.logall : false, + connect_timeout: (this._configuration.connect_timeout !== undefined) ? this._configuration.connect_timeout : 10000, + command_timeout: (this._configuration.command_timeout !== undefined) ? this._configuration.command_timeout : 5000, + }; + + this._control = new ControlAddressable(this._configuration.address, opts); + + this._sumanager = new StateUpdateManager(this._state); + this._registry = registry; + + if ("registry_key" in this._configuration) { + this._registry[this._configuration.registry_key] = this; + } + + this._subsections = []; + + this._initialUpdate = true; + } + + async deinit() { + if ("registry_key" in this._configuration) { + delete this._registry[this._configuration.registry_key]; + } + } + + static get version() { + return require("./package.json").version; + } + + // overwrite to make use of the SUManager + get state() { + return this._sumanager.state.clone(); + } + + get effects() { + return [ + { name: "Subsection Color Mode", id: "subsections" }, + ...RBM_EFFECTS, + ]; + } + + _createSubsectionMode() { + const currentState = this.state; + const mode = new AddressableColorStopMode(this._configuration.strip_length); + + let { red: bgRed, green: bgGreen, blue: bgBlue } = utils.HSL_to_RGB(currentState.color); + + bgRed = Math.round(bgRed * currentState.brightness / 100); + bgGreen = Math.round(bgGreen * currentState.brightness / 100); + bgBlue = Math.round(bgBlue * currentState.brightness / 100); + + mode.addColorStop(0, bgRed, bgGreen, bgBlue); + + for (let i = 0; i < this._subsections.length; i++) { + const ss = this._subsections[i]; + + if (ss.start >= this._configuration.strip_length) { + break; + } + + mode.addColorStop(ss.start, ss.color.red, ss.color.green, ss.color.blue); + + if (i < this._subsections.length - 1) { + if (this._subsections[i+1].start > ss.end && ss.end < this._configuration.strip_length) { + mode.addColorStop(ss.end, bgRed, bgGreen, bgBlue); + } + } else { + if (ss.end < this._configuration.strip_length) { + mode.addColorStop(ss.end, bgRed, bgGreen, bgBlue); + } + } + } + + return mode; + } + + turnOn() { + let futureState = this.state; + futureState.on = true; + let suid = this._sumanager.registerUpdate(futureState); + + return this._control.setPower(true).then(success => { + this._sumanager.confirmUpdate(suid); + + if (this._sumanager.highestConfirmedId == suid) { + this.emit("state change", this.state); + } + return success; + }).catch(err => { + this._sumanager.rejectUpdate(suid); + throw err; + }); + } + + turnOff() { + let futureState = this.state; + futureState.on = false; + let suid = this._sumanager.registerUpdate(futureState); + + return this._control.setPower(false).then(success => { + this._sumanager.confirmUpdate(suid); + + if (this._sumanager.highestConfirmedId == suid) { + this.emit("state change", this.state); + } + return success; + }).catch(err => { + this._sumanager.rejectUpdate(suid); + throw err; + }); + } + + // color = {red, green, blue} + setSubsectionColor(start, end, ssid, color) { + this._subsections = this._subsections.filter(ss => ss.id != ssid); + + this._subsections.push({ start, end, id: ssid, color }); + + this._subsections.sort((a,b) => a.start - b.start); + + return this.setEffect("subsections"); + } + + removeSubsection(ssid) { + this._subsections = this._subsections.filter(ss => ss.id != ssid); + + if (this._subsections.length > 0) { + return this.setEffect("subsections"); + } else { + return this.setEffect("none"); + } + } + + isSubsectionActive(ssid) { + return this.state.on && this.state.effect === "subsections" && this._subsections.find(ss => ss.id === ssid) !== null; + } + + setBrightness(brightness) { + let futureState = this.state; + futureState.brightness = brightness; + let suid = this._sumanager.registerUpdate(futureState); + + let promise; + + if (!["none", "subsections"].includes(futureState.effect)) { + promise = this.setEffect(futureState.effect); + } else { + promise = this.setColor(futureState.color); + } + + return promise.then(success => { + this._sumanager.confirmUpdate(suid); + + if (this._sumanager.highestConfirmedId == suid) { + this.emit("state change", this.state); + } + return success; + }).catch(err => { + this._sumanager.rejectUpdate(suid); + throw err; + }); + } + + setColor(color) { + const futureState = this.state; + futureState.color = utils.fillPartialHSL(color, futureState.color); + futureState.on = true; // setting color turns on the controller + + let { red, green, blue } = utils.HSL_to_RGB(futureState.color); + + red = Math.round(red * futureState.brightness / 100); + green = Math.round(green * futureState.brightness / 100); + blue = Math.round(blue * futureState.brightness / 100); + + let promise; + + if (futureState.effect !== "subsections") { + futureState.effect = "none"; + const suid = this._sumanager.registerUpdate(futureState); + + promise = this._control.setFixedMode({ + effect: 1, + foreground: { red, green, blue } + }).then(success => { + this._sumanager.confirmUpdate(suid); + + if (this._sumanager.highestConfirmedId == suid) { + this.emit("state change", this.state); + } + return success; + }); + } else { + const suid = this._sumanager.registerUpdate(futureState); + + const mode = this._createSubsectionMode(); + + return this._control.setMultiColorMode(mode).then(success => { + this._sumanager.confirmUpdate(suid); + + if (this._sumanager.highestConfirmedId == suid) { + this.emit("state change", this.state); + } + return success; + }); + } + + return promise.catch(err => { + this._sumanager.rejectUpdate(suid); + throw err; + }); + } + + setEffect(id) { + let futureState = this.state; + futureState.effect = id; + + let suid = this._sumanager.registerUpdate(futureState); + + let promise = this.turnOn(); + + if (futureState.effect == "none" || futureState.effect == "subsections") { + promise = this.setColor(futureState.color).then(success => { + this._sumanager.confirmUpdate(suid); + + if (this._sumanager.highestConfirmedId == suid) { + this.emit("state change", this.state); + } + return success; + }) + } else { + const rbmMatch = id.match(/rbm\-(\d+)/); + + if (rbmMatch === null) { + return Promise.reject(); + } + + const rbmMode = Number(rbmMatch[1]); + + promise = this._control.setRbmMode(rbmMode, futureState.brightness, 100).then(success => { + this._sumanager.confirmUpdate(suid); + + if (this._sumanager.highestConfirmedId == suid) { + this.emit("state change", this.state); + } + return success; + }); + } + + return promise.catch(err => { + this._sumanager.rejectUpdate(suid); + throw err; + }); + } + + pullState() { + return this._control.queryState().then(status => { + let currentState = this.state; + let futureState = currentState.clone(); + + // console.log(status); + + futureState.on = status.on; + + // let effect = (status.mode != 'color' && status.mode != 'warm_white' && status.mode != 'special') ? status.mode : 'none'; + // if(effect != 'none') { + // let speed = (status.speed > 80) ? 'fast' : (status.speed > 30) ? 'medium' : 'slow'; + // futureState.effect = speed + '-' + effect; + // } else { + // futureState.effect = 'none'; + // } + + if (status.mode == "fixed" || this._initialUpdate) { // only update color when no effects are playing + futureState.brightness = extractBrightness(status.color); + futureState.color = utils.RGB_to_HSL(removeBrightness(status.color)); + } + + if (status.mode === "rbm") { + futureState.effect = "rbm-" + status.effect; + } + + // console.log(futureState); + + if (currentState.hash != futureState.hash) { + // the state of the controller has changed externally + this._sumanager.insertConfirmedState(futureState); + this.emit("state change", this.state); + } + + this._initialUpdate = false; + }); + } +} + +module.exports = HCMagicHomeAddressable; + +function extractBrightness(rgb) { + let max = Math.max(rgb.red, rgb.green, rgb.blue); + return (max / 255) * 100; +} + +function removeBrightness(rgb) { + let max = Math.max(rgb.red, rgb.green, rgb.blue); + let scale = (max > 0) ? 255 / max : 0; + + return { + red: rgb.red * scale, + green: rgb.green * scale, + blue: rgb.blue * scale, + }; +} \ No newline at end of file diff --git a/README.md b/README.md index d0c4ae5..e4ebdd9 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,42 @@ ## Configuration -- `ack` -Bitmask to set `ack` in the constructor. +All types: + +- `type` +Either `"normal"`, `"addressable"` or `"subsection"` + +Type "normal" and "addressable": + - `logall` Sets `log_all_received` in the constructor. - `connect_timeout` Sets `connect_timeout` in the constructor. (Default: 10000) - `command_timeout` -Sets `command_timeout` in the constructor. (Default: 5000) \ No newline at end of file +Sets `command_timeout` in the constructor. (Default: 5000) + +Type "normal": + +- `ack` +Bitmask to set `ack` in the constructor. + +Type "addressable": + +- `strip_length` +Number of segments on the strip. Required. + +- `registry_key` +Name to register this controller in the global registry as. Only required when this device is supposed to have subsections. + +Type "subsection": + +- `parent` +Name of the parent device in the registry. Corresponds to a `registry_key` + +- `section_id` +Identifier of this section for the parent. + +- `section_start` +Which segment of the strip this subsection starts. May not be greater than `strip_length` of the parent. + +- `section_end` +Which segment of the strip this subsection ends. May not be greater than `strip_length` of the parent. diff --git a/entry.js b/entry.js new file mode 100644 index 0000000..f4e6f98 --- /dev/null +++ b/entry.js @@ -0,0 +1,19 @@ +const HCMagicHome = require("./HCMagicHome"); +const HCMagicHomeAddressable = require("./HCMagicHomeAddressable"); +const HCMHAddressableSubsection = require("./HCMHAddressableSubsection"); + +const MagicHomeRegistry = {}; + +function proxy(opts) { + switch(opts.type) { + case "addressable": + return new HCMagicHomeAddressable(opts, MagicHomeRegistry); + case "subsection": + return new HCMHAddressableSubsection(opts, MagicHomeRegistry); + case "normal": + default: + return new HCMagicHome(opts); + } +} + +module.exports = proxy; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b92b5a0..d92631c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "hc-magichome", - "version": "1.3.1", + "version": "1.5.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -10,7 +10,7 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "homecontrol-control-base": { - "version": "git+https://git.literalchaos.de/jan/homecontrol-control-base.git#304c97bdcf84b71168567046560d7777f09dceed", + "version": "git+https://git.literalchaos.de/jan/homecontrol-control-base.git#37eb940fbda9c423834ca3b0f6895753f446ed88", "from": "git+https://git.literalchaos.de/jan/homecontrol-control-base.git", "requires": { "merge-options": "^1.0.1", @@ -23,9 +23,9 @@ "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=" }, "magic-home": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/magic-home/-/magic-home-2.6.1.tgz", - "integrity": "sha512-KU2msJWzOqqD4ofId99krddZpK3WcWAkGZ/2+VpIOYYRPKEatRvb/J5MfkXhK/MjDypPIaZh/CU5IxBCo5eKuA==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/magic-home/-/magic-home-2.8.1.tgz", + "integrity": "sha512-qKKkGcZs4utvTa3lw8mnbxPahhEmrFXAAm8MtjjENowM5ic6mOi3nqiplHSdMauvsXgt1VHa8/MCh/nfqMv3sQ==", "requires": { "commander": "^2.20.0", "merge-options": "^1.0.1" diff --git a/package.json b/package.json index 3c391c3..d2ec973 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "hc-magichome", - "version": "1.4.0", + "version": "1.5.0", "description": "Magic Home Plugin for Homecontrol", - "main": "HCMagicHome.js", + "main": "entry.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, @@ -14,6 +14,6 @@ "license": "UNLICENSED", "dependencies": { "homecontrol-control-base": "git+https://git.literalchaos.de/jan/homecontrol-control-base.git", - "magic-home": "^2.6.1" + "magic-home": "^2.8.1" } }