UNPKG

homebridge-broadlink-rm

Version:

Broadlink RM plugin (including the mini and pro) for homebridge: https://github.com/nfarina/homebridge

691 lines (502 loc) 26.3 kB
const { assert } = require('chai'); const uuid = require('uuid'); const fs = require('fs'); const findKey = require('find-key'); const delayForDuration = require('../helpers/delayForDuration'); const { getDevice } = require('../helpers/getDevice'); const BroadlinkRMAccessory = require('./accessory'); class AirConAccessory extends BroadlinkRMAccessory { serviceType () { return Service.Thermostat } constructor (log, config = {}) { super(log, config); // Characteristic isn't defined until runtime so we set these the instance scope const HeatingCoolingStates = { off: Characteristic.TargetHeatingCoolingState.OFF, cool: Characteristic.TargetHeatingCoolingState.COOL, heat: Characteristic.TargetHeatingCoolingState.HEAT, auto: Characteristic.TargetHeatingCoolingState.AUTO }; this.HeatingCoolingStates = HeatingCoolingStates; const HeatingCoolingConfigKeys = {}; HeatingCoolingConfigKeys[Characteristic.TargetHeatingCoolingState.OFF] = 'off'; HeatingCoolingConfigKeys[Characteristic.TargetHeatingCoolingState.COOL] = 'cool'; HeatingCoolingConfigKeys[Characteristic.TargetHeatingCoolingState.HEAT] = 'heat'; HeatingCoolingConfigKeys[Characteristic.TargetHeatingCoolingState.AUTO] = 'auto'; this.HeatingCoolingConfigKeys = HeatingCoolingConfigKeys; this.temperatureCallbackQueue = {}; this.monitorTemperature(); } correctReloadedState (state) { if (state.currentHeatingCoolingState === Characteristic.CurrentHeatingCoolingState.OFF) { state.targetTemperature = undefined } state.targetHeatingCoolingState = state.currentHeatingCoolingState; if (state.userSpecifiedTargetTemperature) state.targetTemperature = state.userSpecifiedTargetTemperature } setDefaults () { const { config, state } = this; // Set config default values if (config.turnOnWhenOff === undefined) config.turnOnWhenOff = config.sendOnWhenOff || false; // Backwards compatible with `sendOnWhenOff` if (config.minimumAutoOnOffDuration === undefined) config.minimumAutoOnOffDuration = config.autoMinimumDuration || 120; // Backwards compatible with `autoMinimumDuration` config.minTemperature = config.minTemperature || -15; config.maxTemperature = config.maxTemperature || 50; config.temperatureUpdateFrequency = config.temperatureUpdateFrequency || 10; config.units = config.units ? config.units.toLowerCase() : 'c'; config.temperatureAdjustment = config.temperatureAdjustment || 0; config.autoSwitchName = config.autoSwitch || config.autoSwitchName; if (config.preventResendHex === undefined && config.allowResend === undefined) { config.preventResendHex = false; } else if (config.allowResend !== undefined) { config.preventResendHex = !config.allowResend; } // When a temperature hex doesn't exist we try to use the hex set for these // default temperatures config.defaultCoolTemperature = config.defaultCoolTemperature || 16; config.defaultHeatTemperature = config.defaultHeatTemperature || 30; // Used to determine when we should use the defaultHeatTemperature or the // defaultHeatTemperature config.heatTemperature = config.heatTemperature || 22; // When we turn on the thermostat with Siri it comes thrugh as "auto" which // isn't particularly supported at this time so we convert the mode to cool // or heat // Note that this is only used when you use Siri or press Auto immediately // after launching Homebridge. The rest of the time we'll use your last known // temperature config.replaceAutoMode = config.replaceAutoMode || 'cool'; // Set state default values // state.targetTemperature = state.targetTemperature || config.minTemperature; state.currentHeatingCoolingState = state.currentHeatingCoolingState || Characteristic.CurrentHeatingCoolingState.OFF; state.targetHeatingCoolingState = state.targetHeatingCoolingState || Characteristic.TargetHeatingCoolingState.OFF; state.firstTemperatureUpdate = true; // Check required properties if (config.pseudoDeviceTemperature) { assert.isBelow(config.pseudoDeviceTemperature, config.maxTemperature + 1, `\x1b[31m[CONFIG ERROR] \x1b[33mpseudoDeviceTemperature\x1b[0m (${config.pseudoDeviceTemperature}) must be less than the maxTemperature (${config.maxTemperature})`) assert.isAbove(config.pseudoDeviceTemperature, config.minTemperature - 1, `\x1b[31m[CONFIG ERROR] \x1b[33mpseudoDeviceTemperature\x1b[0m (${config.pseudoDeviceTemperature}) must be more than the minTemperature (${config.minTemperature})`) } // minTemperature can't be more than 10 or HomeKit throws a fit assert.isBelow(config.minTemperature, 11, `\x1b[31m[CONFIG ERROR] \x1b[33mminTemperature\x1b[0m (${config.minTemperature}) must be <= 10`) // maxTemperature > minTemperature assert.isBelow(config.minTemperature, config.maxTemperature, `\x1b[31m[CONFIG ERROR] \x1b[33mmaxTemperature\x1b[0m (${config.minTemperature}) must be more than minTemperature (${config.minTemperature})`) } reset () { super.reset(); this.state.isRunningAutomatically = false; if (this.shouldIgnoreAutoOnOffPromise) { this.shouldIgnoreAutoOnOffPromise.cancel(); this.shouldIgnoreAutoOnOffPromise = undefined; this.shouldIgnoreAutoOnOff = false; } if (this.turnOnWhenOffDelayPromise) { this.turnOnWhenOffDelayPromise.cancel(); this.turnOnWhenOffDelayPromise = undefined; } } updateServiceTargetHeatingCoolingState (value) { const { serviceManager, state } = this; delayForDuration(0.2).then(() => { serviceManager.setCharacteristic(Characteristic.TargetHeatingCoolingState, value); }); } updateServiceCurrentHeatingCoolingState (value) { const { serviceManager, state } = this; delayForDuration(0.25).then(() => { serviceManager.setCharacteristic(Characteristic.CurrentHeatingCoolingState, value); }); } // Allows this accessory to know about switch accessories that can determine whether // auto-on/off should be permitted. updateAccessories (accessories) { const { config, name, log } = this; const { autoSwitchName } = config; if (!autoSwitchName) return; log(`${name} Linking autoSwitch "${autoSwitchName}"`) const autoSwitchAccessories = accessories.filter(accessory => accessory.name === autoSwitchName); if (autoSwitchAccessories.length === 0) return log(`${name} No accessory could be found with the name "${autoSwitchName}". Please update the "autoSwitchName" value or add a matching switch accessory.`); this.autoSwitchAccessory = autoSwitchAccessories[0]; } isAutoSwitchOn () { return (!this.autoSwitchAccessory || (this.autoSwitchAccessory && this.autoSwitchAccessory.state && this.autoSwitchAccessory.state.switchState)); } setTargetTemperature (hexData, previousValue) { const { config, log, name, state } = this; const { preventResendHex, minTemperature, maxTemperature } = config; if (state.targetTemperature === previousValue && preventResendHex && !this.previouslyOff) return; this.previouslyOff = false; if (state.targetTemperature < minTemperature) return log(`The target temperature (${this.targetTemperature}) must be more than the minTemperature (${minTemperature})`); if (state.targetTemperature > maxTemperature) return log(`The target temperature (${this.targetTemperature}) must be less than the maxTemperature (${maxTemperature})`); // Used within correctReloadedState() so that when re-launching the accessory it uses // this temperature rather than one automatically set. state.userSpecifiedTargetTemperature = state.targetTemperature; // Do the actual sending of the temperature this.sendTemperature(state.targetTemperature, previousValue); } async setTargetHeatingCoolingState (hexData, previousValue) { const { HeatingCoolingConfigKeys, HeatingCoolingStates, config, data, host, log, name, serviceManager, state, debug } = this; const { preventResendHex, defaultCoolTemperature, defaultHeatTemperature, replaceAutoMode } = config; const targetHeatingCoolingState = HeatingCoolingConfigKeys[state.targetHeatingCoolingState]; const lastUsedHeatingCoolingState = HeatingCoolingConfigKeys[state.lastUsedHeatingCoolingState]; const currentHeatingCoolingState = HeatingCoolingConfigKeys[state.currentHeatingCoolingState]; // Some calls are made to this without a value for some unknown reason if (state.targetHeatingCoolingState === undefined) return; // Check to see if it's changed if (state.targetHeatingCoolingState === state.currentHeatingCoolingState && preventResendHex) return; if (targetHeatingCoolingState === 'off') { this.updateServiceCurrentHeatingCoolingState(HeatingCoolingStates.off); await this.performSend(data.off); return; } // Perform the auto -> cool/heat conversion if `replaceAutoMode` is specified if (replaceAutoMode && targetHeatingCoolingState === 'auto') { log(`${name} setTargetHeatingCoolingState (converting from auto to ${replaceAutoMode})`); if (previousValue === Characteristic.TargetHeatingCoolingState.OFF) this.previouslyOff = true; this.updateServiceTargetHeatingCoolingState(HeatingCoolingStates[replaceAutoMode]); return; } let temperature; // Selecting a heating/cooling state allows a default temperature to be used for the given state. if (state.targetHeatingCoolingState === Characteristic.TargetHeatingCoolingState.HEAT) { temperature = defaultHeatTemperature; } else if (state.targetHeatingCoolingState === Characteristic.TargetHeatingCoolingState.COOL) { temperature = defaultCoolTemperature; } else { temperature = state.targetTemperature; } if (previousValue === Characteristic.TargetHeatingCoolingState.OFF) this.previouslyOff = true; serviceManager.setCharacteristic(Characteristic.TargetTemperature, temperature); } // Thermostat async sendTemperature (temperature, previousTemperature) { const { HeatingCoolingStates, config, data, host, log, name, state, debug } = this; const { preventResendHex, defaultCoolTemperature, heatTemperature, ignoreTemperatureWhenOff, sendTemperatureOnlyWhenOff } = config; log(`${name} Potential sendTemperature (${temperature})`); // If the air-conditioner is turned off then turn it on first and try this again if (this.checkTurnOnWhenOff()) { this.turnOnWhenOffDelayPromise = delayForDuration(.3); await this.turnOnWhenOffDelayPromise } const { hexData, finalTemperature } = this.getTemperatureHexData(temperature); state.targetTemperature = finalTemperature; const hasTemperatureChanged = (previousTemperature !== finalTemperature); if (!hasTemperatureChanged) { if (!state.firstTemperatureUpdate && state.currentHeatingCoolingState !== Characteristic.TargetHeatingCoolingState.OFF) { if (preventResendHex) return; } } if (!state.currentHeatingCoolingState && !state.targetHeatingCoolingState && ignoreTemperatureWhenOff) { log(`${name} Ignoring sendTemperature due to "ignoreTemperatureWhenOff": true`); return; } state.firstTemperatureUpdate = false; // Send the temperature hex this.log(`${name} sendTemperature (${state.targetTemperature}`); await this.performSend(hexData.data); if (!state.currentHeatingCoolingState && !state.targetHeatingCoolingState && sendTemperatureOnlyWhenOff) { return; } // Update the heating/cooling mode based on the temperature. let mode = hexData['pseudo-mode']; if (mode) assert.oneOf(mode, [ 'heat', 'cool', 'auto' ], `\x1b[31m[CONFIG ERROR] \x1b[33mpseudo-mode\x1b[0m should be one of "heat", "cool" or "auto"`); if (!mode) { if (state.targetTemperature < state.currentTemperature) { mode = 'cool'; } else if (state.targetTemperature > state.currentTemperature) { mode = 'heat'; } else { mode = 'auto'; } } this.log(`${name} sendTemperature (set mode to ${mode}`); state.targetHeatingCoolingState = HeatingCoolingStates[mode]; this.updateServiceCurrentHeatingCoolingState(HeatingCoolingStates[mode]); this.serviceManager.refreshCharacteristicUI(Characteristic.CurrentHeatingCoolingState); this.serviceManager.refreshCharacteristicUI(Characteristic.TargetHeatingCoolingState); } getTemperatureHexData (temperature) { const { config, data, name, state, debug } = this; const { defaultHeatTemperature, defaultCoolTemperature, heatTemperature } = config; let finalTemperature = temperature; let hexData = data[`temperature${temperature}`]; // You may not want to set the hex data for every single mode... if (!hexData) { const defaultTemperature = (temperature >= heatTemperature) ? defaultHeatTemperature : defaultCoolTemperature; hexData = data[`temperature${defaultTemperature}`]; assert(hexData, `\x1b[31m[CONFIG ERROR] \x1b[0m You need to provide a hex code for the following temperature: \x1b[33m{ "temperature${temperature}": { "data": "HEXCODE", "pseudo-mode" : "heat/cool" } }\x1b[0m or provide the default temperature: \x1b[33m { "temperature${defaultTemperature}": { "data": "HEXCODE", "pseudo-mode" : "heat/cool" } }\x1b[0m`); this.log(`${name} Update to default temperature (${defaultTemperature})`); finalTemperature = defaultTemperature; } return { finalTemperature, hexData } } async checkTurnOnWhenOff () { const { config, data, debug, host, log, name, state } = this; const { on } = data; if (state.currentHeatingCoolingState === Characteristic.TargetHeatingCoolingState.OFF && config.turnOnWhenOff) { log(`${name} sendTemperature (sending "on" hex before sending temperature)`); if (on) await this.performSend(on); return true; } return false; } // Device Temperature Methods async monitorTemperature () { const { config, host, log, name, state } = this; const { temperatureFilePath, pseudoDeviceTemperature } = config; if (temperatureFilePath) return; if (pseudoDeviceTemperature !== undefined) return; // Ensure a minimum of a 60 seconds update frequency const temperatureUpdateFrequency = Math.max(60, config.temperatureUpdateFrequency); const device = getDevice({ host, log }); // Try again in a second if we don't have a device yet if (!device) { await delayForDuration(1); this.monitorTemperature(); return; } log(`${name} monitorTemperature`); device.on('temperature', this.onTemperature.bind(this)); device.checkTemperature(); this.updateTemperatureUI(); if (!config.isUnitTest) setInterval(this.updateTemperatureUI.bind(this), temperatureUpdateFrequency * 1000) } onTemperature (temperature) { const { config, host, log, name, state } = this; const { minTemperature, maxTemperature, temperatureAdjustment } = config; // onTemperature is getting called twice. No known cause currently. // This helps prevent the same temperature from being processed twice if (Object.keys(this.temperatureCallbackQueue).length === 0) return; temperature += temperatureAdjustment state.currentTemperature = temperature; log(`${name} onTemperature (${temperature})`); if (temperature > config.maxTemperature) { log(`\x1b[35m[INFO]\x1b[0m Reported temperature (${temperature}) is too high, setting to \x1b[33mmaxTemperature\x1b[0m (${maxTemperature}).`) temperature = config.maxTemperature } if (temperature < config.minTemperature) { log(`\x1b[35m[INFO]\x1b[0m Reported temperature (${temperature}) is too low, setting to \x1b[33mminTemperature\x1b[0m (${minTemperature}).`) temperature = config.minTemperature } assert.isBelow(temperature, config.maxTemperature + 1, `\x1b[31m[CONFIG ERROR] \x1b[33mmaxTemperature\x1b[0m (${config.maxTemperature}) must be more than the reported temperature (${temperature})`) assert.isAbove(temperature, config.minTemperature - 1, `\x1b[31m[CONFIG ERROR] \x1b[33mminTemperature\x1b[0m (${config.maxTemperature}) must be less than the reported temperature (${temperature})`) this.processQueuedTemperatureCallbacks(temperature); } addTemperatureCallbackToQueue (callback) { const { config, host, log, name, state } = this; const { mqttURL, temperatureFilePath } = config; // Clear the previous callback if (Object.keys(this.temperatureCallbackQueue).length > 1) { if (state.currentTemperature) { log(`${name} addTemperatureCallbackToQueue (clearing previous callback, using existing temperature)`); this.processQueuedTemperatureCallbacks(state.currentTemperature); } } // Add a new callback const callbackIdentifier = uuid.v4(); this.temperatureCallbackQueue[callbackIdentifier] = callback; // Read temperature from file if (temperatureFilePath) { this.updateTemperatureFromFile(); return; } // Read temperature from mqtt if (mqttURL) { const temperature = this.mqttValueForIdentifier('temperature'); this.onTemperature(temperature || 0); return; } // Read temperature from Broadlink RM device // If the device is no longer available, use previous tempeature const device = getDevice({ host, log }); if (!device || device.state === 'inactive') { if (device && device.state === 'inactive') { log(`${name} addTemperatureCallbackToQueue (device no longer active, using existing temperature)`); } this.processQueuedTemperatureCallbacks(state.currentTemperature || 0); return; } device.checkTemperature(); log(`${name} addTemperatureCallbackToQueue (requested temperature from device, waiting)`); } updateTemperatureFromFile () { const { config, debug, host, log, name } = this; const { temperatureFilePath } = config; if (debug) log(`\x1b[33m[DEBUG]\x1b[0m ${name} updateTemperatureFromFile reading file: ${temperatureFilePath}`); fs.readFile(temperatureFilePath, 'utf8', (err, temperature) => { if (err) { log(`\x1b[31m[ERROR] \x1b[0m${name} updateTemperatureFromFile\n\n${err.message}`); return; } if (temperature === undefined || temperature.trim().length === 0) { log(`\x1b[31m[ERROR] \x1b[0m${name} updateTemperatureFromFile (no temperature found)`); temperature = (state.currentTemperature || 0); } if (debug) log(`\x1b[33m[DEBUG]\x1b[0m ${name} updateTemperatureFromFile (file content: ${temperature.trim()})`); temperature = parseFloat(temperature); if (debug) log(`\x1b[33m[DEBUG]\x1b[0m ${name} updateTemperatureFromFile (parsed temperature: ${temperature})`); this.onTemperature(temperature); }); } processQueuedTemperatureCallbacks (temperature) { if (Object.keys(this.temperatureCallbackQueue).length === 0) return; Object.keys(this.temperatureCallbackQueue).forEach((callbackIdentifier) => { const callback = this.temperatureCallbackQueue[callbackIdentifier]; callback(null, temperature); delete this.temperatureCallbackQueue[callbackIdentifier]; }) this.temperatureCallbackQueue = {}; this.checkTemperatureForAutoOnOff(temperature); } updateTemperatureUI () { const { serviceManager } = this; serviceManager.refreshCharacteristicUI(Characteristic.CurrentTemperature) } getCurrentTemperature (callback) { const { config, host, log, name, state } = this; const { pseudoDeviceTemperature } = config; // Some devices don't include a thermometer and so we can use `pseudoDeviceTemperature` instead if (pseudoDeviceTemperature !== undefined) { log(`${name} getCurrentTemperature (using pseudoDeviceTemperature ${pseudoDeviceTemperature} from config)`); return callback(null, pseudoDeviceTemperature); } this.addTemperatureCallbackToQueue(callback); } async checkTemperatureForAutoOnOff (temperature) { const { config, host, log, name, serviceManager, state } = this; let { autoHeatTemperature, autoCoolTemperature, minimumAutoOnOffDuration } = config; if (this.shouldIgnoreAutoOnOff) { this.log(`${name} checkTemperatureForAutoOn (ignore within ${minimumAutoOnOffDuration}s of previous auto-on/off due to "minimumAutoOnOffDuration")`); return; } if (!autoHeatTemperature && !autoCoolTemperature) return; if (!this.isAutoSwitchOn()) { this.log(`${name} checkTemperatureForAutoOnOff (autoSwitch is off)`); return; } this.log(`${name} checkTemperatureForAutoOnOff`); if (autoHeatTemperature && temperature < autoHeatTemperature) { this.state.isRunningAutomatically = true; this.log(`${name} checkTemperatureForAutoOnOff (${temperature} < ${autoHeatTemperature}: auto heat)`); serviceManager.setCharacteristic(Characteristic.TargetHeatingCoolingState, Characteristic.TargetHeatingCoolingState.HEAT); } else if (autoCoolTemperature && temperature > autoCoolTemperature) { this.state.isRunningAutomatically = true; this.log(`${name} checkTemperatureForAutoOnOff (${temperature} > ${autoCoolTemperature}: auto cool)`); serviceManager.setCharacteristic(Characteristic.TargetHeatingCoolingState, Characteristic.TargetHeatingCoolingState.COOL); } else { this.log(`${name} checkTemperatureForAutoOnOff (temperature is ok)`); if (this.state.isRunningAutomatically) { this.isAutomatedOff = true; this.log(`${name} checkTemperatureForAutoOnOff (auto off)`); serviceManager.setCharacteristic(Characteristic.TargetHeatingCoolingState, Characteristic.TargetHeatingCoolingState.OFF); } else { return; } } this.shouldIgnoreAutoOnOff = true; this.shouldIgnoreAutoOnOffPromise = delayForDuration(minimumAutoOnOffDuration); await this.shouldIgnoreAutoOnOffPromise; this.shouldIgnoreAutoOnOff = false; } getTemperatureDisplayUnits (callback) { const { config } = this; const temperatureDisplayUnits = (config.units.toLowerCase() === 'f') ? Characteristic.TemperatureDisplayUnits.FAHRENHEIT : Characteristic.TemperatureDisplayUnits.CELSIUS; callback(temperatureDisplayUnits); } // MQTT onMQTTMessage (identifier, message) { const { debug, log, name } = this; if (identifier !== 'unknown' && identifier !== 'temperature') { log(`\x1b[31m[ERROR] \x1b[0m${name} onMQTTMessage (mqtt message received with unexpected identifier: ${identifier}, ${message.toString()})`); return; } super.onMQTTMessage(identifier, message); let temperature = this.mqttValuesTemp[identifier]; if (debug) log(`\x1b[33m[DEBUG]\x1b[0m ${name} onMQTTMessage (raw value: ${temperature})`); try { const temperatureJSON = JSON.parse(temperature); if (typeof temperatureJSON === 'object') { let values = findKey(temperatureJSON, 'temp'); if (values.length === 0) values = findKey(temperatureJSON, 'Temp'); if (values.length === 0) values = findKey(temperatureJSON, 'temperature'); if (values.length === 0) values = findKey(temperatureJSON, 'Temperature'); if (values.length > 0) { temperature = values[0]; } else { temperature = undefined; } } } catch (err) {} if (temperature === undefined || (typeof temperature === 'string' && temperature.trim().length === 0)) { log(`\x1b[31m[ERROR] \x1b[0m${name} onMQTTMessage (mqtt temperature temperature not found)`); return; } if (debug) log(`\x1b[33m[DEBUG]\x1b[0m ${name} onMQTTMessage (raw value 2: ${temperature.trim()})`); temperature = parseFloat(temperature); if (debug) log(`\x1b[33m[DEBUG]\x1b[0m ${name} onMQTTMessage (parsed temperature: ${temperature})`); this.mqttValues[identifier] = temperature; this.updateTemperatureUI(); } // Service Manager Setup configureServiceManager (serviceManager) { const { config } = this; const { minTemperature, maxTemperature } = config; serviceManager.addToggleCharacteristic({ name: 'currentHeatingCoolingState', type: Characteristic.CurrentHeatingCoolingState, getMethod: this.getCharacteristicValue, setMethod: this.setCharacteristicValue, bind: this, props: { } }); serviceManager.addToggleCharacteristic({ name: 'targetTemperature', type: Characteristic.TargetTemperature, getMethod: this.getCharacteristicValue, setMethod: this.setCharacteristicValue, bind: this, props: { setValuePromise: this.setTargetTemperature.bind(this), ignorePreviousValue: true } }); serviceManager.addToggleCharacteristic({ name: 'targetHeatingCoolingState', type: Characteristic.TargetHeatingCoolingState, getMethod: this.getCharacteristicValue, setMethod: this.setCharacteristicValue, bind: this, props: { setValuePromise: this.setTargetHeatingCoolingState.bind(this), ignorePreviousValue: true } }); serviceManager.addGetCharacteristic({ name: 'currentTemperature', type: Characteristic.CurrentTemperature, method: this.getCurrentTemperature, bind: this }) serviceManager.addGetCharacteristic({ name: 'temperatureDisplayUnits', type: Characteristic.TemperatureDisplayUnits, method: this.getTemperatureDisplayUnits, bind: this }) serviceManager .getCharacteristic(Characteristic.TargetTemperature) .setProps({ minValue: minTemperature, maxValue: maxTemperature, minStep: 1 }); serviceManager .getCharacteristic(Characteristic.CurrentTemperature) .setProps({ minValue: minTemperature, maxValue: maxTemperature, minStep: 0.1 }); } } module.exports = AirConAccessory