diff --git a/plejd/api.js b/plejd/api.js index a2a7b31..9e02c76 100644 --- a/plejd/api.js +++ b/plejd/api.js @@ -8,7 +8,7 @@ API_LOGIN_URL = 'login'; API_SITES_URL = 'functions/getSites'; // #region logging -const debug = ''; +let debug = ''; const getLogger = () => { const consoleLogger = msg => console.log('plejd-api', msg); @@ -34,6 +34,15 @@ class PlejdApi extends EventEmitter { this.site = null; } + updateSettings(settings) { + if (settings.debug) { + debug = 'console'; + } + else { + debug = ''; + } + } + login() { logger('login()'); const self = this; @@ -55,7 +64,7 @@ class PlejdApi extends EventEmitter { 'password': this.password }) .then((response) => { - logger('got session token response'); + console.log('plejd-api: got session token response'); self.sessionToken = response.data.sessionToken; self.emit('loggedIn'); }) @@ -86,14 +95,14 @@ class PlejdApi extends EventEmitter { instance.post(API_SITES_URL) .then((response) => { - logger('got sites response'); + console.log('plejd-api: got sites response'); self.site = response.data.result.find(x => x.site.title == self.siteName); self.cryptoKey = self.site.plejdMesh.cryptoKey; callback(self.cryptoKey); }) .catch((error) => { - console.log('error: unable to retrieve the crypto key. error: ' + error); + console.log('error: unable to retrieve the crypto key. error: ' + error + ' (code: ' + error.response.status + ')'); return Promise.reject('unable to retrieve the crypto key. error: ' + error); }); } diff --git a/plejd/ble.js b/plejd/ble.js index 1663822..a643f56 100644 --- a/plejd/ble.js +++ b/plejd/ble.js @@ -34,6 +34,11 @@ const STATE_DISCONNECTED = 'disconnected'; const STATE_UNINITIALIZED = 'uninitialized'; const STATE_INITIALIZED = 'initialized'; +const BLE_CMD_DIM_CHANGE = '00c8'; +const BLE_CMD_DIM2_CHANGE = '0098'; +const BLE_CMD_STATE_CHANGE = '0097'; +const BLE_CMD_SCENE_TRIG = '0021'; + class PlejdService extends EventEmitter { constructor(cryptoKey, keepAlive = false) { super(); @@ -51,6 +56,8 @@ class PlejdService extends EventEmitter { this.writeQueue = []; + this.plejdDevices = {}; + // Holds a reference to all characteristics this.characteristicState = STATE_UNINITIALIZED; this.characteristics = { @@ -64,13 +71,53 @@ class PlejdService extends EventEmitter { this.wireEvents(); } - turnOn(id, brightness) { - logger('turning on ' + id + ' at brightness ' + brightness); + updateSettings(settings) { + if (settings.debug) { + debug = 'console'; + } + else { + debug = ''; + } + } + turnOn(id, command) { + logger('turning on ' + id + ' at brightness ' + (!command.brightness ? 255 : command.brightness)); + const brightness = command.brightness ? command.brightness : 0; + + if (command.transition) { + // we have a transition time, split the target brightness + // into pieces spread of the transition time + const steps = command.transition * 2; + const brightnessStep = brightness / steps; + + let i = 0; + const transitionRef = setInterval(() => { + let currentBrightness = parseInt((brightnessStep * i) + 1); + if (currentBrightness > 254) { + currentBrightness = 254; + } + + this._turnOn(id, currentBrightness); + + if (i >= steps) { + clearInterval(transitionRef); + } + + i++; + }, 500); + } + else { + this._turnOn(id, brightness); + } + } + + _turnOn(id, brightness) { var payload; - if (!brightness) { + if (!brightness || brightness === 0) { + logger('no brightness specified, setting to previous known.'); payload = Buffer.from((id).toString(16).padStart(2, '0') + '0110009701', 'hex'); } else { + logger('brightness is ' + brightness); brightness = brightness << 8 | brightness; payload = Buffer.from((id).toString(16).padStart(2, '0') + '0110009801' + (brightness).toString(16).padStart(4, '0'), 'hex'); } @@ -78,9 +125,39 @@ class PlejdService extends EventEmitter { this.write(payload); } - turnOff(id) { + turnOff(id, command) { logger('turning off ' + id); + if (command.transition) { + // we have a transition time, split the target brightness (which will be 0) + // into pieces spread of the transition time + const initialBrightness = this.plejdDevices[id] ? this.plejdDevices[id].dim : 250; + const steps = command.transition * 2; + const brightnessStep = initialBrightness / steps; + let currentBrightness = initialBrightness; + + let i = 0; + const transitionRef = setInterval(() => { + currentBrightness = parseInt(initialBrightness - (brightnessStep * i)); + if (currentBrightness <= 0 || i >= steps) { + clearInterval(transitionRef); + + // finally, we turn it off + this._turnOff(id); + return; + } + + this._turnOn(id, currentBrightness); + + i++; + }, 500); + } + else { + this._turnOff(id); + } + } + + _turnOff(id) { var payload = Buffer.from((id).toString(16).padStart(2, '0') + '0110009700', 'hex'); this.write(payload); } @@ -143,11 +220,15 @@ class PlejdService extends EventEmitter { ) ); - logger('connecting to ' + this.device.id + ' with addr ' + this.device.address + ' and rssi ' + this.device.rssi); + console.log('connecting to ' + this.device.id + ' with addr ' + this.device.address + ' and rssi ' + this.device.rssi); setTimeout(() => { if (self.state !== STATE_CONNECTED && self.state !== STATE_AUTHENTICATED) { if (self.deviceIdx < Object.keys(self.devices).length) { - logger('connection timed out after 10 s. trying next.'); + logger('connection timed out after 10 s. cleaning up and trying next.'); + + self.device.removeAllListeners('servicesDiscover'); + self.device.removeAllListeners('connect'); + self.device.removeAllListeners('disconnect'); self.deviceIdx++; self.connect(); @@ -168,12 +249,16 @@ class PlejdService extends EventEmitter { disconnect() { logger('disconnect()'); - if (this.state !== STATE_CONNECTED) { + if (this.state !== STATE_CONNECTED && this.state !== STATE_AUTHENTICATED) { return; } clearInterval(this.pingRef); + this.device.removeAllListeners('servicesDiscover'); + this.device.removeAllListeners('connect'); + this.device.removeAllListeners('disconnect'); + this.unsubscribeCharacteristics(); this.device.disconnect(); @@ -435,19 +520,39 @@ class PlejdService extends EventEmitter { let dim = 0; let device = parseInt(decoded[0], 10); - if (decoded.toString('hex', 3, 5) === '00c8' || decoded.toString('hex', 3, 5) === '0098') { + if (decoded.length < 5) { + // ignore the notification since too small + return; + } + + const cmd = decoded.toString('hex', 3, 5); + + if (debug) { + logger('raw event received: ' + decoded.toString('hex')); + } + + if (cmd === BLE_CMD_DIM_CHANGE || cmd === BLE_CMD_DIM2_CHANGE) { state = parseInt(decoded.toString('hex', 5, 6), 10); dim = parseInt(decoded.toString('hex', 6, 8), 16) >> 8; logger('d: ' + device + ' got state+dim update: ' + state + ' - ' + dim); - this.emit('dimChanged', device, state, dim); + this.emit('stateChanged', device, { state: state, brightness: dim }); } - else if (decoded.toString('hex', 3, 5) === '0097') { + else if (cmd === BLE_CMD_STATE_CHANGE) { state = parseInt(decoded.toString('hex', 5, 6), 10); logger('d: ' + device + ' got state update: ' + state); - this.emit('stateChanged', device, state); + this.emit('stateChanged', device, { state: state }); } + else if (cmd === BLE_CMD_SCENE_TRIG) { + const scene = parseInt(decoded.toString('hex', 5, 6), 10); + this.emit('sceneTriggered', device, scene); + } + + this.plejdDevices[device] = { + state: state, + dim: dim + }; } wireEvents() { diff --git a/plejd/config.json b/plejd/config.json index e182b83..84e552d 100644 --- a/plejd/config.json +++ b/plejd/config.json @@ -1,6 +1,6 @@ { "name": "Plejd", - "version": "0.1.1", + "version": "0.2.0", "slug": "plejd", "description": "Adds support for the Swedish home automation devices from Plejd.", "url": "https://github.com/icanos/hassio-plejd/", diff --git a/plejd/main.js b/plejd/main.js index 5224851..3c81a13 100644 --- a/plejd/main.js +++ b/plejd/main.js @@ -3,7 +3,11 @@ const mqtt = require('./mqtt'); const fs = require('fs'); const PlejdService = require('./ble'); +const version = "0.2.0"; + async function main() { + console.log('starting Plejd add-on v. ' + version); + const rawData = fs.readFileSync('/data/plejd.json'); const config = JSON.parse(rawData); @@ -28,31 +32,35 @@ async function main() { }); // subscribe to changes from Plejd - plejd.on('stateChanged', (deviceId, state) => { - client.updateState(deviceId, state); + plejd.on('stateChanged', (deviceId, command) => { + client.updateState(deviceId, command); }); - plejd.on('dimChanged', (deviceId, state, dim) => { - client.updateState(deviceId, state); - client.updateBrightness(deviceId, dim); + + plejd.on('sceneTriggered', (scene) => { + client.sceneTriggered(scene); }); // subscribe to changes from HA - client.on('stateChanged', (deviceId, state) => { - if (state) { - plejd.turnOn(deviceId); + client.on('stateChanged', (deviceId, command) => { + if (command.state === 'ON') { + plejd.turnOn(deviceId, command); } else { - plejd.turnOff(deviceId); + plejd.turnOff(deviceId, command); } }); - client.on('brightnessChanged', (deviceId, brightness) => { - if (brightness > 0) { - plejd.turnOn(deviceId, brightness); + + client.on('settingsChanged', (settings) => { + if (settings.module === 'mqtt') { + client.updateSettings(settings); } - else { - plejd.turnOff(deviceId); + else if (settings.module === 'ble') { + plejd.updateSettings(settings); } - }); + else if (settings.module === 'api') { + plejdApi.updateSettings(settings); + } + }); }); }); diff --git a/plejd/mqtt.js b/plejd/mqtt.js index 30b2a3d..39afbb9 100644 --- a/plejd/mqtt.js +++ b/plejd/mqtt.js @@ -5,7 +5,7 @@ const _ = require('lodash'); const startTopic = 'hass/status'; // #region logging -const debug = ''; +let debug = ''; const getLogger = () => { const consoleLogger = msg => console.log('plejd-mqtt', msg); @@ -27,32 +27,19 @@ const getSubscribePath = () => `${discoveryPrefix}/+/${nodeId}/#`; const getPath = ({ id, type }) => `${discoveryPrefix}/${type}/${nodeId}/${id}`; const getConfigPath = plug => `${getPath(plug)}/config`; -const getAvailabilityTopic = plug => `${getPath(plug)}/availability`; const getStateTopic = plug => `${getPath(plug)}/state`; -const getBrightnessCommandTopic = plug => `${getPath(plug)}/setBrightness`; -const getBrightnessTopic = plug => `${getPath(plug)}/brightness`; const getCommandTopic = plug => `${getPath(plug)}/set`; - -const getDiscoveryDimmablePayload = device => ({ - name: device.name, - unique_id: `light.plejd.${device.name.toLowerCase().replace(/ /g, '')}`, - state_topic: getStateTopic(device), - command_topic: getCommandTopic(device), - brightness_command_topic: getBrightnessCommandTopic(device), - brightness_state_topic: getBrightnessTopic(device), - payload_on: 1, - payload_off: 0, - optimistic: false -}); +const getSceneEventTopic = () => `plejd/event/scene`; +const getSettingsTopic = () => `plejd/settings`; const getDiscoveryPayload = device => ({ + schema: 'json', name: device.name, unique_id: `light.plejd.${device.name.toLowerCase().replace(/ /g, '')}`, state_topic: getStateTopic(device), command_topic: getCommandTopic(device), - payload_on: 1, - payload_off: 0, - optimistic: false + optimistic: false, + brightness: `${device.dimmable}` }); // #endregion @@ -92,6 +79,12 @@ class MqttClient extends EventEmitter { logger('error: unable to subscribe to control topics'); } }); + + this.client.subscribe(getSettingsTopic(), (err) => { + if (err) { + console.log('error: could not subscribe to settings topic'); + } + }); }); this.client.on('close', () => { @@ -99,28 +92,33 @@ class MqttClient extends EventEmitter { }); this.client.on('message', (topic, message) => { - const command = message.toString(); + //const command = message.toString(); + const command = JSON.parse(message.toString()); if (topic === startTopic) { logger('home assistant has started. lets do discovery.'); self.emit('connected'); } - - if (_.includes(topic, 'setBrightness')) { - const device = self.devices.find(x => getBrightnessCommandTopic(x) === topic); - logger('got brightness update for ' + device.name + ' with brightness: ' + command); - - self.emit('brightnessChanged', device.id, parseInt(command)); + else if (topic === getSettingsTopic()) { + self.emit('settingsChanged', command); } - else if (_.includes(topic, 'set') && _.includes(['0', '1'], command)) { - const device = self.devices.find(x => getCommandTopic(x) === topic); - logger('got state update for ' + device.name + ' with state: ' + command); - self.emit('stateChanged', device.id, parseInt(command)); + if (_.includes(topic, 'set')) { + const device = self.devices.find(x => getCommandTopic(x) === topic); + self.emit('stateChanged', device.id, command); } }); } + updateSettings(settings) { + if (settings.debug) { + debug = 'console'; + } + else { + debug = ''; + } + } + reconnect() { this.client.reconnect(); } @@ -134,16 +132,8 @@ class MqttClient extends EventEmitter { devices.forEach((device) => { logger(`sending discovery for ${device.name}`); - let payload = null; - - if (device.type === 'switch') { - payload = getDiscoveryPayload(device); - } - else { - payload = device.dimmable ? getDiscoveryDimmablePayload(device) : getDiscoveryPayload(device); - } - - console.log(`discovered ${device.name} with Plejd ID ${device.id}.`); + let payload = getDiscoveryPayload(device); + console.log(`discovered ${device.type}: ${device.name} with Plejd ID ${device.id}.`); self.deviceMap[device.id] = payload.unique_id; @@ -154,7 +144,7 @@ class MqttClient extends EventEmitter { }); } - updateState(deviceId, state) { + updateState(deviceId, data) { const device = this.devices.find(x => x.id === deviceId); if (!device) { @@ -162,27 +152,31 @@ class MqttClient extends EventEmitter { return; } - logger('updating state for ' + device.name + ': ' + state); + logger('updating state for ' + device.name + ': ' + data.state); + let payload = null; + + if (device.dimmable) { + payload = { + state: data.state === 1 ? 'ON' : 'OFF', + brightness: data.brightness + } + } + else { + payload = { + state: data.state === 1 ? 'ON' : 'OFF' + } + } this.client.publish( getStateTopic(device), - state.toString() + JSON.stringify(payload) ); } - updateBrightness(deviceId, brightness) { - const device = this.devices.find(x => x.id === deviceId); - - if (!device) { - logger('error: ' + deviceId + ' is not handled by us.'); - return; - } - - logger('updating brightness for ' + device.name + ': ' + brightness); - + sceneTriggered(scene) { this.client.publish( - getBrightnessTopic(device), - brightness.toString() + getSceneEventTopic(), + JSON.stringify({ scene: scene }) ); } }