From a54e256b40cb2ca91b8e07785165548c2850f7b0 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Mon, 29 Apr 2019 20:38:40 +0200 Subject: [PATCH] Group configuration via configuration.yaml and group state (#1464) Group configuration via configuration.yaml and group state. --- .gitignore | 3 +- lib/extension/groups.js | 180 ++++++++++++++++++++++++++++++++-------- lib/state.js | 16 +++- lib/util/data.js | 11 +++ lib/util/settings.js | 43 +++++++++- npm-shrinkwrap.json | 17 ++-- package.json | 1 + test/group.test.js | 178 +++++++++++++++++++++++++++++++++++++++ test/settings.test.js | 6 +- 9 files changed, 408 insertions(+), 47 deletions(-) create mode 100644 test/group.test.js diff --git a/.gitignore b/.gitignore index 0ad9eb95c..96ad72123 100644 --- a/.gitignore +++ b/.gitignore @@ -64,4 +64,5 @@ data/log*.txt data/state.json data/log data-backup/ -data/coordinator_backup.json \ No newline at end of file +data/coordinator_backup.json +data/.storage \ No newline at end of file diff --git a/lib/extension/groups.js b/lib/extension/groups.js index e543cb498..266777f3e 100644 --- a/lib/extension/groups.js +++ b/lib/extension/groups.js @@ -1,5 +1,8 @@ const settings = require('../util/settings'); const logger = require('../util/logger'); +const data = require('../util/data'); +const fs = require('fs'); +const diff = require('deep-diff'); const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/.+/(remove|add|remove_all)$`); @@ -9,6 +12,18 @@ class Groups { this.mqtt = mqtt; this.state = state; this.publishEntityState = publishEntityState; + this.onStateChange = this.onStateChange.bind(this); + + this.groupsCacheFile = data.joinPathStorage('groups_cache.json'); + this.groupsCache = this.readGroupsCache(); + } + + readGroupsCache() { + return fs.existsSync(this.groupsCacheFile) ? JSON.parse(fs.readFileSync(this.groupsCacheFile, 'utf8')) : {}; + } + + writeGroupsCache() { + fs.writeFileSync(this.groupsCacheFile, JSON.stringify(this.groupsCache), 'utf8'); } onMQTTConnected() { @@ -17,6 +32,70 @@ class Groups { this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/group/+/remove_all`); } + apply(from, to) { + const sortGroups = (obj) => Object.keys(obj).forEach((key) => obj[key] = obj[key].sort()); + + sortGroups(from); + sortGroups(to); + + const differences = diff(from, to); + if (differences) { + differences.forEach((diff) => { + const groupID = diff.path[0]; + + if (diff.kind === 'N') { + diff.rhs.forEach((ieeeAddr) => this.updateDeviceGroup(ieeeAddr, 'add', groupID)); + } else if (diff.kind === 'A') { + if (diff.item.lhs) { + this.updateDeviceGroup(diff.item.lhs, 'remove', groupID); + } else { + this.updateDeviceGroup(diff.item.rhs, 'add', groupID); + } + } else if (diff.kind === 'D') { + diff.lhs.forEach((ieeeAddr) => this.updateDeviceGroup(ieeeAddr, 'remove', groupID)); + } else if (diff.kind === 'E') { + this.updateDeviceGroup(diff.rhs, 'add', groupID); + this.updateDeviceGroup(diff.lhs, 'remove', groupID); + } + }); + } + } + + getGroupsOfDevice(ieeeAddr) { + return Object.keys(settings.getGroups()).filter((groupID) => { + return settings.getGroup(groupID).devices.includes(ieeeAddr); + }); + } + + onStateChange(ieeeAddr, from, to) { + const properties = ['state', 'brightness', 'color_temp', 'color']; + const payload = {}; + + properties.forEach((prop) => { + if (to.hasOwnProperty(prop) && from[prop] != to[prop]) { + payload[prop] = to[prop]; + } + }); + + if (Object.keys(payload)) { + const groups = this.getGroupsOfDevice(ieeeAddr); + groups.forEach((groupID) => { + this.publishEntityState(groupID, payload); + }); + } + } + + onZigbeeStarted() { + this.state.registerOnStateChangeListener(this.onStateChange); + + const settingsGroups = {}; + Object.keys(settings.getGroups()).forEach((groupID) => { + settingsGroups[groupID] = settings.getGroup(groupID).devices; + }); + + this.apply(this.groupsCache, settingsGroups); + } + parseTopic(topic) { if (!topic.match(topicRegex)) { return null; @@ -34,6 +113,73 @@ class Groups { return {friendly_name: topic, type}; } + updateDeviceGroup(ieeeAddr, cmd, groupID) { + let payload = null; + const orignalCmd = cmd; + if (cmd === 'add') { + payload = {groupid: groupID, groupname: ''}; + cmd = 'add'; + } else if (cmd === 'remove') { + payload = {groupid: groupID}; + cmd = 'remove'; + } else if (cmd === 'remove_all') { + payload = {}; + cmd = 'removeAll'; + } + + const cb = (error, rsp) => { + if (error) { + logger.error(`Failed to ${cmd} ${ieeeAddr} from ${groupID}`); + } else { + logger.info(`Successfully ${cmd} ${ieeeAddr} to ${groupID}`); + + // Log to MQTT + this.mqtt.log({ + device: settings.getDevice(ieeeAddr).friendly_name, + group: groupID, + action: orignalCmd, + }); + + // Update group cache + if (cmd === 'add') { + if (!this.groupsCache[groupID]) { + this.groupsCache[groupID] = []; + } + + if (!this.groupsCache[groupID].includes(ieeeAddr)) { + this.groupsCache[groupID].push(ieeeAddr); + } + } else if (cmd === 'remove') { + if (this.groupsCache[groupID]) { + this.groupsCache[groupID] = this.groupsCache[groupID].filter((device) => device != ieeeAddr); + } + } else if (cmd === 'removeAll') { + Object.keys(this.groupsCache).forEach((groupID_) => { + this.groupsCache[groupID_] = this.groupsCache[groupID_].filter((device) => device != ieeeAddr); + }); + } + + this.writeGroupsCache(); + + // Update settings + if (cmd === 'add') { + settings.addDeviceToGroup(groupID, ieeeAddr); + } else if (cmd === 'remove') { + settings.removeDeviceFromGroup(groupID, ieeeAddr); + } else if (cmd === 'removeAll') { + Object.keys(settings.get().groups).forEach((groupID) => { + settings.removeDeviceFromGroup(groupID, ieeeAddr); + }); + } + } + }; + + this.zigbee.publish( + ieeeAddr, 'device', 'genGroups', cmd, 'functional', + payload, null, null, cb, + ); + } + onMQTTMessage(topic, message) { topic = this.parseTopic(topic); @@ -62,39 +208,7 @@ class Groups { } // Send command to the device. - let payload = null; - let cmd = null; - if (topic.type === 'add') { - payload = {groupid: groupID, groupname: ''}; - cmd = 'add'; - } else if (topic.type === 'remove') { - payload = {groupid: groupID}; - cmd = 'remove'; - } else if (topic.type === 'remove_all') { - payload = {}; - cmd = 'removeAll'; - } - - const callback = (error, rsp) => { - if (error) { - logger.error(`Failed to ${topic.type} ${ieeeAddr} from ${topic.friendly_name}`); - } else { - logger.info(`Successfully ${topic.type} ${ieeeAddr} to ${topic.friendly_name}`); - - // Log to MQTT - const log = {device: message}; - if (['remove', 'add'].includes(topic.type)) { - log.group = topic.friendly_name; - } - - this.mqtt.log(log); - } - }; - - this.zigbee.publish( - ieeeAddr, 'device', 'genGroups', cmd, 'functional', - payload, null, null, callback, - ); + this.updateDeviceGroup(ieeeAddr, topic.type, groupID); return true; } diff --git a/lib/state.js b/lib/state.js index 71457b58c..31607dec3 100644 --- a/lib/state.js +++ b/lib/state.js @@ -17,6 +17,7 @@ class State { this.state = {}; this.file = data.joinPath('state.json'); this.timer = null; + this.stateChangeListeners = []; this.handleSettingsChanged = this.handleSettingsChanged.bind(this); } @@ -38,6 +39,10 @@ class State { this.checkLastSeen(); } + registerOnStateChangeListener(listener) { + this.stateChangeListeners.push(listener); + } + checkLastSeen() { if (settings.get().advanced.last_seen === 'disable') { Object.values(this.state).forEach((s) => { @@ -90,14 +95,17 @@ class State { } set(ieeeAddr, state) { - const s = objectAssignDeep.noMutate(state); + const toState = objectAssignDeep.noMutate(state); dontCacheProperties.forEach((property) => { - if (s.hasOwnProperty(property)) { - delete s[property]; + if (toState.hasOwnProperty(property)) { + delete toState[property]; } }); - this.state[ieeeAddr] = s; + const fromState = this.state[ieeeAddr]; + this.stateChangeListeners.forEach((listener) => listener(ieeeAddr, fromState, toState)); + + this.state[ieeeAddr] = toState; } remove(ieeeAddr) { diff --git a/lib/util/data.js b/lib/util/data.js index 7b5f9f94f..b87fb95ed 100644 --- a/lib/util/data.js +++ b/lib/util/data.js @@ -1,4 +1,5 @@ const path = require('path'); +const fs = require('fs'); let dataPath = null; @@ -13,8 +14,18 @@ function load() { load(); +function joinPathStorage(file) { + const storagePath = path.join(dataPath, '.storage'); + if (!fs.existsSync(storagePath)) { + fs.mkdirSync(storagePath); + } + + return path.join(storagePath, file); +} + module.exports = { joinPath: (file) => path.join(dataPath, file), + joinPathStorage: (file) => joinPathStorage(file), getPath: () => dataPath, // For test only. diff --git a/lib/util/settings.js b/lib/util/settings.js index 6ba96cb30..700120535 100644 --- a/lib/util/settings.js +++ b/lib/util/settings.js @@ -153,9 +153,17 @@ const getDevices = () => getSettings().devices || []; const getDevice = (ieeeAddr) => getDevices()[ieeeAddr]; -const getGroups = () => getSettings().groups || []; +const getGroups = () => getSettings().groups || {}; -const getGroup = (ID) => getGroups()[ID]; +const getGroup = (ID) => { + const group = getGroups()[ID]; + + if (group && !group.hasOwnProperty('devices')) { + group.devices = []; + } + + return group; +}; function addDevice(ieeeAddr) { @@ -204,6 +212,35 @@ function addGroup(groupName) { return true; } +function addDeviceToGroup(ID, ieeeAddr) { + const settings = getSettings(); + const group = settings.groups[ID]; + if (!group.devices) { + group.devices = []; + } + + if (!group.devices.includes(ieeeAddr)) { + group.devices.push(ieeeAddr); + writeRead(); + return true; + } else { + return false; + } +} + +function removeDeviceFromGroup(ID, ieeeAddr) { + const settings = getSettings(); + const group = settings.groups[ID]; + + if (group.devices && group.devices.includes(ieeeAddr)) { + group.devices = group.devices.filter((d) => d != ieeeAddr); + writeRead(); + return true; + } else { + return false; + } +} + function removeGroup(name) { const settings = getSettings(); if (!settings.groups) return; @@ -308,6 +345,8 @@ module.exports = { removeDevice: (ieeeAddr) => removeDevice(ieeeAddr), addGroup: (name) => addGroup(name), removeGroup: (name) => removeGroup(name), + addDeviceToGroup: (ID, ieeeAddr) => addDeviceToGroup(ID, ieeeAddr), + removeDeviceFromGroup: (ID, ieeeAddr) => removeDeviceFromGroup(ID, ieeeAddr), getIeeeAddrByFriendlyName: (friendlyName) => getIeeeAddrByFriendlyName(friendlyName), getGroupIDByFriendlyName: (friendlyName) => getGroupIDByFriendlyName(friendlyName), diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 5a8760187..3f15560b7 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -440,9 +440,9 @@ "dev": true }, "@types/jest": { - "version": "24.0.11", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-24.0.11.tgz", - "integrity": "sha512-2kLuPC5FDnWIDvaJBzsGTBQaBbnDweznicvK7UGYzlIJP4RJR2a4A/ByLUXEyEgag6jz8eHdlWExGDtH3EYUXQ==", + "version": "24.0.12", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-24.0.12.tgz", + "integrity": "sha512-60sjqMhat7i7XntZckcSGV8iREJyXXI6yFHZkSZvCPUeOnEJ/VP1rU/WpEWQ56mvoh8NhC+sfKAuJRTyGtCOow==", "dev": true, "requires": { "@types/jest-diff": "*" @@ -2077,6 +2077,11 @@ "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", "dev": true }, + "deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==" + }, "deep-eql": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", @@ -2901,9 +2906,9 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.8.tgz", - "integrity": "sha512-tPvHgPGB7m40CZ68xqFGkKuzN+RnpGmSV+hgeKxhRpbxdqKXUFJGC3yonBOLzQBcJyGpdZFDfCsdOC2KFsXzeA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.9.tgz", + "integrity": "sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw==", "dev": true, "optional": true, "requires": { diff --git a/package.json b/package.json index e2e35f796..9b8bfa4e7 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "ziee": "*", "zigbee-shepherd": "git+https://github.com/Koenkk/zigbee-shepherd.git#30d08aacf50327dc1e2c3f146076b9efb8581192", "zigbee-shepherd-converters": "8.1.5", + "deep-diff": "*", "zive": "*" }, "devDependencies": { diff --git a/test/group.test.js b/test/group.test.js new file mode 100644 index 000000000..a7378524f --- /dev/null +++ b/test/group.test.js @@ -0,0 +1,178 @@ +const Groups = require('../lib/extension/groups'); + +let groupExtension = null; +let zigbee = null; + +describe('Groups', () => { + beforeEach(() => { + zigbee = { + publish: jest.fn(), + }; + + groupExtension = new Groups(zigbee, null, null, null); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('Apply group updates add', async () => { + zigbee.publish.mockClear(); + const from = {}; + const to = {'1': ['1', '2']}; + groupExtension.apply(from, to); + expect(zigbee.publish).toHaveBeenCalledTimes(2); + expect(zigbee.publish).toHaveBeenCalledWith( + '1', 'device', 'genGroups', 'add', 'functional', + {groupid: '1', groupname: ''}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '2', 'device', 'genGroups', 'add', 'functional', + {groupid: '1', groupname: ''}, null, null, expect.any(Function) + ); + }); + + it('Apply group updates remove', async () => { + zigbee.publish.mockClear(); + const from = {'1': ['1', '2', '3']}; + const to = {'1': ['1']}; + groupExtension.apply(from, to); + expect(zigbee.publish).toHaveBeenCalledTimes(2); + expect(zigbee.publish).toHaveBeenCalledWith( + '3', 'device', 'genGroups', 'remove', 'functional', + {groupid: '1'}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '2', 'device', 'genGroups', 'remove', 'functional', + {groupid: '1'}, null, null, expect.any(Function) + ); + }); + + it('Apply group updates add 1', async () => { + zigbee.publish.mockClear(); + const from = {'1': ['1']}; + const to = {'1': ['1', '2', '3']}; + groupExtension.apply(from, to); + expect(zigbee.publish).toHaveBeenCalledTimes(2); + expect(zigbee.publish).toHaveBeenCalledWith( + '3', 'device', 'genGroups', 'add', 'functional', + {groupid: '1', groupname: ''}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '2', 'device', 'genGroups', 'add', 'functional', + {groupid: '1', groupname: ''}, null, null, expect.any(Function) + ); + }); + + it('Apply group updates add and remove group', async () => { + zigbee.publish.mockClear(); + const from = {'1': ['1', '2']}; + const to = {'2': ['1', '2']}; + groupExtension.apply(from, to); + expect(zigbee.publish).toHaveBeenCalledTimes(4); + expect(zigbee.publish).toHaveBeenCalledWith( + '1', 'device', 'genGroups', 'remove', 'functional', + {groupid: '1'}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '2', 'device', 'genGroups', 'remove', 'functional', + {groupid: '1'}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '1', 'device', 'genGroups', 'add', 'functional', + {groupid: '2', groupname: ''}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '2', 'device', 'genGroups', 'add', 'functional', + {groupid: '2', groupname: ''}, null, null, expect.any(Function) + ); + }); + + it('Apply group updates change 1', async () => { + zigbee.publish.mockClear(); + const from = {'1': ['1', '4', '2']}; + const to = {'1': ['1', '2', '3']}; + groupExtension.apply(from, to); + expect(zigbee.publish).toHaveBeenCalledTimes(2); + expect(zigbee.publish).toHaveBeenCalledWith( + '3', 'device', 'genGroups', 'add', 'functional', + {groupid: '1', groupname: ''}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '4', 'device', 'genGroups', 'remove', 'functional', + {groupid: '1'}, null, null, expect.any(Function) + ); + }); + + it('Apply group updates change 2', async () => { + zigbee.publish.mockClear(); + const from = {'1': ['1', '2', '3']}; + const to = {'1': ['3', '1', '2', '4']}; + groupExtension.apply(from, to); + expect(zigbee.publish).toHaveBeenCalledTimes(1); + expect(zigbee.publish).toHaveBeenCalledWith( + '4', 'device', 'genGroups', 'add', 'functional', + {groupid: '1', groupname: ''}, null, null, expect.any(Function) + ); + }); + + it('Apply group updates change 3', async () => { + zigbee.publish.mockClear(); + const from = {'1': ['1', '2']}; + const to = {'1': ['3', '1', '4', '2']}; + groupExtension.apply(from, to); + expect(zigbee.publish).toHaveBeenCalledTimes(2); + expect(zigbee.publish).toHaveBeenCalledWith( + '3', 'device', 'genGroups', 'add', 'functional', + {groupid: '1', groupname: ''}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '4', 'device', 'genGroups', 'add', 'functional', + {groupid: '1', groupname: ''}, null, null, expect.any(Function) + ); + }); + + it('Apply group updates change 4', async () => { + zigbee.publish.mockClear(); + const from = {'1': ['1']}; + const to = {'2': ['3', '1']}; + groupExtension.apply(from, to); + expect(zigbee.publish).toHaveBeenCalledTimes(3); + expect(zigbee.publish).toHaveBeenCalledWith( + '1', 'device', 'genGroups', 'remove', 'functional', + {groupid: '1'}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '1', 'device', 'genGroups', 'add', 'functional', + {groupid: '2', groupname: ''}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '3', 'device', 'genGroups', 'add', 'functional', + {groupid: '2', groupname: ''}, null, null, expect.any(Function) + ); + }); + + it('Apply group updates change 5', async () => { + zigbee.publish.mockClear(); + const from = {'1': ['1']}; + const to = {'1': ['3'], '2': ['3', '1']}; + groupExtension.apply(from, to); + expect(zigbee.publish).toHaveBeenCalledTimes(4); + expect(zigbee.publish).toHaveBeenCalledWith( + '1', 'device', 'genGroups', 'remove', 'functional', + {groupid: '1'}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '3', 'device', 'genGroups', 'add', 'functional', + {groupid: '1', groupname: ''}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '1', 'device', 'genGroups', 'add', 'functional', + {groupid: '2', groupname: ''}, null, null, expect.any(Function) + ); + expect(zigbee.publish).toHaveBeenCalledWith( + '3', 'device', 'genGroups', 'add', 'functional', + {groupid: '2', groupname: ''}, null, null, expect.any(Function) + ); + }); +}); diff --git a/test/settings.test.js b/test/settings.test.js index e3b2054fd..ad6751224 100644 --- a/test/settings.test.js +++ b/test/settings.test.js @@ -203,12 +203,13 @@ describe('Settings', () => { const group = settings.getGroup('1'); const expected = { friendly_name: '123', + devices: [], }; expect(group).toStrictEqual(expected); }); - it('Should read groups form a separate file', () => { + it('Should read groups from a separate file', () => { const contentConfiguration = { groups: 'groups.yaml', }; @@ -225,6 +226,7 @@ describe('Settings', () => { const group = settings.getGroup('1'); const expected = { friendly_name: '123', + devices: [], }; expect(group).toStrictEqual(expected); @@ -239,6 +241,7 @@ describe('Settings', () => { const contentGroups = { '1': { friendly_name: '123', + devices: [], }, }; @@ -268,6 +271,7 @@ describe('Settings', () => { const group = settings.getGroup('1'); const expectedGroup = { friendly_name: '123', + devices: [], }; expect(group).toStrictEqual(expectedGroup);