homebridge-broadlink-rm-pro
Version:
Broadlink RM plugin (including the mini and pro) for homebridge with AC Pro and TV features
1,136 lines (985 loc) • 54.6 kB
JavaScript
const { assert } = require('chai');
const uuid = require('uuid');
const fs = require('fs');
const findKey = require('find-key');
const delayForDuration = require('../helpers/delayForDuration');
const ServiceManagerTypes = require('../helpers/serviceManagerTypes');
const catchDelayCancelError = require('../helpers/catchDelayCancelError');
const { getDevice, discoverDevices } = require('../helpers/getDevice');
const BroadlinkRMAccessory = require('./accessory');
// Initializing predefined constants based on homekit API
// All temperature values passed and received from homekit API are defined in degree Celsius
const COOLING_THRESHOLD_TEMPERATURE = {
minValue: 10,
maxValue: 30,
minStep: 0.1
}
const HEATING_THRESHOLD_TEMPERATURE = {
minValue: 18,
maxValue: 25,
minStep: 0.1
}
const CharacteristicName = {
ACTIVE: "active",
CURRENT_HEATER_COOLER_STATE: "currentHeaterCoolerState",
TARGET_HEATER_COOLER_STATE: "targetHeaterCoolerState",
CURRENT_TEMPERATURE: "currentTemperature",
COOLING_THRESHOLD_TEMPERATURE: "coolingThresholdTemperature",
HEATING_THRESHOLD_TEMPERATURE: "heatingThresholdTemperature",
ROTATION_SPEED: "rotationSpeed",
SWING_MODE: "swingMode",
SLEEP: "sleep"
}
/**
* This accessory implements the HAP Service and Characteristics as documented under
* https://developers.homebridge.io/#/service/HeaterCooler.
*
* Implemented Characteristics
* 1. Active
* 2. Current Heater Cooler State
* 3. Target Heater Cooler State (Cool & Heat only)
* 4. Current Temperature
* 5. Cooling Threshold Temperature
* 6. Heating Threshold Temperature
* 7. Rotation Speed
* 8. Swing Mode (Oscillation)
*/
class HeaterCoolerAccessory extends BroadlinkRMAccessory {
/**
*
* @param {func} log - function used for logging
* @param {object} config - object with config data for accessory
* @param {classType} serviceManagerType - represents object type of service manager
*/
constructor(log, config = {}, serviceManagerType) {
super(log, config, serviceManagerType);
// Fakegato setup
if (config.noHistory !== true) {
this.displayName = config.name;
this.lastUpdatedAt = undefined;
this.historyService = new HistoryService("room", this, { storage: 'fs', filename: 'RMPro_' + config.name.replace(' ', '-') + '_persist.json' });
this.historyService.log = this.log;
}
this.temperatureCallbackQueue = {};
this.monitorTemperature();
}
/**
* Setting default state of accessory for each defined characteristic. This ensures that
* getCharacteristic calls provide valid data on first run.
* Prerequisties: this.config is validated and defaults are initialized.
*/
setDefaults() {
const { config, state } = this
const { coolingThresholdTemperature, heatingThresholdTemperature, defaultMode, defaultRotationSpeed } = config
// For backwards compatibility with resend hex
if (config.preventResendHex === undefined && config.allowResend === undefined) {
config.preventResendHex = false;
} else if (config.allowResend !== undefined) {
config.preventResendHex = !config.allowResend;
}
config.allowResend = !config.preventResendHex;
config.turnOnWhenOff = config.turnOnWhenOff === undefined ? true : config.turnOnWhenOff;
state.active = state.active || Characteristic.Active.INACTIVE
state.currentHeaterCoolerState = state.currentHeaterCoolerState || Characteristic.CurrentHeaterCoolerState.INACTIVE
if (state.coolingThresholdTemperature === undefined) { state.coolingThresholdTemperature = coolingThresholdTemperature }
if (state.heatingThresholdTemperature === undefined) { state.heatingThresholdTemperature = heatingThresholdTemperature }
if (state.targetHeaterCoolerState === undefined) {
state.targetHeaterCoolerState = defaultMode === "cool" ? Characteristic.TargetHeaterCoolerState.COOL : Characteristic.TargetHeaterCoolerState.HEAT
}
if (state.currentTemperature === undefined) { state.currentTemperature = config.defaultNowTemperature }
config.temperatureAdjustment = config.temperatureAdjustment || 0;
config.humidityAdjustment = config.humidityAdjustment || 0;
if (config.mqttURL) {
//MQTT updates when published so frequent refreshes aren't required ( 10 minute default as a fallback )
config.temperatureUpdateFrequency = config.temperatureUpdateFrequency || 600;
} else {
config.temperatureUpdateFrequency = config.temperatureUpdateFrequency || 10;
}
const { internalConfig } = config
const { available } = internalConfig
if (available.cool.rotationSpeed || available.heat.rotationSpeed) {
if (state.rotationSpeed === undefined) { state.rotationSpeed = defaultRotationSpeed }
}
if (available.cool.swingMode || available.heat.swingMode) {
if (state.swingMode === undefined) { state.swingMode = Characteristic.SwingMode.SWING_DISABLED }
}
}
/**
********************************************************
* SETTERS *
********************************************************
*/
/**
* Updates the characteristic value for current heater cooler in homebridge service along with
* updating cached state based on whether the device is set to cool or heat.
*/
updateServiceCurrentHeaterCoolerState() {
const { serviceManager, state, log, logLevel } = this
const { targetHeaterCoolerState } = state
if (!state.active) {
state.currentHeaterCoolerState = Characteristic.CurrentHeaterCoolerState.INACTIVE
delayForDuration(0.25).then(() => {
serviceManager.setCharacteristic(Characteristic.CurrentHeaterCoolerState, Characteristic.CurrentHeaterCoolerState.INACTIVE)
})
return
}
switch (targetHeaterCoolerState) {
// TODO: support Auto mode
case Characteristic.TargetHeaterCoolerState.HEAT:
state.currentHeaterCoolerState = Characteristic.CurrentHeaterCoolerState.HEATING
delayForDuration(0.25).then(() => {
serviceManager.setCharacteristic(Characteristic.CurrentHeaterCoolerState, Characteristic.CurrentHeaterCoolerState.HEATING)
})
break
case Characteristic.TargetHeaterCoolerState.COOL:
state.currentHeaterCoolerState = Characteristic.CurrentHeaterCoolerState.COOLING
delayForDuration(0.25).then(() => {
serviceManager.setCharacteristic(Characteristic.CurrentHeaterCoolerState, Characteristic.CurrentHeaterCoolerState.COOLING)
})
break
default:
}
if (logLevel <= 2) {log(`Updated currentHeaterCoolerState to ${state.currentHeaterCoolerState}`)}
}
/**
* Homekit automatically requests Characteristic.Active and Characteristics.TargetHeaterCoolerState
* when the device is turned on. However it doesn't specify a temperature. We use a default temperature
* from the config file to determine which hex codes to send if temperature control is supported.
*
* setTargetHeaterCoolerState() is called by the main handler after updating the state.targetHeaterCoolerState
* to the latest requested value. Method is only invoked by Homekit when going from 'off' -> 'any mode' or
* 'heat/cool/auto' -> 'heat/cool/auto'. Method is not called when turning off device.
* Characteristic.Active is either already 'Active' or set to 'Active' prior to method call.
* This sub-handler is only called if the previous targetHeaterCoolerState value is different from the new
* requested value
*
* Prerequisites: this.state is updated with the latest requested value for Target Heater Cooler State
* @param {any} hexData The decoded data that is passed in by handler
* @param {int} previousValue Previous value for targetHeaterCoolerState
*/
async setTargetHeaterCoolerState(hexData, previousValue) {
const { config, data, state, log, logLevel } = this
const { internalConfig } = config
const { available } = internalConfig
const { targetHeaterCoolerState } = state
let { heatingThresholdTemperature, coolingThresholdTemperature } = state
if (logLevel <= 2) {log(`Changing target state from ${previousValue} to ${targetHeaterCoolerState}`)}
switch (targetHeaterCoolerState) {
case Characteristic.TargetHeaterCoolerState.COOL:
if (available.cool.temperatureCodes) {
// update internal state to be consistent with what Home app & homebridge see
coolingThresholdTemperature = this.serviceManager.getCharacteristic(Characteristic.CoolingThresholdTemperature).value
}
hexData = this.decodeHexFromConfig(CharacteristicName.TARGET_HEATER_COOLER_STATE)
break
case Characteristic.TargetHeaterCoolerState.HEAT:
if (available.heat.temperatureCodes) {
// update internal state to be consistent with what Home app & homebridge see
heatingThresholdTemperature = this.serviceManager.getCharacteristic(Characteristic.HeatingThresholdTemperature).value
}
hexData = this.decodeHexFromConfig(CharacteristicName.TARGET_HEATER_COOLER_STATE)
break
default:
if (logLevel <= 4) {log(`BUG: ${this.name} setTargetHeaterCoolerState invoked with unsupported target mode ${targetHeaterCoolerState}`)}
}
await this.performSend(hexData)
// Update current heater cooler state to match the new state
this.updateServiceCurrentHeaterCoolerState()
return;
}
/**
* Returns hexcodes from config file to operate the device. Hexcodes are decoded based on the requested targetHeaterCoolerState
* currently stored in the cached state
* @param {CharacteristicName} toUpdateCharacteristic - string name of the characteristic that is being updated by the caller
* @returns {any} hexData - object, array or string values to be sent to IR device
*/
decodeHexFromConfig(toUpdateCharacteristic) {
const { state, config, data, log, logLevel, name } = this
const { heatingThresholdTemperature, coolingThresholdTemperature, targetHeaterCoolerState } = state
const { heat, cool } = data
const { available } = config.internalConfig
var temperature
switch (targetHeaterCoolerState) {
case Characteristic.TargetHeaterCoolerState.COOL:
temperature = coolingThresholdTemperature
if (!available.coolMode) {
if (logLevel <= 4) {log(`BUG: ${name} decodeHexFromConfig invoked with unsupported target mode: cool.`)}
return "0'" // sending dummy hex data to prevent homebridge from tripping
}
if (toUpdateCharacteristic === CharacteristicName.ACTIVE
&& state.active === Characteristic.Active.INACTIVE) {
return cool.off
}
if (!available.cool.temperatureCodes) {
return cool.on
}
return this.decodeTemperatureHex(temperature, cool, toUpdateCharacteristic)
break
case Characteristic.TargetHeaterCoolerState.HEAT:
temperature = heatingThresholdTemperature
if (!available.heatMode) {
if (logLevel <= 4) {log(`BUG: ${name} decodeHexFromConfig invoked with unsupported target mode: heat.`)}
return "0'" // sending dummy hex data to prevent homebridge from tripping
}
if (toUpdateCharacteristic === CharacteristicName.ACTIVE
&& state.active === Characteristic.Active.INACTIVE) {
return heat.off
}
if (!available.heat.temperatureCodes) {
return heat.on // temperature codes are not supported for the heater device
}
return this.decodeTemperatureHex(temperature, heat, toUpdateCharacteristic)
break
default:
if (logLevel <= 4) {log(`BUG: decodeHexFromConfig has invalid value for targetHeaterCoolerState: ${targetHeaterCoolerState}.`)}
break
}
}
/**
* Recursively parses supplied hexData object to find the hex codes.
* @param {object} hexDataObject - object to parse in order to retrieve hex codes
* @param {array} checkCharacteristics - list of all hierarchical characteristics in the object to parse
* @param {CharacteristicName} toUpdateCharacteristic - characteristic that is being updated
* @returns {any} hexData - object, array or string values to be sent to IR device
*/
decodeHierarchichalHex(hexDataObject, checkCharacteristics, toUpdateCharacteristic) {
const { state, log, logLevel, name } = this
if (hexDataObject === undefined || hexDataObject == null) { return "hexDataObject" } // should never happen, unless bug
if (typeof hexDataObject !== 'object') { return hexDataObject }
if (Array.isArray(hexDataObject)) { return hexDataObject }
// All hierarchical characteristics have been checked so we can return
if (checkCharacteristics.length === 0) {
return hexDataObject // finished checking all characteristics
}
const keys = Object.keys(hexDataObject)
let keyFromState
const characteristic = checkCharacteristics.pop()
switch (characteristic) {
case CharacteristicName.ROTATION_SPEED:
keyFromState = 'rotationSpeed' + state.rotationSpeed
if (toUpdateCharacteristic === CharacteristicName.ROTATION_SPEED) {
if (keys.includes('fanSpeedToggle')) {
return this.decodeHierarchichalHex(hexDataObject.fanSpeedToggle, checkCharacteristics, null)
}
if (keys.includes(keyFromState)) {
return this.decodeHierarchichalHex(hexDataObject[keyFromState], checkCharacteristics, null)
}
if (logLevel <= 3) {log(`Could not find rotationSpeed${state.rotationSpeed} hex codes`)}
return "0"
}
// do not change state of fanspeed mode
if (keys.includes('fanSpeedDnd')) {
return this.decodeHierarchichalHex(hexDataObject.fanSpeedDnd, checkCharacteristics, toUpdateCharacteristic)
}
if (keys.includes(keyFromState)) {
return this.decodeHierarchichalHex(hexDataObject[keyFromState], checkCharacteristics, toUpdateCharacteristic)
}
break
case CharacteristicName.SWING_MODE:
if (toUpdateCharacteristic === CharacteristicName.SWING_MODE) {
if (keys.includes('swingToggle')) {
return this.decodeHierarchichalHex(hexDataObject.swingToggle, checkCharacteristics, null)
}
keyFromState = state.swingMode === Characteristic.SwingMode.SWING_ENABLED ? 'swingOn' : 'swingOff'
if (keys.includes(keyFromState)) {
return this.decodeHierarchichalHex(hexDataObject[keyFromState], checkCharacteristics, null)
}
if (logLevel <= 3) {log(`Could not find swingMode hex codes for swingMode ${keyFromState}`)}
return "0"
}
// do not change state of swing mode
if (keys.includes('swingDnd')) {
return this.decodeHierarchichalHex(hexDataObject.swingDnd, checkCharacteristics, toUpdateCharacteristic)
}
keyFromState = state.swingMode === Characteristic.SwingMode.SWING_ENABLED ? 'swingOn' : 'swingOff'
if (keys.includes(keyFromState)) {
return this.decodeHierarchichalHex(hexDataObject[keyFromState], checkCharacteristics, toUpdateCharacteristic)
}
break
case undefined:
// should not happen, this is a fail safe to prevent infinite recursion.
if (logLevel <= 4) {log(`BUG: ${name} decodeHierarchichalHex encountered a bug, please raise an issue`)}
return hexDataObject
}
if (logLevel <= 4) {log(`Hex codes not found for ${characteristic}`)}
// if we reach here, this characteristic is not defined for the accessory so continue searching for the next one
return this.decodeHierarchichalHex(hexDataObject, checkCharacteristics, toUpdateCharacteristic)
}
/**
* Decode hexData from temperature codes.
* Prerequisites: Temperature control is available
* @param {number} temperature - temperature in degree Celsius for hex code lookup
* @param {object} hexDataObject - Object to parse in order to find the hex codes
* @param {CharacteristicName} toUpdateCharacteristic - characteristic that is being updated
* @returns {any} hexData - object, array or string values to be sent to IR device
*/
decodeTemperatureHex(temperature, hexDataObject, toUpdateCharacteristic) {
const { config, state, log, logLevel } = this
const { temperatureCodes } = hexDataObject
const { temperatureUnits, internalConfig } = config
const { available } = internalConfig
if (temperatureUnits === "f") {
temperature = this.temperatureCtoF(temperature)
}
if (logLevel <= 2) {log(`Looking up temperature hex codes for ${temperature}`)}
const CONFIG_CHARACTERISTICS = [
//CharacteristicName.SLEEP,
CharacteristicName.SWING_MODE,
CharacteristicName.ROTATION_SPEED
]
let hexCode = "0"
const temperatureHexDataObject = temperatureCodes[`${temperature}`]
if (temperatureHexDataObject) {
hexCode = this.decodeHierarchichalHex(temperatureHexDataObject, CONFIG_CHARACTERISTICS, toUpdateCharacteristic)
if (logLevel <= 2) {log(`\tSending hex codes for temperature ${temperature}`)}
} else {
if (logLevel <= 4) {log(`\tDid not find temperature code for ${temperature}. Please update data.${this.state.targetHeaterCoolerState === 1 ?
"heat" : "cool"}.temperatureCodes in config.json`)}
}
return hexCode
}
/**
* Send IR codes to set the temperature of the accessory in its current mode of operation.
* @param {string} hexData
* @param {number} previousValue - previous temperature value
*/
async setTemperature(hexData, previousValue) {
const { name, log, logLevel, state, config } = this
const { targetHeaterCoolerState, coolingThresholdTemperature, heatingThresholdTemperature } = state
const targetTemperature = targetHeaterCoolerState === Characteristic.TargetHeaterCoolerState.COOL ? coolingThresholdTemperature : heatingThresholdTemperature;
if (logLevel <= 2) {log(`${name} setTemperature: Changing temperature from ${previousValue} to ${targetTemperature}`)}
hexData = this.decodeHexFromConfig(targetHeaterCoolerState === Characteristic.TargetHeaterCoolerState.COOL ? CharacteristicName.CoolingThresholdTemperature : CharacteristicName.HeatingThresholdTemperature)
await this.performSend(hexData)
}
/**
* Send IR codes to toggle the device on/off. By the time this function is invoked cached state is already updated
* to reflect the requested value.
* If requested value is to turn on the device then we will send hex codes based on the last saved cached state
* @param {string} hexData
* @param {int} previousValue
*/
async setActive(hexData, previousValue) {
const { state, config, data, logLevel } = this
const { resetPropertiesOnRestart, turnOnWhenOff } = config
const { available } = config.internalConfig
const { targetHeaterCoolerState } = state
const requestedValue = state.active // state is already set by main handler before this subhandler is called
hexData = this.decodeHexFromConfig(CharacteristicName.ACTIVE)
if (turnOnWhenOff === true && state.active === Characteristic.Active.ACTIVE && previousValue === Characteristic.Active.INACTIVE) {
//Add ON hex to be sent first
if (logLevel <= 2) {this.log(`\tAdding ON code first`);}
//Add pause to the ON Code
const onCode = targetHeaterCoolerState === Characteristic.TargetHeaterCoolerState.COOL ? data.cool.on : data.heat.on;
let newCode = [];
if (typeof onCode === 'string') {
newCode = [{ "data": onCode, "pause": 1 }];
} else {
onCode[onCode.length - 1].pause = 1;
newCode = onCode;
}
//Append the On code (with pause) to the state code.
if (typeof hexData === 'string') {
newCode.push({ "data": hexData });
hexData = newCode;
} else {
hexData = newCode.concat(hexData);
}
}
await this.performSend(hexData)
// Update homebridge and home app state to reflect the cached state of all the available
// characteristics. This ensures that UI for osciallte, fan speed, etc in the app are in
// sync with device settings
this.updateServiceCurrentHeaterCoolerState();
if (available.swingMode) {
this.serviceManager.getCharacteristic(Characteristic.SwingMode)
.updateValue(state.swingMode)
}
if (available.rotationSpeed) {
this.serviceManager.getCharacteristic(Characteristic.RotationSpeed)
.updateValue(state.rotationSpeed)
}
}
/**
* Send IR codes to enable/disable swing mode (oscillation)
* @param {string} hexData
* @param {int} previousValue
*/
async setSwingMode(hexData, previousValue) {
const { state, data, config, log, logLevel, name } = this
const { swingMode } = state
if (logLevel <= 2) {log(`${name} setSwingMode: Changing swing from ${previousValue} to ${Characteristic.SwingMode.SWING_ENABLED}`)}
if (data.swingOn && data.swingOff) {
hexData = swingMode === Characteristic.SwingMode.SWING_ENABLED ? data.swingOn : data.swingOff
}
else if (data.swingMode && data.swingToggle) {
hexData = data.swingToggle
}
else {
hexData = this.decodeHexFromConfig(CharacteristicName.SWING_MODE)
}
if (hexData === "0") {
if (logLevel <= 3) {log(`Swing hex codes not found, resetting state to previous value`)}
state.swingMode = previousValue
this.serviceManager.service
.getCharacteristic(Characteristic.SwingMode)
.updateValue(previousValue)
} else {
await this.performSend(hexData)
}
}
/**
* Send IR codes to change fan speed of device.
* @param {string} hexData
* @param {int} previousValue - previous rotation speed of device
*/
async setRotationSpeed(hexData, previousValue) {
const { state, config, log, logLevel, name } = this
const { rotationSpeed } = state
if (logLevel <= 2) {log(`${name} setRotationSpeed: Changing RotationSpeed from ${previousValue} to ${state.rotationSpeed}`)}
// TODO: Check other locations for fanSpeed
if (rotationSpeed === 0) {
// reset rotationSpeed back to default
state.rotationSpeed = previousValue
// set active handler (called by homebridge/home app) will take
// care of turning off the fan
return
}
hexData = this.decodeHexFromConfig(CharacteristicName.ROTATION_SPEED)
if (hexData === "0") {
if (logLevel <= 3) {log(`Fan speed hex codes not found, resetting back to previous value`)}
state.rotationSpeed = previousValue
this.serviceManager.service
.getCharacteristic(Characteristic.RotationSpeed)
.updateValue(previousValue)
} else {
await this.performSend(hexData)
}
}
/**
********************************************************
* GETTERS *
********************************************************
*/
/**
* Read current temperature from device. We don't have any way of knowing the device temperature so we will
* instead send a default value.
* @param {func} callback - callback function passed in by homebridge API to be called at the end of the method
*/
async monitorTemperature() {
const { config, host, log, logLevel, name, state } = this;
const { temperatureFilePath, defaultNowTemperature, w1DeviceID } = config;
if (defaultNowTemperature !== undefined) {return;}
//Force w1 and file devices to a minimum 1 minute refresh
if (w1DeviceID || temperatureFilePath) {config.temperatureUpdateFrequency = Math.max(config.temperatureUpdateFrequency, 60);}
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;
}
if (logLevel <= 3) {log(`${name} monitorTemperature`);}
device.on('temperature', this.onTemperature.bind(this));
device.checkTemperature();
this.updateTemperatureUI();
if (!config.isUnitTest) {setInterval(this.updateTemperatureUI.bind(this), config.temperatureUpdateFrequency * 1000)}
}
onTemperature(temperature, humidity) {
const { config, host, log, logLevel, name, state } = this;
const { minTemperature, maxTemperature, temperatureAdjustment, humidityAdjustment, noHumidity, tempSourceUnits } = 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;
if (tempSourceUnits == 'F') {temperature = (temperature - 32) * 5/9;}
state.currentTemperature = temperature;
if (logLevel <= 1) {log(`\x1b[34m[DEBUG]\x1b[0m ${name} onTemperature (${temperature})`);}
if (humidity) {
if (noHumidity) {
state.currentHumidity = null;
} else {
humidity += humidityAdjustment;
state.currentHumidity = humidity;
if (logLevel <= 1) {log(`\x1b[34m[DEBUG]\x1b[0m ${name} onHumidity (` + humidity + `)`);}
}
}
//Process Fakegato history
//Ignore readings of exactly zero - the default no value value.
if (config.noHistory !== true && this.state.currentTemperature != 0.00) {
this.lastUpdatedAt = Date.now();
if (logLevel <= 1) {log(`\x1b[34m[DEBUG]\x1b[0m ${name} Logging data to history: temp: ${this.state.currentTemperature}, humidity: ${this.state.currentHumidity}`);}
if (noHumidity) {
this.historyService.addEntry({ time: Math.round(new Date().valueOf() / 1000), temp: this.state.currentTemperature });
} else {
this.historyService.addEntry({ time: Math.round(new Date().valueOf() / 1000), temp: this.state.currentTemperature, humidity: this.state.currentHumidity });
}
}
this.processQueuedTemperatureCallbacks(temperature);
}
addTemperatureCallbackToQueue(callback) {
const { config, host, logLevel, log, name, state } = this;
const { mqttURL, temperatureFilePath, w1DeviceID, noHumidity } = config;
// Clear the previous callback
if (Object.keys(this.temperatureCallbackQueue).length > 1) {
if (state.currentTemperature) {
if (logLevel <= 1) {log(`\x1b[34m[DEBUG]\x1b[0m ${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 W1 Device
if (w1DeviceID) {
this.updateTemperatureFromW1();
return;
}
// Read temperature from mqtt
if (mqttURL) {
const temperature = this.mqttValueForIdentifier('temperature');
const humidity = noHumidity ? null : this.mqttValueForIdentifier('humidity');
this.onTemperature(temperature || 0, humidity);
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') {
if (logLevel <= 3) {log(`${name} addTemperatureCallbackToQueue (device no longer active, using existing temperature)`);}
}
this.processQueuedTemperatureCallbacks(state.currentTemperature || 0);
return;
}
device.checkTemperature();
if (logLevel < 1) {log(`\x1b[34m[DEBUG]\x1b[0m ${name} addTemperatureCallbackToQueue (requested temperature from device, waiting)`);}
}
updateTemperatureFromFile() {
const { config, host, log, logLevel, name, state } = this;
const { temperatureFilePath, noHumidity, batteryAlerts } = config;
let humidity = null;
let temperature = null;
if (logLevel <= 1) {log(`\x1b[34m[DEBUG]\x1b[0m ${name} updateTemperatureFromFile reading file: ${temperatureFilePath}`);}
fs.readFile(temperatureFilePath, 'utf8', (err, data) => {
if (err) {
if (logLevel <= 4) {log(`\x1b[31m[ERROR] \x1b[0m${name} updateTemperatureFromFile\n\n${err.message}`);}
}
if (data === undefined || data.trim().length === 0) {
if (logLevel <= 3) {log(`\x1b[33m[WARNING]\x1b[0m ${name} updateTemperatureFromFile error reading file: ${temperatureFilePath}, using previous Temperature`);}
if (!noHumidity) {humidity = (state.currentHumidity || 0);}
temperature = (state.currentTemperature || 0);
}
const lines = data.split(/\r?\n/);
if (/^[0-9]+\.*[0-9]*$/.test(lines[0])) {
temperature = parseFloat(data);
} else {
lines.forEach((line) => {
if (-1 < line.indexOf(':')) {
const value = line.split(':');
if (value[0] == 'temperature') {temperature = parseFloat(value[1]);}
if (value[0] == 'humidity' && !noHumidity) {humidity = parseFloat(value[1]);}
if (value[0] == 'battery' && batteryAlerts) {state.batteryLevel = parseFloat(value[1]);}
}
});
}
if (logLevel <= 1) {log(`\x1b[34m[DEBUG]\x1b[0m ${name} updateTemperatureFromFile (parsed temperature: ${temperature} humidity: ${humidity})`);}
this.onTemperature(temperature, humidity);
});
}
updateTemperatureFromW1() {
const { config, logLevel, host, log, name, state } = this;
const { w1DeviceID } = config;
var W1PATH = "/sys/bus/w1/devices";
var fName = W1PATH + "/" + w1DeviceID + "/w1_slave";
var temperature;
if (logLevel <= 1) {log(`\x1b[34m[DEBUG]\x1b[0m ${name} updateTemperatureFromW1 reading file: ${fName}`);}
fs.readFile(fName, 'utf8', (err, data) => {
if (err) {
if (logLevel <= 4) {log(`\x1b[31m[ERROR] \x1b[0m${name} updateTemperatureFromW1\n\n${err.message}`);}
}
if (data.includes("t=")) {
var matches = data.match(/t=([0-9]+)/);
temperature = parseInt(matches[1]) / 1000;
} else {
if (logLevel <= 4) {log(`\x1b[33m[WARNING]\x1b[0m ${name} updateTemperatureFromW1 error reading file: ${fName}, using previous Temperature`);}
temperature = (state.currentTemperature || 0);
}
if (logLevel <= 1) {log(`\x1b[34m[DEBUG]\x1b[0m ${name} updateTemperatureFromW1 (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 = {};
}
updateTemperatureUI() {
const { config, serviceManager, state } = this;
const { noHumidity } = config;
serviceManager.updateCharacteristic(Characteristic.CurrentTemperature,state.currentTemperature);
if (!noHumidity) { serviceManager.updateCharacteristic(Characteristic.CurrentRelativeHumidity,state.currentHumidity); }
}
getCurrentTemperature(callback) {
const { config, host, logLevel, log, name, state } = this;
const { defaultNowTemperature } = config;
// Some devices don't include a thermometer and so we can use `defaultNowTemperature` instead
if (defaultNowTemperature !== undefined) {
if (logLevel <= 1) {log(`\x1b[34m[DEBUG]\x1b[0m ${name} getCurrentTemperature (using defaultNowTemperature ${defaultNowTemperature} from config)`);}
return callback(null, defaultNowTemperature);
}
this.addTemperatureCallbackToQueue(callback);
}
getCurrentHumidity(callback) {
const { config, host, logLevel, log, name, state } = this;
const { defaultNowTemperature } = config;
return callback(null, state.currentHumidity);
}
getTemperatureDisplayUnits(callback) {
const { config } = this;
const temperatureDisplayUnits = (config.units.toLowerCase() === 'f') ? Characteristic.TemperatureDisplayUnits.FAHRENHEIT : Characteristic.TemperatureDisplayUnits.CELSIUS;
callback(null, temperatureDisplayUnits);
}
// MQTT
onMQTTMessage(identifier, message) {
const { state, logLevel, log, name } = this;
if (identifier !== 'unknown' && identifier !== 'temperature' && identifier !== 'humidity' && identifier !== 'battery' && identifier !== 'combined') {
if (logLevel <= 4) {log(`\x1b[31m[ERROR] \x1b[0m${name} onMQTTMessage (mqtt message received with unexpected identifier: ${identifier}, ${message.toString()})`);}
return;
}
super.onMQTTMessage(identifier, message);
let temperatureValue, humidityValue, batteryValue;
let objectFound = false;
let value = this.mqttValuesTemp[identifier];
if (logLevel <= 1) {log(`\x1b[34m[DEBUG]\x1b[0m ${name} onMQTTMessage (raw value: ${value})`);}
try {
//Attempt to parse JSON - if result is JSON
const temperatureJSON = JSON.parse(value);
if (typeof temperatureJSON === 'object') {
objectFound = true;
let values = [];
if (identifier !== 'temperature' && identifier !== 'battery') {
//Try to locate other Humidity fields
if (values.length === 0) {values = findKey(temperatureJSON, 'Hum');}
if (values.length === 0) {values = findKey(temperatureJSON, 'hum');}
if (values.length === 0) {values = findKey(temperatureJSON, 'Humidity');}
if (values.length === 0) {values = findKey(temperatureJSON, 'humidity');}
if (values.length === 0) {values = findKey(temperatureJSON, 'RelativeHumidity');}
if (values.length === 0) {values = findKey(temperatureJSON, 'relativehumidity');}
if (values.length > 0) {
humidityValue = values;
values = [];
}
}
if (identifier !== 'temperature' && identifier !== 'humidity') {
//Try to locate other Battery fields
if (values.length === 0) {values = findKey(temperatureJSON, 'Batt');}
if (values.length === 0) {values = findKey(temperatureJSON, 'batt');}
if (values.length === 0) {values = findKey(temperatureJSON, 'Battery');}
if (values.length === 0) {values = findKey(temperatureJSON, 'battery');}
if (values.length > 0) {
batteryValue = values;
values = [];
}
}
if (identifier !== 'battery' && identifier !== 'humidity') {
//Try to locate other Temperature fields
if (values.length === 0) {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) {values = findKey(temperatureJSON, 'local_temperature');}
if (values.length > 0) {
temperatureValue = values;
}
}
if (values.length > 0) {
value = values[0];
} else {
value = undefined;
}
}
} catch (err) { } //Result couldn't be parsed as JSON
if (objectFound) {
if (temperatureValue !== undefined && temperatureValue.length > 0) {
this.mqttValues.temperature = parseFloat(temperatureValue[0]);
}
if (batteryValue !== undefined && batteryValue.length > 0) {
state.batteryLevel = parseFloat(batteryValue[0]);
this.mqttValues.battery = parseFloat(batteryValue[0]);
}
if (humidityValue !== undefined && humidityValue.length > 0) {
this.mqttValues.humidity = parseFloat(humidityValue[0]);
}
} else {
if (value === undefined || (typeof value === 'string' && value.trim().length === 0)) {
if (logLevel <= 3) {log(`\x1b[31m[ERROR] \x1b[0m${name} onMQTTMessage (mqtt value not found)`);}
return;
}
if (logLevel <= 1) {log(`\x1b[34m[DEBUG]\x1b[0m ${name} onMQTTMessage (parsed value: ${value})`);}
value = parseFloat(value);
if (identifier == 'battery') {
state.batteryLevel = value;
return;
}
this.mqttValues[identifier] = value;
}
this.updateTemperatureUI();
}
/**
********************************************************
* UTILITIES *
********************************************************
*/
/**
* Converts supplied temperature to value from degree Celcius to degree Fahrenheit, truncating to the
* default step size of 1. Returns temperature in degree Fahrenheit.
* @param {number} temperature - Temperature value in degress Celcius
*/
temperatureCtoF(temperature) {
const temp = (temperature * 9 / 5) + 32
const whole = Math.round(temp)
return Math.trunc(whole)
}
/**
* Converts supplied temperature to value from degree Fahrenheit to degree Celsius, truncating to the
* default step size of 0.1. Returns temperature in degree Celsius.
* @param {number} temperature - Temperature value in degress Fahrenheit
*/
temperatureFtoC(temperature) {
const temp = (temperature - 32) * 5 / 9
const abs = Math.abs(temp)
const whole = Math.trunc(abs)
var fraction = (abs - whole) * 10
fraction = Math.trunc(fraction) / 10
return temp < 0 ? -(fraction + whole) : (fraction + whole)
}
/**
********************************************************
* CONFIGURATION *
********************************************************
*/
/**
* Validates if the keys for optional characteristics are defined in config.json and
* accordingly updates supplied config object
* @param {object} dataObject - hex code object to validate keys
* @param {object} configObject - object pointing to available.<mode>
*/
validateOptionalCharacteristics(dataObject, configObject) {
const isValidRotationSpeedKey = (stringValue) => stringValue.startsWith('rotationSpeed') // loose validation, can further validate number endings
const isValidSwingModeKey = (stringValue) => stringValue.startsWith('swing')
const isValidSleepKey = (stringValue) => stringValue.startsWith('sleep')
const isValidTemperature = (stringValue) => isFinite(Number(stringValue)) // Arrow function to check if supplied string is a int or float parseable number
const dataObjectKeys = Object.keys(dataObject)
if (this.config.logLevel <= 1) {this.log(`Checking keys ${dataObjectKeys}`)}
if (!configObject.temperatureCodes && dataObjectKeys.every(isValidTemperature)) {
configObject.temperatureCodes = true
}
else if (!configObject.rotationSpeed && dataObjectKeys.every(isValidRotationSpeedKey)) {
configObject.rotationSpeed = true
}
else if (!configObject.swingMode && dataObjectKeys.every(isValidSwingModeKey)) {
configObject.swingMode = true
}
else if (!configObject.sleep && dataObjectKeys.every(isValidSleepKey)) {
configObject.sleep = true
}
for (const [key, value] of Object.entries(dataObject)) {
if (this.config.logLevel <= 1) {this.log(`Going into key -> ${key}`)}
if (typeof value === 'object' && !Array.isArray(value)) {
this.validateOptionalCharacteristics(value, configObject)
}
}
}
/**
* Configure optional characteristics like rotation speed, swing mode, temperature control
* and sleep mode
*/
configureOptionalCharacteristics() {
const { name, config, data, logLevel } = this
const { internalConfig } = config
const { available } = internalConfig || {}
const { heat, cool } = data
assert(available.coolMode || available.heatMode, `ERROR: ${name} configureOptionalCharacteristics invoked without configuring heat and cool modes`)
available.cool = new Object()
available.heat = new Object()
available.cool.temperatureCodes = false
available.cool.rotationSpeed = false
available.cool.swingMode = false
available.cool.sleep = false
available.heat.temperatureCodes = false
available.heat.rotationSpeed = false
available.heat.swingMode = false
available.heat.sleep = false
if (available.coolMode && cool.temperatureCodes && typeof cool.temperatureCodes === 'object'
&& !Array.isArray(cool.temperatureCodes)) {
this.validateOptionalCharacteristics(cool.temperatureCodes, available.cool)
}
if (available.heatMode && heat.temperatureCodes && typeof heat.temperatureCodes === 'object'
&& !Array.isArray(heat.temperatureCodes)) {
this.validateOptionalCharacteristics(heat.temperatureCodes, available.heat)
}
if (logLevel <= 2) {this.log(`INFO ${name} configured with optional characteristics:
Temperature control: ${available.cool.temperatureCodes} ${available.heat.temperatureCodes}
Rotation speed: ${available.cool.rotationSpeed} ${available.heat.rotationSpeed}
Swing mode: ${available.cool.swingMode} ${available.heat.swingMode}
Sleep: ${available.cool.sleep} ${available.heat.sleep}`)}
}
/**
* Validates and initializes following values in this.config:
* coolingThresholdTemperature, heatingThresholdTemperature, defaultNowTemperature,
* minTemperature, maxTemperature,temperatureUnits.
* All temperatures are converted to degree Celsius for internal usage in the plugin.
*/
configureTemperatures() {
const { config } = this
const { internalConfig } = config
const { available } = internalConfig
if (!["C", "c", "F", "f"].includes(config.temperatureUnits)) { config.temperatureUnits = "c" }
config.temperatureUnits = config.temperatureUnits.toLowerCase()
const { coolingThresholdTemperature, heatingThresholdTemperature, temperatureUnits, defaultNowTemperature } = config
if (coolingThresholdTemperature === undefined) {
config.coolingThresholdTemperature = 30
} else if (temperatureUnits === "f") {
config.coolingThresholdTemperature = this.temperatureFtoC(coolingThresholdTemperature)
}
if (heatingThresholdTemperature === undefined) {
config.heatingThresholdTemperature = 18
} else if (temperatureUnits === "f") {
config.heatingThresholdTemperature = this.temperatureFtoC(heatingThresholdTemperature)
}
//if (defaultNowTemperature === undefined) {
// config.defaultNowTemperature = 24
//} else if (temperatureUnits === "f") {
// config.defaultNowTemperature = this.temperatureFtoC(defaultNowTemperature)
//}
// convert min and max temperatures to degree Celsius if defined as fahrenheit
if (temperatureUnits === "f") {
if (config.minTemperature) { config.minTemperature = this.temperatureFtoC(config.minTemperature) }
if (config.maxTemperature) { config.maxTemperature = this.temperatureFtoC(config.maxTemperature) }
}
const { cool, heat } = config.data
// Apple doesn't complain if we set the values above or below documented max,min values respectively
// so if your device supports a higher max or a lower min we set it here.
if (available.heatMode) {
heat.minTemperature = Math.min(heat.minTemperature, HEATING_THRESHOLD_TEMPERATURE.minValue)
heat.maxTemperature = Math.max(heat.maxTemperature, HEATING_THRESHOLD_TEMPERATURE.maxValue)
}
if (available.coolMode) {
cool.minTemperature = Math.min(cool.minTemperature, COOLING_THRESHOLD_TEMPERATURE.minValue)
cool.maxTemperature = Math.max(cool.maxTemperature, COOLING_THRESHOLD_TEMPERATURE.maxValue)
}
}
/**
* Configures available heat and cool operations in the this.config.internalConfig
* based on parsing of config.json
* Prerequisites: this.config and this.data are defined, this.config.internalConfig.available
* is allocated.
*/
configureHeatCoolModes() {
const { config } = this
const { heat, cool } = config.data || {}
const { internalConfig } = config
assert(internalConfig !== undefined && typeof internalConfig === 'object', `ERROR: ${this.name} internalConfig is not initialized. Please raise an issue`)
const { available } = internalConfig
assert(available !== undefined && typeof available === 'object', `ERROR: ${this.name} internalConfig.available is not initialized. Please raise an issue`)
if (typeof heat === 'object' && heat.on !== undefined && heat.off !== undefined) {
internalConfig.available.heatMode = true
} else {
internalConfig.available.heatMode = false
}
if (typeof cool === 'object' && cool.on !== undefined && cool.off !== undefined) {
internalConfig.available.coolMode = true
} else {
internalConfig.available.coolMode = false
}
if (!available.coolMode && !available.heatMode)
{throw new Error(`At least one of data.cool or data.heat object is required in config.json. Please update your config.json file`)}
// Default power on mode for first run when both heat & cool modes are available.
if (config.defaultMode === undefined) {
config.defaultMode = available.coolMode ? "cool" : "heat"
}
}
/**
* Setup default config values which are used to initializing the service manager
*/
configDefaultsHelper() {
const { config, name, log, logLevel } = this
// this is a safeguard and should never happen unless the base constructor invokes
// setupServiceManager before validating config file
if (config === undefined || typeof config !== 'object')
{throw new Error('config.json is not setup properly, please check documentation')}
const { data } = config
if (data === undefined || typeof data !== 'object')
{throw new Error(`data object is required in config.json for initializing accessory`)}
config.defaultRotationSpeed = config.defaultRotationSpeed || 100
config.internalConfig = new Object()
config.internalConfig.available = new Object()
const { available } = config.internalConfig
this.configureHeatCoolModes()
this.configureTemperatures()
this.configureOptionalCharacteristics()
if (logLevel <= 2) {log(`${name} initialized with modes Cool: ${available.coolMode ? '\u2705' : '\u274c'}, Heat: ${available.heatMode ? '\u2705' : '\u274c'},\
config temperatures as: ${this.config.temperatureUnits === "f" ? '\u00b0F' : '\u00b0C'}\
Using following default configuration:
Power on mode: ${config.defaultMode}
Now Temperature: ${config.defaultNowTemperature} \u00b0C
Cooling Temperature: ${config.coolingThresholdTemperature} \u00b0C
Heating Temperature: ${config.heatingThresholdTemperature} \u00b0C`)}
}
// Service Manager Setup
setupServiceManager() {
this.configDefaultsHelper()
const { config, name, data, serviceManagerType } = this;
const { minTemperature, maxTemperature } = config
const { internalConfig } = config
const { available } = internalConfig
this.serviceManager = new ServiceManagerTypes[serviceManagerType](name, Service.HeaterCooler, this.log);
// Setting up all Required Characteristic handlers
this.serviceManager.addToggleCharacteristic({
name: 'active',
type: Characteristic.Active,
getMethod: this.getCharacteristicValue,
setMethod: this.setCharacteristicValue,
bind: this,
props: {
setValuePromise: this.setActive.bind(this)
}
});
this.serviceManager.addToggleCharacteristic({
name: 'currentHeaterCoolerState',
type: Characteristic.CurrentHeaterCoolerState,
getMethod: this.getCharacteristicValue,
setMethod: this.setCharacteristicValue,
bind: this,
props: {
}
});
this.serviceManager.addToggleCharacteristic({
name: 'targetHeaterCoolerState',
type: Characteristic.TargetHeaterCoolerState,
getMethod: this.getCharacteristicValue,
setMethod: this.setCharacteristicValue,
bind: this,
props: {
setValuePromise: this.setTargetHeaterCoolerState.bind(this),
}
});
this.serviceManager.addGetCharacteristic({
name: 'currentTemperature',
type: Characteristic.CurrentTemperature,
method: this.getCurrentTemperature,
bind: this
});
if (!config.noHumidity) {
this.serviceManager.addGetCharacteristic({
name: 'currentHumidity',
type: Characteristic.CurrentRelativeHumidity,