From 78feb4ca97f7b876da5b2006337d3dbccab1aa3d Mon Sep 17 00:00:00 2001 From: Stephen Date: Fri, 3 May 2024 18:25:48 +1000 Subject: [PATCH] 3.1.1 Release (#462) * Adding in fix for Issue #444. * Reverting the SimpleBlindsAccessory back to the version suggested in #451 * Updating to try cater for multiple types of blinds in the same file. * 3.4 Protocol succesfull connection * restore other protocols compatibility * Revert "Changes from @Fate8383. https://github.com/iRayanKhan/homebridge-tuya/issues/433#issuecomment-1999366687" This reverts commit d85caa57cc48b8f7171ec6690640918a5a785292. * Add support for Proscenic air purifier (tested against Proscenic A8) addresses #363 and #195 (#364) * Add support for Proscenic air purifier (tested against Proscenic A8) * Additional fix for "Turning off..." state as getCurrentAirPurifierState always returned 2 (PURIFYING_AIR) * Fix for setting rotation speed and active status * Add initial support for Virone VDP-65 doorbell. Notifies on ring with a snapshot. Notifies when a user presses the lock or gate buttons on another device. Does not provide video or audio. Does not allow the user to control the lock or gate. * Add Siguro support from @fate8383. * Revert "Add initial support for Virone VDP-65 doorbell." This reverts commit 7db7a1f12bceb03405ef7d1fbbb3c6fffdc2b30c. --------- Co-authored-by: Andrew Co-authored-by: Andrew Kurowski * Adding Air purifier config map so that changes can be made from the ui. * Bumping package version. Removing a rogue , * Dropping the Beta Version in preparation of a release. --------- Co-authored-by: 05TEVE <> Co-authored-by: scooterpsu <3433982+scooterpsu@users.noreply.github.com> Co-authored-by: tomash1 Co-authored-by: ak6i <62596884+ak6i@users.noreply.github.com> Co-authored-by: Andrew Co-authored-by: Andrew Kurowski --- config.schema.json | 13 + lib/AirPurifierAccessory.js | 281 +++++++-------- lib/BaseAccessory.js | 12 + lib/SimpleBlindsAccessory.js | 605 ++++++++++++++++----------------- lib/SimpleFanAccessory.js | 49 ++- lib/SimpleFanLightAccessory.js | 49 ++- lib/TuyaAccessory.js | 267 +++++++++++++-- package.json | 2 +- 8 files changed, 758 insertions(+), 520 deletions(-) diff --git a/config.schema.json b/config.schema.json index 79bbb3be..2e122b11 100644 --- a/config.schema.json +++ b/config.schema.json @@ -113,6 +113,12 @@ "enum": [ "FanLight" ] + }, + { + "title": "Air Purifier", + "enum": [ + "AirPurifier" + ] } ] }, @@ -495,6 +501,13 @@ "condition": { "functionBody": "return model.devices && model.devices[arrayIndices] && ['RGBTWOutlet'].includes(model.devices[arrayIndices].type);" } + }, + "dpBlindType": { + "type": "integer", + "placeholder": 1, + "condition": { + "functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleBlinds'].includes(model.devices[arrayIndices].type);" + } } } } diff --git a/lib/AirPurifierAccessory.js b/lib/AirPurifierAccessory.js index baa80af8..f6f9d816 100644 --- a/lib/AirPurifierAccessory.js +++ b/lib/AirPurifierAccessory.js @@ -1,14 +1,11 @@ const BaseAccessory = require('./BaseAccessory'); - const DP_SWITCH = '1'; const DP_PM25 = '2'; const DP_MODE = '3' const DP_FAN_SPEED = '4'; const DP_LOCK_PHYSICAL_CONTROLS = '7'; const DP_AIR_QUALITY = '22'; - const STATE_OTHER = 9; - /** * Accessory for Air Purifiers, with an optional setting to also include Air Quality sensor details. * @@ -90,44 +87,47 @@ class AirPurifierAccessory extends BaseAccessory { static getCategory(Categories) { return Categories.AIR_PURIFIER; } - constructor(...props) { super(...props); - const {Characteristic} = this.hap; + if (this.device.context.noRotationSpeed) { - if (!this.device.context.noRotationSpeed) { - - const fanSpeedSteps = ( - this.device.context.fanSpeedSteps && - isFinite(this.device.context.fanSpeedSteps) && - this.device.context.fanSpeedSteps > 0 && + let fanSpeedSteps = ( + this.device.context.fanSpeedSteps && + isFinite(this.device.context.fanSpeedSteps) && + this.device.context.fanSpeedSteps > 0 && this.device.context.fanSpeedSteps < 100) ? this.device.context.fanSpeedSteps : 100; - - let _fanSpeedLabels = {}; - // Special handling for particular devices // switch (this.device.context.manufacturer) { - case 'Breville': - _fanSpeedLabels = {0: 'off', 1: 'low', 2: 'mid', 3: 'high', 4: 'turbo'}; - this._rotationSteps = [...Array(5).keys()]; + case 'Breville': + _fanSpeedLabels = {0: 'off', 1: 'low', 2: 'mid', 3: 'high', 4: 'turbo'}; + this._rotationSteps = [...Array(5).keys()]; + fanSpeedSteps = 5; + break; + case 'Proscenic': + _fanSpeedLabels = {0: 'sleep', 1: 'mid', 2: 'high', 3: 'auto'}; + fanSpeedSteps = 3; + this._rotationSteps = [...Array(4).keys()]; break; - default: // Just use numeric values - this._rotationSteps = [...Array(fanSpeedSteps).keys()]; + case 'siguro': + _fanSpeedLabels = {0: 'sleep', 1: 'auto'}; + fanSpeedSteps = 2; + this._rotationSteps = [...Array(2).keys()]; + break; + default: // Just use numeric values + this._rotationSteps = [...Array(fanSpeedSteps).keys()]; for (let i = 0; i <= fanSpeedSteps; i++) { _fanSpeedLabels[i] = i; } } - this._rotationStops = {0: _fanSpeedLabels[0]}; for (let i = 0; i < 100; i++) { const _rotationStep = Math.floor(fanSpeedSteps * i / 100); this._rotationStops[i+1] = _fanSpeedLabels[_rotationStep]; } } - this.airQualityLevels = [ [200, Characteristic.AirQuality.POOR], [150, Characteristic.AirQuality.INFERIOR], @@ -135,32 +135,25 @@ class AirPurifierAccessory extends BaseAccessory { [50, Characteristic.AirQuality.GOOD], [0, Characteristic.AirQuality.EXCELLENT], ]; - this.cmdAuto = 'AUTO'; if (this.device.context.cmdAuto) { if (/^a[a-z]+$/i.test(this.device.context.cmdAuto)) this.cmdAuto = ('' + this.device.context.cmdAuto).trim(); else throw new Error('The cmdAuto doesn\'t appear to be valid: ' + this.device.context.cmdAuto); } - } - /** * Register the services that this accessory supports. */ _registerPlatformAccessory() { const {Service} = this.hap; - /* Add the main air purifier */ this.accessory.addService(Service.AirPurifier, this.device.context.name); - /* If configured to include air quality data, include that service too */ if (this.device.context.showAirQuality) { this._addAirQualityService(); } - super._registerPlatformAccessory(); } - /** * Method to add the AirQualitySensor service to the accessory. * @@ -169,39 +162,33 @@ class AirPurifierAccessory extends BaseAccessory { */ _addAirQualityService() { const {Service} = this.hap; - const nameAirQuality = this.device.context.nameAirQuality || 'Air Quality'; this.log.info('Adding air quality sensor: %s', nameAirQuality); this.accessory.addService(Service.AirQualitySensor, nameAirQuality); } - /** * Register the Characteristics that this accessory supports. * @param {*} dps */ _registerCharacteristics(dps) { const {Service, Characteristic} = this.hap; - /* Air purifier service characteristics */ const airPurifierService = this.accessory.getService(Service.AirPurifier); this._checkServiceName(airPurifierService, this.device.context.name); - this.log.debug('_registerCharacteristics dps: %o', dps); - const characteristicActive = airPurifierService.getCharacteristic(Characteristic.Active) .updateValue(this._getActive(dps[DP_SWITCH])) .on('get', this.getActive.bind(this)) .on('set', this.setActive.bind(this)); - const characteristicCurrentAirPurifierState = airPurifierService.getCharacteristic(Characteristic.CurrentAirPurifierState) .updateValue(this._getCurrentAirPurifierState(dps[DP_SWITCH])) .on('get', this.getCurrentAirPurifierState.bind(this)); - const characteristicTargetAirPurifierState = airPurifierService.getCharacteristic(Characteristic.TargetAirPurifierState) - .updateValue(this._getTargetAirPurifierState(dps[DP_MODE])) - .on('get', this.getTargetAirPurifierState.bind(this)) - .on('set', this.setTargetAirPurifierState.bind(this)); + const characteristicTargetAirPurifierState = airPurifierService.getCharacteristic(Characteristic.TargetAirPurifierState) + .updateValue(this._getTargetAirPurifierState(this._getMode(dps))) + .on('get', this.getTargetAirPurifierState.bind(this)) + .on('set', this.setTargetAirPurifierState.bind(this)); let characteristicLockPhysicalControls; if (!this.device.context.noChildLock) { @@ -212,17 +199,14 @@ class AirPurifierAccessory extends BaseAccessory { } else { this._removeCharacteristic(service, Characteristic.LockPhysicalControls); } - const characteristicRotationSpeed = airPurifierService.getCharacteristic(Characteristic.RotationSpeed) .updateValue(this._getRotationSpeed(dps)) .on('get', this.getRotationSpeed.bind(this)) .on('set', this.setRotationSpeed.bind(this)); - /* Air quality sensor characteristics */ let airQualitySensorService = this.accessory.getService(Service.AirQualitySensor); let characteristicAirQuality; let characteristicPM25Density; - /* Ensure the air quality sensor service existance aligns with the configuration. * If configured to include air quality data, and the service was not already registered, register it. * If configured to not include it, but the service this there, remove it @@ -233,54 +217,63 @@ class AirPurifierAccessory extends BaseAccessory { } else if (airQualitySensorService && !this.device.context.showAirQuality) { this.accessory.removeService(airQualitySensorService); } - if (airQualitySensorService) { const nameAirQuality = this.device.context.nameAirQuality || 'Air Quality'; this._checkServiceName(airQualitySensorService, nameAirQuality); - characteristicAirQuality = airQualitySensorService.getCharacteristic(Characteristic.AirQuality) .updateValue(this._getAirQuality(dps)) .on('get', this.getAirQuality.bind(this)); - characteristicPM25Density = airQualitySensorService.getCharacteristic(Characteristic.PM2_5Density) .updateValue(dps[DP_PM25]) .on('get', this.getPM25.bind(this)); } - /* Listen for changes */ this.device.on('change', (changes, state) => { - this.log.debug('Changes: %o, State: %o', changes, state); - - if (changes.hasOwnProperty(DP_SWITCH)) { - /* On/Off state change */ - const newActive = this._getActive(changes[DP_SWITCH]); - if (characteristicActive.value !== newActive) { - characteristicActive.updateValue(newActive); - - characteristicCurrentAirPurifierState.updateValue( - this._getCurrentAirPurifierState(changes[DP_SWITCH])); - - if (!changes.hasOwnProperty(DP_FAN_SPEED)) { - characteristicRotationSpeed.updateValue(this._getRotationSpeed(state)); - } - if (!changes.hasOwnProperty(DP_MODE)) { - characteristicTargetAirPurifierState.updateValue( - this._getTargetAirPurifierState(state[DP_MODE])); - } - } - } - - if (changes.hasOwnProperty(DP_FAN_SPEED)) { - /* Fan speed change */ - const newRotationSpeed = this._getRotationSpeed(state); - if (characteristicRotationSpeed.value !== newRotationSpeed) { - characteristicRotationSpeed.updateValue(newRotationSpeed); - } + if (changes.hasOwnProperty(DP_SWITCH)) { + /* On/Off state change */ + const newActive = this._getActive(changes[DP_SWITCH]); + + // switch power on before other updates to avoid "Turning on..." state + if (changes[DP_SWITCH]) { + this.log.debug("Switching state first"); + characteristicActive.updateValue(newActive); + + characteristicCurrentAirPurifierState.updateValue( + this._getCurrentAirPurifierState(changes[DP_SWITCH])); + } + + if (!changes.hasOwnProperty(DP_FAN_SPEED)) { + characteristicRotationSpeed.updateValue(this._getRotationSpeed(state)); + } + if (!changes.hasOwnProperty(DP_MODE)) { + characteristicTargetAirPurifierState.updateValue( + this._getTargetAirPurifierState(this._getMode(state))); + } + + // switch power off after other updates to avoid "Turning off..." state + if (!changes[DP_SWITCH]) { + this.log.debug("Switching state last"); + characteristicCurrentAirPurifierState.updateValue( + this._getCurrentAirPurifierState(changes[DP_SWITCH])); + + characteristicActive.updateValue(newActive); + } + } + + if (changes.hasOwnProperty(DP_FAN_SPEED)) { + /* Fan speed change */ + const newRotationSpeed = this._getRotationSpeed(state); + // Proscenic "auto" fan speed is not mapped and should not trigger a rotation speed update + if (newRotationSpeed) { + if (characteristicRotationSpeed.value !== newRotationSpeed) { + characteristicRotationSpeed.updateValue(newRotationSpeed); + } + } if (!changes.hasOwnProperty(DP_MODE)) { characteristicTargetAirPurifierState.updateValue( - this._getTargetAirPurifierState(state[DP_MODE])); + this._getTargetAirPurifierState(this._getMode(state))); } } @@ -291,70 +284,65 @@ class AirPurifierAccessory extends BaseAccessory { characteristicLockPhysicalControls.updateValue(newLockPhysicalControls); } } - if (changes.hasOwnProperty(DP_MODE)) { /* Change to the running mode */ - const newTargetAirPurifierState = this._getTargetAirPurifierState(changes[DP_MODE]); if (characteristicTargetAirPurifierState.value !== newTargetAirPurifierState) { characteristicTargetAirPurifierState.updateValue(newTargetAirPurifierState); } } - if (airQualitySensorService && changes.hasOwnProperty(DP_PM25)) { /* Change to the air quality */ const newPM25 = changes[DP_PM25]; if (characteristicPM25Density.value !== newPM25) { characteristicPM25Density.updateValue(newPM25); } - if (!changes.hasOwnProperty(DP_AIR_QUALITY)) { characteristicAirQuality.updateValue(this._getAirQuality(state)); } } - }); - } - - getActive(callback) { - this.getState(DP_SWITCH, (err, dp) => { - if (err) { + }); + } + + /* Proscenic air purifier does not support DP_MODE but has fan speed 'auto' */ + _getMode(state) { + if (state[DP_MODE]) { + return state[DP_MODE]; + } else { + return state[DP_FAN_SPEED] == 'auto' ? 'auto' : 'manual'; + } + } + + getActive(callback) { + this.getState(DP_SWITCH, (err, dp) => { + if (err) { return callback(err); } - callback(null, this._getActive(dp)); }); } - _getActive(dp) { const {Characteristic} = this.hap; - return dp ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE; } - setActive(value, callback) { const {Characteristic} = this.hap; - switch (value) { case Characteristic.Active.ACTIVE: return this.setState(DP_SWITCH, true, callback); - case Characteristic.Active.INACTIVE: return this.setState(DP_SWITCH, false, callback); } - callback(); } - getAirQuality(callback) { this.getState([DP_PM25], (err, dps) => { if (err) { return callback(err); } - callback(null, this._getAirQuality(dps)); }); } - _getAirQuality(dps) { const {Characteristic} = this.hap; /* TODO: Other DP values can be used for Air Quality */ @@ -375,108 +363,82 @@ class AirPurifierAccessory extends BaseAccessory { } break; default: - if (dps[DP_PM25]) { - /* Loop through the air quality levels until a match is found */ for (var item of this.airQualityLevels) { if (dps[DP_PM25] >= item[0]) { return item[1]; } } - } } - /* Default return value if nothing has already returned */ return 0; - } - getCurrentAirPurifierState(callback) { - this.getState([DP_SWITCH], (err, dps) => { + this.getState(DP_SWITCH, (err, dp) => { if (err) return callback(err); - - callback(null, this._getCurrentAirPurifierState(dps)); + callback(null, this._getCurrentAirPurifierState(dp)); }); } - _getCurrentAirPurifierState(dp) { const {Characteristic} = this.hap; - - /* There isn't really a direct mapping to this from the purifier, * so just using as inactive or purifying. */ - return dp ? Characteristic.CurrentAirPurifierState.PURIFYING_AIR : Characteristic.CurrentAirPurifierState.INACTIVE; } - getLockPhysicalControls(callback) { this.getState(DP_LOCK_PHYSICAL_CONTROLS, (err, dp) => { if (err) { return callback(err); } - callback(null, this._getLockPhysicalControls(dp)); }); } - _getLockPhysicalControls(dp) { const {Characteristic} = this.hap; - return dp ? Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED : Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED; } - setLockPhysicalControls(value, callback) { const {Characteristic} = this.hap; - switch (value) { case Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED: return this.setState(DP_LOCK_PHYSICAL_CONTROLS, true, callback); - case Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED: return this.setState(DP_LOCK_PHYSICAL_CONTROLS, false, callback); } - callback(); - } + } - getPM25(callback) { - this.getState([DP_PM25], (err, dps) => { - if (err) { - return callback(err); - } - - callback(null, dps); - }); - } + getPM25(callback) { + this.getState(DP_PM25, (err, dp) => { + if (err) { + return callback(err); + } + callback(null, dp); + }); + } getRotationSpeed(callback) { this.getState([DP_SWITCH, DP_FAN_SPEED], (err, dps) => { if (err) { return callback(err); } - callback(null, this._getRotationSpeed(dps)); }); } - _getRotationSpeed(dps) { if (!dps[DP_SWITCH]) { return 0; } else if (this._hkRotationSpeed) { const currntRotationSpeed = this.convertRotationSpeedFromHomeKitToTuya(this._hkRotationSpeed); - return currntRotationSpeed === dps[DP_FAN_SPEED] ? this._hkRotationSpeed : this._hkRotationSpeed = this.convertRotationSpeedFromTuyaToHomeKit(dps[DP_FAN_SPEED]); } - return this._hkRotationSpeed = this.convertRotationSpeedFromTuyaToHomeKit(dps[DP_FAN_SPEED]); } - setRotationSpeed(value, callback) { const {Characteristic} = this.hap; - if (value === 0) { this.setActive(Characteristic.Active.INACTIVE, callback); } else { @@ -487,21 +449,20 @@ class AirPurifierAccessory extends BaseAccessory { //return this.setMultiState(newState, callback); return this.setState(DP_FAN_SPEED, this.convertRotationSpeedFromHomeKitToTuya(value), callback); } - } + } - getTargetAirPurifierState(callback) { - this.getState(DP_MODE, (err, dp) => { - if (err) { - return callback(err); - } + getTargetAirPurifierState(callback) { + this.getState([DP_MODE, DP_FAN_SPEED], (err, dps) => { + if (err) { + return callback(err); + } - callback(null, this._getTargetAirPurifierState(dp)); - }); - } + callback(null, this._getTargetAirPurifierState(this._getMode(dps))); + }); + } _getTargetAirPurifierState(dp) { const {Characteristic} = this.hap; - switch (dp) { case 'manual': case 'Manual': @@ -516,45 +477,55 @@ class AirPurifierAccessory extends BaseAccessory { return STATE_OTHER; } } - setTargetAirPurifierState(value, callback) { const {Characteristic} = this.hap; switch (value) { - case Characteristic.TargetAirPurifierState.MANUAL: + case Characteristic.TargetAirPurifierState.MANUAL: if (this.device.context.manufacturer == 'Breville') { - return this.setState(DP_MODE, 'manual', callback); + return this.setState(DP_MODE, 'manual', callback); + } else if (this.device.context.manufacturer == 'Proscenic') { + // When going from auto to manual, set to the lowest speed + return this.setState(DP_FAN_SPEED, 'sleep', callback); + + } else if (this.device.context.manufacturer == 'siguro') { + // When going from auto to manual, set to the lowest speed + return this.setState(DP_FAN_SPEED, 'sleep', callback); } else { return this.setState(DP_MODE, 'Manual', callback); } - case Characteristic.TargetAirPurifierState.AUTO: + case Characteristic.TargetAirPurifierState.AUTO: if (this.device.context.manufacturer == 'Breville') { - return this.setState(DP_MODE, 'auto', callback); + return this.setState(DP_MODE, 'auto', callback); + } else if (this.device.context.manufacturer == 'Proscenic') { + return this.setState(DP_FAN_SPEED, 'auto', callback); + } else if (this.device.context.manufacturer == 'siguro') { + return this.setState(DP_FAN_SPEED, 'auto', callback); } else { - return this.setState(DP_MODE, 'Auto', callback); + return this.setState(DP_MODE, 'Auto', callback); } default: //TODO: Can we do anything about Sleep? this.log.warn('Unhandled setTargetAirPurifierState value: %s', value); } - callback(); } - getKeyByValue(object, value) { return Object.keys(object).find(key => object[key] === value); } - convertRotationSpeedFromHomeKitToTuya(value) { this.log.debug('convertRotationSpeedFromHomeKitToTuya: %s: %s', value, this._rotationStops[parseInt(value)]); return this._rotationStops[parseInt(value)]; } - convertRotationSpeedFromTuyaToHomeKit(value) { - this.log.debug('convertRotationSpeedFromTuyaToHomeKit: %s: %s', value, this.getKeyByValue(this._rotationStops, value)); - return this.device.context.fanSpeedSteps ? '' + this.getKeyByValue(this._rotationStops, value) : this.getKeyByValue(this._rotationStops, value); - } - -} + convertRotationSpeedFromTuyaToHomeKit(value) { + this.log.debug('convertRotationSpeedFromTuyaToHomeKit: %s: %s', value, this.getKeyByValue(this._rotationStops, value)); + let speed = this.device.context.fanSpeedSteps ? '' + this.getKeyByValue(this._rotationStops, value) : this.getKeyByValue(this._rotationStops, value); + if (speed === undefined) { + return 0; + } + return speed; + } + } module.exports = AirPurifierAccessory; diff --git a/lib/BaseAccessory.js b/lib/BaseAccessory.js index 302a6130..c71fb8f1 100644 --- a/lib/BaseAccessory.js +++ b/lib/BaseAccessory.js @@ -137,6 +137,18 @@ class BaseAccessory { const scale = this.device.context.scaleBrightness || 255; return Math.round((99 * (value || 0) - 100 * min + scale) / (scale - min)); } + + convertRotationSpeedFromHomeKitToTuya(value) { + const max = this.device.context.maxSpeed || 3; + const scale = Math.floor(100 / max); + return Math.round(value / scale) + } + + convertRotationSpeedFromTuyaToHomeKit(value) { + const max = this.device.context.maxSpeed || 3; + const scale = Math.max(100 / max); + return Math.round(value * scale) + } convertColorTemperatureFromHomeKitToTuya(value) { const min = this.device.context.minWhiteColor || 140; diff --git a/lib/SimpleBlindsAccessory.js b/lib/SimpleBlindsAccessory.js index 8f088207..f628d82e 100644 --- a/lib/SimpleBlindsAccessory.js +++ b/lib/SimpleBlindsAccessory.js @@ -1,307 +1,298 @@ -const BaseAccessory = require('./BaseAccessory'); - -const BLINDS_OPENING = 'opening'; -const BLINDS_CLOSING = 'closing'; -const BLINDS_STOPPED = 'stopped'; - -const BLINDS_OPEN = 100; -const BLINDS_CLOSED = 0; - -class SimpleBlindsAccessory extends BaseAccessory { - static getCategory(Categories) { - return Categories.WINDOW_COVERING; - } - - constructor(...props) { - super(...props); - } - - _isType1() { - if (this.device.context.manufacturer.trim().toLowerCase() === 'type1') { - return true; - } else { - return false; - } - } - - _isType2() { - if (this.device.context.manufacturer.trim().toLowerCase() === 'type2') { - return true; - } else { - return false; - } - } - _registerPlatformAccessory() { - const {Service} = this.hap; - this.accessory.addService(Service.WindowCovering, this.device.context.name); - super._registerPlatformAccessory(); - } - - _registerCharacteristics(dps) { - const {Service, Characteristic} = this.hap; - const service = this.accessory.getService(Service.WindowCovering); - this._checkServiceName(service, this.device.context.name); - this.dpAction = this._getCustomDP(this.device.context.dpAction) || '1'; - - if (this._isType1()) { - let _cmdOpen = '1'; - } - else if (this._isBelleLife()) { - let _cmdOpen = 'on'; - } - else { - let _cmdOpen = '1'; - } - if (this.device.context.cmdOpen) { - _cmdOpen = ('' + this.device.context.cmdOpen).trim(); - } - - if (this._isType1()) { - let _cmdOpen = '2'; - } - else if (this._isType2()) { - let _cmdOpen = 'off'; - } - else { - let _cmdOpen = '2'; - } - if (this.device.context.cmdClose) { - _cmdClose = ('' + this.device.context.cmdClose).trim(); - } - - if (this._isType1()) { - let _cmdOpen = '3'; - } - else if (this._isType2()) { - let _cmdOpen = 'stop'; - } - else { - let _cmdOpen = '3'; - } - if (this.device.context.cmdStop) { - this.cmdStop = ('' + this.device.context.cmdStop).trim(); - } - - this.cmdOpen = _cmdOpen; - this.cmdClose = _cmdClose; - if (!!this.device.context.flipState) { - this.cmdOpen = _cmdClose; - this.cmdClose = _cmdOpen; - } - - this.duration = parseInt(this.device.context.timeToOpen) || 45; - const endingDuration = parseInt(this.device.context.timeToTighten) || 0; - this.minPosition = endingDuration ? Math.round(endingDuration * -100 / (this.duration - endingDuration)) : BLINDS_CLOSED; - - // If the blinds are closed, note it; if not, assume open because there is no way to know where it is - this.assumedPosition = dps[this.dpAction] === this.cmdClose ? this.minPosition : BLINDS_OPEN; - this.assumedState = BLINDS_STOPPED; - this.changeTime = this.targetPosition = false; - - const characteristicCurrentPosition = service.getCharacteristic(Characteristic.CurrentPosition) - .updateValue(this._getCurrentPosition(dps[this.dpAction])) - .on('get', this.getCurrentPosition.bind(this)); - - const characteristicTargetPosition = service.getCharacteristic(Characteristic.TargetPosition) - .updateValue(this._getTargetPosition(dps[this.dpAction])) - .on('get', this.getTargetPosition.bind(this)) - .on('set', this.setTargetPosition.bind(this)); - - const characteristicPositionState = service.getCharacteristic(Characteristic.PositionState) - .updateValue(this._getPositionState()) - .on('get', this.getPositionState.bind(this)); - - this.device.on('change', changes => { - this.log.info(" Blinds saw change to " + changes[this.dpAction]); - if (changes.hasOwnProperty(this.dpAction)) { - switch (changes[this.dpAction]) { - case this.cmdOpen: // Starting to open - this.assumedState = BLINDS_OPENING; - characteristicPositionState.updateValue(Characteristic.PositionState.INCREASING); - - // Only if change was external or someone internally asked for open - if (this.targetPosition === false || this.targetPosition === BLINDS_OPEN) { - this.targetPosition = false; - - const durationToOpen = Math.abs(this.assumedPosition - BLINDS_OPEN) * this.duration * 10; - this.changeTime = Date.now() - durationToOpen; - - this.log.info(" Blinds will be marked open in " + durationToOpen + "ms"); - - if (this.changeTimeout) clearTimeout(this.changeTimeout); - this.changeTimeout = setTimeout(() => { - characteristicCurrentPosition.updateValue(BLINDS_OPEN); - characteristicPositionState.updateValue(Characteristic.PositionState.STOPPED); - this.changeTime = false; - this.assumedPosition = BLINDS_OPEN; - this.assumedState = BLINDS_STOPPED; - this.log.info(" Blinds marked open"); - }, durationToOpen); - } - break; - - case this.cmdClose: // Starting to close - this.assumedState = BLINDS_CLOSING; - characteristicPositionState.updateValue(Characteristic.PositionState.DECREASING); - - // Only if change was external or someone internally asked for close - if (this.targetPosition === false || this.targetPosition === BLINDS_CLOSED) { - this.targetPosition = false; - - const durationToClose = Math.abs(this.assumedPosition - BLINDS_CLOSED) * this.duration * 10; - this.changeTime = Date.now() - durationToClose; - - this.log.info(" Blinds will be marked closed in " + durationToClose + "ms"); - - if (this.changeTimeout) clearTimeout(this.changeTimeout); - this.changeTimeout = setTimeout(() => { - characteristicCurrentPosition.updateValue(BLINDS_CLOSED); - characteristicPositionState.updateValue(Characteristic.PositionState.STOPPED); - this.changeTime = false; - this.assumedPosition = this.minPosition; - this.assumedState = BLINDS_STOPPED; - this.log.info(" Blinds marked closed"); - }, durationToClose); - } - break; - - case this.cmdStop: // Stopped in middle - if (this.changeTimeout) clearTimeout(this.changeTimeout); - - this.log.info(" Blinds last change was " + this.changeTime + "; " + (Date.now() - this.changeTime) + 'ms ago'); - - if (this.changeTime) { - /* - this.assumedPosition = Math.min(100 - this.minPosition, Math.max(0, Math.round((Date.now() - this.changeTime) / (10 * this.duration)))); - if (this.assumedState === BLINDS_CLOSING) this.assumedPosition = 100 - this.assumedPosition; - else this.assumedPosition += this.minPosition; - */ - const disposition = ((Date.now() - this.changeTime) / (10 * this.duration)); - if (this.assumedState === BLINDS_CLOSING) { - this.assumedPosition = BLINDS_OPEN - disposition; - } else { - this.assumedPosition = this.minPosition + disposition; - } - } - - const adjustedPosition = Math.max(0, Math.round(this.assumedPosition)); - characteristicCurrentPosition.updateValue(adjustedPosition); - characteristicTargetPosition.updateValue(adjustedPosition); - characteristicPositionState.updateValue(Characteristic.PositionState.STOPPED); - this.log.info(" Blinds marked stopped at " + adjustedPosition + "; assumed to be at " + this.assumedPosition); - - this.changeTime = this.targetPosition = false; - this.assumedState = BLINDS_STOPPED; - break; - } - } - }); - } - - getCurrentPosition(callback) { - this.getState(this.dpAction, (err, dp) => { - if (err) return callback(err); - - callback(null, this._getCurrentPosition(dp)); - }); - } - - _getCurrentPosition(dp) { - switch (dp) { - case this.cmdOpen: - return BLINDS_OPEN; - - case this.cmdClose: - return BLINDS_CLOSED; - - default: - return Math.max(BLINDS_CLOSED, Math.round(this.assumedPosition)); - } - } - - getTargetPosition(callback) { - this.getState(this.dpAction, (err, dp) => { - if (err) return callback(err); - - callback(null, this._getTargetPosition(dp)); - }); - } - - _getTargetPosition(dp) { - switch (dp) { - case this.cmdOpen: - return BLINDS_OPEN; - - case this.cmdClose: - return BLINDS_CLOSED; - - default: - return Math.max(BLINDS_CLOSED, Math.round(this.assumedPosition)); - } - } - - setTargetPosition(value, callback) { - this.log.info('Blinds asked to move from ' + this.assumedPosition + ' to ' + value); - - if (this.changeTimeout) clearTimeout(this.changeTimeout); - this.targetPosition = value; - - if (this.changeTime !== false) { - this.log.info(" Blinds " + (this.assumedState === BLINDS_CLOSING ? 'closing' : 'opening') + " had started " + this.changeTime + "; " + (Date.now() - this.changeTime) + 'ms ago'); - const disposition = ((Date.now() - this.changeTime) / (10 * this.duration)); - if (this.assumedState === BLINDS_CLOSING) { - this.assumedPosition = BLINDS_OPEN - disposition; - } else { - this.assumedPosition = this.minPosition + disposition; - } - this.log.info(" Blinds' adjusted assumedPosition is " + this.assumedPosition); - } - - const duration = Math.abs(this.assumedPosition - value) * this.duration * 10; - - if (Math.abs(value - this.assumedPosition) < 1) { - return this.setState(this.dpAction, this.cmdStop, callback); - } else if (value > this.assumedPosition) { - this.assumedState = BLINDS_OPENING; - this.setState(this.dpAction, this.cmdOpen, callback); - this.changeTime = Date.now() - Math.abs(this.assumedPosition - this.minPosition) * this.duration * 10; - } else { - this.assumedState = BLINDS_CLOSING; - this.setState(this.dpAction, this.cmdClose, callback); - this.changeTime = Date.now() - Math.abs(this.assumedPosition - BLINDS_OPEN) * this.duration * 10; - } - - if (value !== BLINDS_OPEN && value !== BLINDS_CLOSED) { - this.log.info(" Blinds will stop in " + duration + "ms"); - this.log.info(" Blinds assumed started " + this.changeTime + "; " + (Date.now() - this.changeTime) + 'ms ago'); - this.changeTimeout = setTimeout(() => { - this.log.info(" Blinds asked to stop"); - this.setState(this.dpAction, this.cmdStop); - }, duration); - } - } - - getPositionState(callback) { - const state = this._getPositionState(); - process.nextTick(() => { - callback(null, state); - }); - } - - _getPositionState() { - const {Characteristic} = this.hap; - - switch (this.assumedState) { - case BLINDS_OPENING: - return Characteristic.PositionState.INCREASING; - - case BLINDS_CLOSING: - return Characteristic.PositionState.DECREASING; - - default: - return Characteristic.PositionState.STOPPED; - } - } -} - -module.exports = SimpleBlindsAccessory; +const BaseAccessory = require('./BaseAccessory'); + +const BLINDS_OPENING = 'opening'; +const BLINDS_CLOSING = 'closing'; +const BLINDS_STOPPED = 'stopped'; + +const BLINDS_OPEN = 100; +const BLINDS_CLOSED = 0; + +class SimpleBlindsAccessory extends BaseAccessory { + static getCategory(Categories) { + return Categories.WINDOW_COVERING; + } + + constructor(...props) { + super(...props); + } + + _registerPlatformAccessory() { + const {Service} = this.hap; + + this.accessory.addService(Service.WindowCovering, this.device.context.name); + + super._registerPlatformAccessory(); + } + + _registerCharacteristics(dps) { + const {Service, Characteristic} = this.hap; + const service = this.accessory.getService(Service.WindowCovering); + this._checkServiceName(service, this.device.context.name); + this.dpBlindType = parseInt(this.device.context.dpBlindType) || 1; + this.dpAction = this._getCustomDP(this.device.context.dpAction) || '1'; + + //setting the defaults if no one declares anything. + this.OPEN_COMMAND = ''; + this.CLOSE_COMMAND = ''; + this.STOP_COMMAND = ''; + + if(this.dpBlindType === 1) + { + this.log.debug("Blinds Type 1 Selected"); + this.OPEN_COMMAND = 'open'; + this.CLOSE_COMMAND = 'close'; + this.STOP_COMMAND = 'stop'; + } else if (this.dpBlindType === 2) + { + this.log.debug("Blinds Type 2 Detected"); + this.OPEN_COMMAND = 'on'; + this.CLOSE_COMMAND = 'off'; + this.STOP_COMMAND = 'stop'; + + } else if (this.dpBlindType === 3) { + this.log.debug("Blinds Type 3 Selected"); + //this one here should fix https://github.com/iRayanKhan/homebridge-tuya/issues/444 when the type is set to 0 + this.OPEN_COMMAND = '1'; + this.CLOSE_COMMAND = '2'; + this.STOP_COMMAND = '3'; + } + + let _cmdOpen = this.OPEN_COMMAND; + if (this.device.context.cmdOpen) { + _cmdOpen = ('' + this.device.context.cmdOpen).trim(); + } + let _cmdClose = this.CLOSE_COMMAND; + + if (this.device.context.cmdClose) { + _cmdClose = ('' + this.device.context.cmdClose).trim(); + } + + let _cmdStop = this.STOP_COMMAND; + if (this.device.context.cmdStop) { + _cmdStop = ('' + this.device.context.cmdStop).trim(); + } + + this.cmdStop = _cmdStop; + this.cmdOpen = _cmdOpen; + this.cmdClose = _cmdClose; + if (!!this.device.context.flipState) { + this.cmdOpen = _cmdClose; + this.cmdClose = _cmdOpen; + } + + this.duration = parseInt(this.device.context.timeToOpen) || 45; + const endingDuration = parseInt(this.device.context.timeToTighten) || 0; + this.minPosition = endingDuration ? Math.round(endingDuration * -100 / (this.duration - endingDuration)) : BLINDS_CLOSED; + + // If the blinds are closed, note it; if not, assume open because there is no way to know where it is + this.assumedPosition = dps[this.dpAction] === this.cmdClose ? this.minPosition : BLINDS_OPEN; + this.assumedState = BLINDS_STOPPED; + this.changeTime = this.targetPosition = false; + + const characteristicCurrentPosition = service.getCharacteristic(Characteristic.CurrentPosition) + .updateValue(this._getCurrentPosition(dps[this.dpAction])) + .on('get', this.getCurrentPosition.bind(this)); + + const characteristicTargetPosition = service.getCharacteristic(Characteristic.TargetPosition) + .updateValue(this._getTargetPosition(dps[this.dpAction])) + .on('get', this.getTargetPosition.bind(this)) + .on('set', this.setTargetPosition.bind(this)); + + const characteristicPositionState = service.getCharacteristic(Characteristic.PositionState) + .updateValue(this._getPositionState()) + .on('get', this.getPositionState.bind(this)); + + this.device.on('change', changes => { + this.log.debug("[TuyaAccessory] Blinds saw change to " + changes[this.dpAction]); + if (changes.hasOwnProperty(this.dpAction)) { + switch (changes[this.dpAction]) { + case this.cmdOpen: // Starting to open + this.assumedState = BLINDS_OPENING; + characteristicPositionState.updateValue(Characteristic.PositionState.INCREASING); + + // Only if change was external or someone internally asked for open + if (this.targetPosition === false || this.targetPosition === BLINDS_OPEN) { + this.targetPosition = false; + + const durationToOpen = Math.abs(this.assumedPosition - BLINDS_OPEN) * this.duration * 10; + this.changeTime = Date.now() - durationToOpen; + + this.log.debug("[TuyaAccessory] Blinds will be marked open in " + durationToOpen + "ms"); + + if (this.changeTimeout) clearTimeout(this.changeTimeout); + this.changeTimeout = setTimeout(() => { + characteristicCurrentPosition.updateValue(BLINDS_OPEN); + characteristicPositionState.updateValue(Characteristic.PositionState.STOPPED); + this.changeTime = false; + this.assumedPosition = BLINDS_OPEN; + this.assumedState = BLINDS_STOPPED; + this.log.debug("[TuyaAccessory] Blinds marked open"); + }, durationToOpen); + } + break; + + case this.cmdClose: // Starting to close + this.assumedState = BLINDS_CLOSING; + characteristicPositionState.updateValue(Characteristic.PositionState.DECREASING); + + // Only if change was external or someone internally asked for close + if (this.targetPosition === false || this.targetPosition === BLINDS_CLOSED) { + this.targetPosition = false; + + const durationToClose = Math.abs(this.assumedPosition - BLINDS_CLOSED) * this.duration * 10; + this.changeTime = Date.now() - durationToClose; + + this.log.debug("[TuyaAccessory] Blinds will be marked closed in " + durationToClose + "ms"); + + if (this.changeTimeout) clearTimeout(this.changeTimeout); + this.changeTimeout = setTimeout(() => { + characteristicCurrentPosition.updateValue(BLINDS_CLOSED); + characteristicPositionState.updateValue(Characteristic.PositionState.STOPPED); + this.changeTime = false; + this.assumedPosition = this.minPosition; + this.assumedState = BLINDS_STOPPED; + this.log.debug("[TuyaAccessory] Blinds marked closed"); + }, durationToClose); + } + break; + + case this.cmdStop: // Stopped in middle + if (this.changeTimeout) clearTimeout(this.changeTimeout); + + this.log.debug("[TuyaAccessory] Blinds last change was " + this.changeTime + "; " + (Date.now() - this.changeTime) + 'ms ago'); + + if (this.changeTime) { + /* + this.assumedPosition = Math.min(100 - this.minPosition, Math.max(0, Math.round((Date.now() - this.changeTime) / (10 * this.duration)))); + if (this.assumedState === BLINDS_CLOSING) this.assumedPosition = 100 - this.assumedPosition; + else this.assumedPosition += this.minPosition; + */ + const disposition = ((Date.now() - this.changeTime) / (10 * this.duration)); + if (this.assumedState === BLINDS_CLOSING) { + this.assumedPosition = BLINDS_OPEN - disposition; + } else { + this.assumedPosition = this.minPosition + disposition; + } + } + + const adjustedPosition = Math.max(0, Math.round(this.assumedPosition)); + characteristicCurrentPosition.updateValue(adjustedPosition); + characteristicTargetPosition.updateValue(adjustedPosition); + characteristicPositionState.updateValue(Characteristic.PositionState.STOPPED); + this.log.debug("[TuyaAccessory] Blinds marked stopped at " + adjustedPosition + "; assumed to be at " + this.assumedPosition); + + this.changeTime = this.targetPosition = false; + this.assumedState = BLINDS_STOPPED; + break; + } + } + }); + } + + getCurrentPosition(callback) { + this.getState(this.dpAction, (err, dp) => { + if (err) return callback(err); + + callback(null, this._getCurrentPosition(dp)); + }); + } + + _getCurrentPosition(dp) { + switch (dp) { + case this.cmdOpen: + return BLINDS_OPEN; + + case this.cmdClose: + return BLINDS_CLOSED; + + default: + return Math.max(BLINDS_CLOSED, Math.round(this.assumedPosition)); + } + } + + getTargetPosition(callback) { + this.getState(this.dpAction, (err, dp) => { + if (err) return callback(err); + + callback(null, this._getTargetPosition(dp)); + }); + } + + _getTargetPosition(dp) { + switch (dp) { + case this.cmdOpen: + return BLINDS_OPEN; + + case this.cmdClose: + return BLINDS_CLOSED; + + default: + return Math.max(BLINDS_CLOSED, Math.round(this.assumedPosition)); + } + } + + setTargetPosition(value, callback) { + this.log.debug('[TuyaAccessory] Blinds asked to move from ' + this.assumedPosition + ' to ' + value); + + if (this.changeTimeout) clearTimeout(this.changeTimeout); + this.targetPosition = value; + + if (this.changeTime !== false) { + this.log.debug("[TuyaAccessory] Blinds " + (this.assumedState === BLINDS_CLOSING ? 'closing' : 'opening') + " had started " + this.changeTime + "; " + (Date.now() - this.changeTime) + 'ms ago'); + const disposition = ((Date.now() - this.changeTime) / (10 * this.duration)); + if (this.assumedState === BLINDS_CLOSING) { + this.assumedPosition = BLINDS_OPEN - disposition; + } else { + this.assumedPosition = this.minPosition + disposition; + } + this.log.debug("[TuyaAccessory] Blinds' adjusted assumedPosition is " + this.assumedPosition); + } + + const duration = Math.abs(this.assumedPosition - value) * this.duration * 10; + + if (Math.abs(value - this.assumedPosition) < 1) { + return this.setState(this.dpAction, this.cmdStop, callback); + } else if (value > this.assumedPosition) { + this.assumedState = BLINDS_OPENING; + this.setState(this.dpAction, this.cmdOpen, callback); + this.changeTime = Date.now() - Math.abs(this.assumedPosition - this.minPosition) * this.duration * 10; + } else { + this.assumedState = BLINDS_CLOSING; + this.setState(this.dpAction, this.cmdClose, callback); + this.changeTime = Date.now() - Math.abs(this.assumedPosition - BLINDS_OPEN) * this.duration * 10; + } + + if (value !== BLINDS_OPEN && value !== BLINDS_CLOSED) { + this.log.debug("[TuyaAccessory] Blinds will stop in " + duration + "ms"); + this.log.debug("[TuyaAccessory] Blinds assumed started " + this.changeTime + "; " + (Date.now() - this.changeTime) + 'ms ago'); + this.changeTimeout = setTimeout(() => { + this.log.debug("[TuyaAccessory] Blinds asked to stop"); + this.setState(this.dpAction, this.cmdStop); + }, duration); + } + } + + getPositionState(callback) { + const state = this._getPositionState(); + process.nextTick(() => { + callback(null, state); + }); + } + + _getPositionState() { + const {Characteristic} = this.hap; + + switch (this.assumedState) { + case BLINDS_OPENING: + return Characteristic.PositionState.INCREASING; + + case BLINDS_CLOSING: + return Characteristic.PositionState.DECREASING; + + default: + return Characteristic.PositionState.STOPPED; + } + } +} + +module.exports = SimpleBlindsAccessory; diff --git a/lib/SimpleFanAccessory.js b/lib/SimpleFanAccessory.js index 58bfe498..6b1180e3 100644 --- a/lib/SimpleFanAccessory.js +++ b/lib/SimpleFanAccessory.js @@ -20,12 +20,14 @@ class SimpleFanAccessory extends BaseAccessory { const serviceFan = this.accessory.getService(Service.Fan); this._checkServiceName(serviceFan, this.device.context.name); this.dpFanOn = this._getCustomDP(this.device.context.dpFanOn) || '1'; - this.dpRotationSpeed = this._getCustomDP(this.device.context.RotationSpeed) || '3'; + this.dpRotationSpeed = this._getCustomDP(this.device.context.dpRotationSpeed) || '3'; this.maxSpeed = parseInt(this.device.context.maxSpeed) || 3; // This variable is here so that we can set the fans to turn onto speed one instead of 3 on start. this.fanDefaultSpeed = parseInt(this.device.context.fanDefaultSpeed) || 1; // This variable is here as a workaround to allow for the on/off function to work. this.fanCurrentSpeed = 0; + // Add setting to use .toString() on return values or not. + this.useStrings = this._coerceBoolean(this.device.context.useStrings, true); const characteristicFanOn = serviceFan.getCharacteristic(Characteristic.On) .updateValue(this._getFanOn(dps[this.dpFanOn])) @@ -35,10 +37,10 @@ class SimpleFanAccessory extends BaseAccessory { const characteristicRotationSpeed = serviceFan.getCharacteristic(Characteristic.RotationSpeed) .setProps({ minValue: 0, - maxValue: this.maxSpeed, - minStep: 1 + maxValue: 100, + minStep: Math.max(100 / this.maxSpeed) }) - .updateValue(this._getSpeed(dps[this.dpRotationSpeed])) + .updateValue(this.convertRotationSpeedFromTuyaToHomeKit(dps[this.dpRotationSpeed])) .on('get', this.getSpeed.bind(this)) .on('set', this.setSpeed.bind(this)); @@ -47,8 +49,8 @@ class SimpleFanAccessory extends BaseAccessory { if (changes.hasOwnProperty(this.dpFanOn) && characteristicFanOn.value !== changes[this.dpFanOn]) characteristicFanOn.updateValue(changes[this.dpFanOn]); - if (changes.hasOwnProperty(this.dpRotationSpeed) && characteristicRotationSpeed.value !== changes[this.dpRotationSpeed]) - characteristicRotationSpeed.updateValue(changes[this.dpRotationSpeed]); + if (changes.hasOwnProperty(this.dpRotationSpeed) && this.convertRotationSpeedFromHomeKitToTuya(characteristicRotationSpeed.value) !== changes[this.dpRotationSpeed]) + characteristicRotationSpeed.updateValue(this.convertRotationSpeedFromTuyaToHomeKit(changes[this.dpRotationSpeed])); this.log.debug('SimpleFan changed: ' + JSON.stringify(state)); }); @@ -78,10 +80,18 @@ class SimpleFanAccessory extends BaseAccessory { } else { if (this.fanCurrentSpeed === 0) { // The current fanDefaultSpeed Variable is there to have the fan set to a sensible default if turned on. - return this.setMultiStateLegacy({[this.dpFanOn]: value, [this.dpRotationSpeed]: this.fanDefaultSpeed.toString()}, callback); + if (this.useStrings) { + return this.setMultiStateLegacy({[this.dpFanOn]: value, [this.dpRotationSpeed]: this.fanDefaultSpeed.toString()}, callback); + } else { + return this.setMultiStateLegacy({[this.dpFanOn]: value, [this.dpRotationSpeed]: this.fanDefaultSpeed}, callback); + } } else { // The current fanCurrentSpeed Variable is there to ensure the fan speed doesn't change if the fan is already on. - return this.setMultiStateLegacy({[this.dpFanOn]: value, [this.dpRotationSpeed]: this.fanCurrentSpeed.toString()}, callback); + if (this.useStrings) { + return this.setMultiStateLegacy({[this.dpFanOn]: value, [this.dpRotationSpeed]: this.fanCurrentSpeed.toString()}, callback); + } else { + return this.setMultiStateLegacy({[this.dpFanOn]: value, [this.dpRotationSpeed]: this.fanCurrentSpeed}, callback); + } } } callback(); @@ -91,26 +101,29 @@ class SimpleFanAccessory extends BaseAccessory { getSpeed(callback) { this.getState(this.dpRotationSpeed, (err, dp) => { if (err) return callback(err); - callback(null, this._getSpeed(dp)); + callback(null, this.convertRotationSpeedFromTuyaToHomeKit(this.device.state[this.dpRotationSpeed])); }); } - _getSpeed(dp) { - const {Characteristic} = this.hap; - return dp; - } - // Set the new fan speed setSpeed(value, callback) { const {Characteristic} = this.hap; if (value === 0) { // This is to set the fan speed variable to be 1 when the fan is off. - return this.setMultiStateLegacy({[this.dpFanOn]: false, [this.dpRotationSpeed]: this.fanDefaultSpeed.toString()}, callback); + if (this.useStrings) { + return this.setMultiStateLegacy({[this.dpFanOn]: false, [this.dpRotationSpeed]: this.fanDefaultSpeed.toString()}, callback); + } else { + return this.setMultiStateLegacy({[this.dpFanOn]: false, [this.dpRotationSpeed]: this.fanDefaultSpeed}, callback); + } } else { // This is to set the fan speed variable to match the current speed. - this.fanCurrentSpeed = value; - // This uses the multistate set command to send the fan on and speed request in one call. - return this.setMultiStateLegacy({[this.dpFanOn]: true, [this.dpRotationSpeed]: value.toString()}, callback); + this.fanCurrentSpeed = this.convertRotationSpeedFromHomeKitToTuya(value); + // This uses the multistatelegacy set command to send the fan on and speed request in one call. + if (this.useStrings) { + return this.setMultiStateLegacy({[this.dpFanOn]: true, [this.dpRotationSpeed]: this.convertRotationSpeedFromHomeKitToTuya(value).toString()}, callback); + } else { + return this.setMultiStateLegacy({[this.dpFanOn]: true, [this.dpRotationSpeed]: this.convertRotationSpeedFromHomeKitToTuya(value)}, callback); + } } callback(); } diff --git a/lib/SimpleFanLightAccessory.js b/lib/SimpleFanLightAccessory.js index 7b251c59..7c86e6a0 100644 --- a/lib/SimpleFanLightAccessory.js +++ b/lib/SimpleFanLightAccessory.js @@ -23,7 +23,7 @@ class SimpleFanLightAccessory extends BaseAccessory { this._checkServiceName(serviceFan, this.device.context.name); this._checkServiceName(serviceLightbulb, this.device.context.name + " Light"); this.dpFanOn = this._getCustomDP(this.device.context.dpFanOn) || '1'; - this.dpRotationSpeed = this._getCustomDP(this.device.context.RotationSpeed) || '3'; + this.dpRotationSpeed = this._getCustomDP(this.device.context.dpRotationSpeed) || '3'; this.dpLightOn = this._getCustomDP(this.device.context.dpLightOn) || '9'; this.dpBrightness = this._getCustomDP(this.device.context.dpBrightness) || '10'; this.useLight = this._coerceBoolean(this.device.context.useLight, true); @@ -33,6 +33,8 @@ class SimpleFanLightAccessory extends BaseAccessory { this.fanDefaultSpeed = parseInt(this.device.context.fanDefaultSpeed) || 1; // This variable is here as a workaround to allow for the on/off function to work. this.fanCurrentSpeed = 0; + // Add setting to use .toString() on return values or not. + this.useStrings = this._coerceBoolean(this.device.context.useStrings, true); const characteristicFanOn = serviceFan.getCharacteristic(Characteristic.On) .updateValue(this._getFanOn(dps[this.dpFanOn])) @@ -42,10 +44,10 @@ class SimpleFanLightAccessory extends BaseAccessory { const characteristicRotationSpeed = serviceFan.getCharacteristic(Characteristic.RotationSpeed) .setProps({ minValue: 0, - maxValue: this.maxSpeed, - minStep: 1 + maxValue: 100, + minStep: Math.max(100 / this.maxSpeed) }) - .updateValue(this._getSpeed(dps[this.dpRotationSpeed])) + .updateValue(this.convertRotationSpeedFromTuyaToHomeKit(dps[this.dpRotationSpeed])) .on('get', this.getSpeed.bind(this)) .on('set', this.setSpeed.bind(this)); @@ -75,8 +77,8 @@ class SimpleFanLightAccessory extends BaseAccessory { if (changes.hasOwnProperty(this.dpFanOn) && characteristicFanOn.value !== changes[this.dpFanOn]) characteristicFanOn.updateValue(changes[this.dpFanOn]); - if (changes.hasOwnProperty(this.dpRotationSpeed) && characteristicRotationSpeed.value !== changes[this.dpRotationSpeed]) - characteristicRotationSpeed.updateValue(changes[this.dpRotationSpeed]); + if (changes.hasOwnProperty(this.dpRotationSpeed) && this.convertRotationSpeedFromHomeKitToTuya(characteristicRotationSpeed.value) !== changes[this.dpRotationSpeed]) + characteristicRotationSpeed.updateValue(this.convertRotationSpeedFromTuyaToHomeKit(changes[this.dpRotationSpeed])); if (changes.hasOwnProperty(this.dpLightOn) && characteristicLightOn && characteristicLightOn.value !== changes[this.dpLightOn]) characteristicLightOn.updateValue(changes[this.dpLightOn]); @@ -112,10 +114,18 @@ class SimpleFanLightAccessory extends BaseAccessory { } else { if (this.fanCurrentSpeed === 0) { // The current fanDefaultSpeed Variable is there to have the fan set to a sensible default if turned on. - return this.setMultiStateLegacy({[this.dpFanOn]: value, [this.dpRotationSpeed]: this.fanDefaultSpeed.toString()}, callback); + if (this.useStrings) { + return this.setMultiStateLegacy({[this.dpFanOn]: value, [this.dpRotationSpeed]: this.fanDefaultSpeed.toString()}, callback); + } else { + return this.setMultiStateLegacy({[this.dpFanOn]: value, [this.dpRotationSpeed]: this.fanDefaultSpeed}, callback); + } } else { // The current fanCurrentSpeed Variable is there to ensure the fan speed doesn't change if the fan is already on. - return this.setMultiStateLegacy({[this.dpFanOn]: value, [this.dpRotationSpeed]: this.fanCurrentSpeed.toString()}, callback); + if (this.useStrings) { + return this.setMultiStateLegacy({[this.dpFanOn]: value, [this.dpRotationSpeed]: this.fanCurrentSpeed.toString()}, callback); + } else { + return this.setMultiStateLegacy({[this.dpFanOn]: value, [this.dpRotationSpeed]: this.fanCurrentSpeed}, callback); + } } } callback(); @@ -125,26 +135,29 @@ class SimpleFanLightAccessory extends BaseAccessory { getSpeed(callback) { this.getState(this.dpRotationSpeed, (err, dp) => { if (err) return callback(err); - callback(null, this._getSpeed(dp)); + callback(null, this.convertRotationSpeedFromTuyaToHomeKit(this.device.state[this.dpRotationSpeed])); }); } - _getSpeed(dp) { - const {Characteristic} = this.hap; - return dp; - } - // Set the new fan speed setSpeed(value, callback) { const {Characteristic} = this.hap; if (value === 0) { // This is to set the fan speed variable to be 1 when the fan is off. - return this.setMultiStateLegacy({[this.dpFanOn]: false, [this.dpRotationSpeed]: this.fanDefaultSpeed.toString()}, callback); + if (this.useStrings) { + return this.setMultiStateLegacy({[this.dpFanOn]: false, [this.dpRotationSpeed]: this.fanDefaultSpeed.toString()}, callback); + } else { + return this.setMultiStateLegacy({[this.dpFanOn]: false, [this.dpRotationSpeed]: this.fanDefaultSpeed}, callback); + } } else { // This is to set the fan speed variable to match the current speed. - this.fanCurrentSpeed = value; - // This uses the multistate set command to send the fan on and speed request in one call. - return this.setMultiStateLegacy({[this.dpFanOn]: true, [this.dpRotationSpeed]: value.toString()}, callback); + this.fanCurrentSpeed = this.convertRotationSpeedFromHomeKitToTuya(value); + // This uses the multistatelegacy set command to send the fan on and speed request in one call. + if (this.useStrings) { + return this.setMultiStateLegacy({[this.dpFanOn]: true, [this.dpRotationSpeed]: this.convertRotationSpeedFromHomeKitToTuya(value).toString()}, callback); + } else { + return this.setMultiStateLegacy({[this.dpFanOn]: true, [this.dpRotationSpeed]: this.convertRotationSpeedFromHomeKitToTuya(value)}, callback); + } } callback(); } diff --git a/lib/TuyaAccessory.js b/lib/TuyaAccessory.js index 6627fb47..e855f326 100644 --- a/lib/TuyaAccessory.js +++ b/lib/TuyaAccessory.js @@ -22,7 +22,7 @@ class TuyaAccessory extends EventEmitter { this.state = {}; this._cachedBuffer = Buffer.allocUnsafe(0); - this._msgQueue = async.queue(this[this.context.version < 3.2 ? '_msgHandler_3_1': '_msgHandler_3_3'].bind(this), 1); + this._msgQueue = async.queue(this[this.context.version < 3.2 ? '_msgHandler_3_1' : this.context.version === '3.4' ? '_msgHandler_3_4' : '_msgHandler_3_3'].bind(this), 1); if (this.context.version >= 3.2) { this.context.pingGap = Math.min(this.context.pingGap || 9, 9); @@ -34,6 +34,10 @@ class TuyaAccessory extends EventEmitter { this._connectionAttempts = 0; this._sendCounter = 0; + + this._tmpLocalKey = null; + this._tmpRemoteKey = null; + this.session_key = null; } _connect() { @@ -98,24 +102,37 @@ class TuyaAccessory extends EventEmitter { }; this._socket.on('connect', () => { - clearTimeout(this._socket._connTimeout); + if (this.context.version !== '3.4') { + clearTimeout(this._socket._connTimeout); - this.connected = true; - this.emit('connect'); - if (this._socket._pinger) - clearTimeout(this._socket._pinger); - this._socket._pinger = setTimeout(() => this._socket._ping(), 1000); + this.connected = true; + this.emit('connect'); + if (this._socket._pinger) + clearTimeout(this._socket._pinger); + this._socket._pinger = setTimeout(() => this._socket._ping(), 1000); - if (this.context.intro === false) { - this.emit('change', {}, this.state); - process.nextTick(this.update.bind(this)); + if (this.context.intro === false) { + this.emit('change', {}, this.state); + process.nextTick(this.update.bind(this)); + } } }); this._socket.on('ready', () => { if (this.context.intro === false) return; this.connected = true; - this.update(); + + if (this.context.version === '3.4') { + this._tmpLocalKey = crypto.randomBytes(16); + const payload = { + data: this._tmpLocalKey, + encrypted: true, + cmd: 3 //CommandType.BIND + } + this._send(payload); + } else { + this.update(); + } }); this._socket.on('data', msg => { @@ -174,11 +191,13 @@ class TuyaAccessory extends EventEmitter { this._socket.on('close', err => { this.connected = false; + this.session_key = null; //this.log.info('Closed connection with', this.context.name); }); this._socket.on('end', () => { this.connected = false; + this.session_key = null; this.log.info('Disconnected from', this.context.name); }); } @@ -348,6 +367,127 @@ class TuyaAccessory extends EventEmitter { callback(); } + _msgHandler_3_4(task, callback) { + if (!(task.msg instanceof Buffer)) return callback; + + const len = task.msg.length; + if (len < 16 || + task.msg.readUInt32BE(0) !== 0x000055aa || + task.msg.readUInt32BE(len - 4) !== 0x0000aa55 + ) return callback(); + + const size = task.msg.readUInt32BE(12); + if (len - 8 < size) return callback(); + + const cmd = task.msg.readUInt32BE(8); + + if (cmd === 7 || cmd === 13) return callback(); // ignoring + if (cmd === 9) { + if (this._socket._pinger) clearTimeout(this._socket._pinger); + this._socket._pinger = setTimeout(() => { + this._socket._ping(); + }, (this.context.pingGap || 20) * 1000); + + return callback(); + } + + let versionPos = task.msg.indexOf('3.4'); + const cleanMsg = task.msg.slice(versionPos === -1 ? len - size + ((task.msg.readUInt32BE(16) & 0xFFFFFF00) ? 0 : 4) : 15 + versionPos, len - 0x24); + + const expectedCrc = task.msg.slice(len - 0x24, task.msg.length - 4).toString('hex'); + const computedCrc = hmac(task.msg.slice(0, len - 0x24), this.session_key ?? this.context.key).toString('hex'); + + if (expectedCrc !== computedCrc) { + throw new Error(`HMAC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${task.msg.toString('hex')}`); + } + + let decryptedMsg; + const decipher = crypto.createDecipheriv('aes-128-ecb', this.session_key ?? this.context.key, null); + decipher.setAutoPadding(false) + decryptedMsg = decipher.update(cleanMsg); + decipher.final(); + //remove padding + decryptedMsg = decryptedMsg.slice(0, (decryptedMsg.length - decryptedMsg[decryptedMsg.length-1]) ) + + let parsedPayload; + try { + if (decryptedMsg.indexOf(this.context.version) === 0) { + decryptedMsg = decryptedMsg.slice(15) + } + let res = JSON.parse(decryptedMsg) + if('data' in res) { + let resdata = res.data + resdata.t = res.t + parsedPayload = resdata//res.data //for compatibility with tuya-mqtt + } else { + parsedPayload = res; + } + } catch (_) { + parsedPayload = decryptedMsg; + } + + if (cmd === 4) { // CommandType.RENAME_GW + this._tmpRemoteKey = parsedPayload.subarray(0, 16); + const calcLocalHmac = hmac(this._tmpLocalKey, this.session_key ?? this.context.key).toString('hex') + const expLocalHmac = parsedPayload.slice(16, 16 + 32).toString('hex') + if (expLocalHmac !== calcLocalHmac) { + throw new Error(`HMAC mismatch(keys): expected ${expLocalHmac}, was ${calcLocalHmac}. ${parsedPayload.toString('hex')}`); + } + const payload = { + data: hmac(this._tmpRemoteKey, this.context.key), + encrypted: true, + cmd: 5 //CommandType.RENAME_DEVICE + } + this._send(payload); + clearTimeout(this._socket._connTimeout); + + this.session_key = Buffer.from(this._tmpLocalKey) + for( let i=0; i this._socket._ping(), 1000); + + return callback(); + } + + if (cmd === 10 && parsedPayload === 'json obj data unvalid') { + this.log.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`); + this.emit('change', {}, this.state); + return callback(); + } + + switch (cmd) { + case 8: + case 10: + case 16: + if (parsedPayload) { + if (parsedPayload.dps) { + //this.log.info(`Heard back from ${this.context.name} with command ${cmd}`); + this._change(parsedPayload.dps); + } else { + this.log.info(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg); + this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex')); + } + } + break; + + default: + this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg); + this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex')); + } + + callback(); + } + update(o) { const dps = {}; let hasDataPoint = false; @@ -366,19 +506,32 @@ class TuyaAccessory extends EventEmitter { let result = false; if (hasDataPoint) { //this.log.info(" Sending", this.context.name, JSON.stringify(dps)); + const t = (Date.now() / 1000).toFixed(0); + const payload = { + devId: this.context.id, + uid: '', + t, + dps + }; + const data = this.context.version === '3.4' + ? { + data: { + ...payload, + ctype: 0, + t: undefined + }, + protocol:5, + t + } + : payload result = this._send({ - data: { - devId: this.context.id, - uid: '', - t: (Date.now() / 1000).toFixed(0), - dps: dps - }, - cmd: 7 + data, + cmd: this.context.version === '3.4' ? 13 : 7 }); if (result !== true) this.log.info(" Result", result); if (this.context.sendEmptyUpdate) { //this.log.info(" Sending", this.context.name, 'empty signature'); - this._send({cmd: 7}); + this._send({cmd: this.context.version === '3.4' ? 13 : 7}); } } else { //this.log.info(`Sending first query to ${this.context.name} (${this.context.version})`); @@ -387,7 +540,7 @@ class TuyaAccessory extends EventEmitter { gwId: this.context.id, devId: this.context.id }, - cmd: 10 + cmd: this.context.version === '3.4' ? 16 : 10 }); } @@ -415,7 +568,8 @@ class TuyaAccessory extends EventEmitter { if (!this.connected) return false; if (this.context.version < 3.2) return this._send_3_1(o); - return this._send_3_3(o); + if (this.context.version === '3.3') return this._send_3_3(o); + return this._send_3_4(o); } _send_3_1(o) { @@ -498,6 +652,77 @@ class TuyaAccessory extends EventEmitter { this.emit('change', dps, this.state); }, 1000); } + + _send_3_4(o) { + let {cmd, data} = {...o}; + + //data + if (!data) { + data = Buffer.allocUnsafe(0); + } + if (!(data instanceof Buffer)) { + if (typeof data !== 'string') { + data = JSON.stringify(data); + } + + data = Buffer.from(data); + } + + if (cmd !== 10 && + cmd !== 9 && + cmd !== 16 && + cmd !== 3 && + cmd !== 5 && + cmd !== 18) { + // Add 3.4 header + // check this: mqc_very_pcmcd_mcd(int a1, unsigned int a2) + const buffer = Buffer.alloc(data.length + 15); + Buffer.from('3.4').copy(buffer, 0); + data.copy(buffer, 15); + data = buffer; + } + + const padding=0x10 - (data.length & 0xf); + let buf34 = Buffer.alloc((data.length + padding), padding); + data.copy(buf34); + data = buf34 + const encrypted = encrypt34(data, this.session_key ?? this.context.key) + + const encryptedBuffer = Buffer.from(encrypted); + // Allocate buffer with room for payload + 24 bytes for + // prefix, sequence, command, length, crc, and suffix + const buffer = Buffer.alloc(encryptedBuffer.length + 52); + // Add prefix, command, and length + buffer.writeUInt32BE(0x000055AA, 0); + buffer.writeUInt32BE(cmd, 8); + buffer.writeUInt32BE(encryptedBuffer.length + 0x24, 12); + + // If sending empty dp-update command, we should not increment the sequence + if ((cmd !== 7 && cmd !== 13) || data) { + this._sendCounter++; + buffer.writeUInt32BE(this._sendCounter, 4); + } + + // Add payload, crc, and suffix + encryptedBuffer.copy(buffer, 16); + const calculatedCrc = hmac(buffer.slice(0, encryptedBuffer.length + 16), this.session_key ?? this.context.key);// & 0xFFFFFFFF; + calculatedCrc.copy(buffer, encryptedBuffer.length + 16); + buffer.writeUInt32BE(0x0000AA55, encryptedBuffer.length + 48); + + return this._socket.write(buffer); + } +} + +const encrypt34 = (data, encryptKey) => { + const cipher = crypto.createCipheriv('aes-128-ecb', encryptKey, null); + cipher.setAutoPadding(false); + let encrypted = cipher.update(data); + cipher.final(); + return encrypted; +} + +const hmac = (data, hmacKey) => { + return crypto.createHmac('sha256',hmacKey).update(data, 'utf8').digest(); } const crc32LookupTable = []; diff --git a/package.json b/package.json index cc9984ef..252296f8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "homebridge-tuya", - "version": "3.1.0", + "version": "3.1.1", "description": "🏠 Offical Homebridge plugin for TuyAPI ", "main": "index.js",