homebridge-broadlink-rm-pro
Version:
Broadlink RM plugin (including the mini and pro) for homebridge with AC Pro and TV features
400 lines (298 loc) • 12.8 kB
JavaScript
const persistentState = require('./helpers/persistentState');
const mqtt = require('mqtt');
const addSaveProxy = (name, target, saveFunc) => {
const handler = {
set(target, key, value) {
target[key] = value;
saveFunc(target);
return true
}
}
return new Proxy(target, handler);
}
class HomebridgeAccessory {
constructor(log, config = {}, serviceManagerType = 'ServiceManager') {
this.serviceManagerType = serviceManagerType;
let { disableLogs, host, name, data, persistState, resendDataAfterReload, resendDataAfterReloadDelay } = config;
this.log = (!disableLogs && log) ? log : () => { };
if (this.logLevel === undefined) {this.logLevel = 2;} //Default to info
this.config = config;
this.host = host;
this.name = name;
this.data = data;
this.state = {}
this.checkConfig(config)
this.setupServiceManager()
this.loadState()
this.setDefaults();
this.subscribeToMQTT();
}
setDefaults() {
if (this.config.allowResend === undefined) {
if (this.config.preventResendHex === undefined) {
this.config.allowResend = true;
} else {
this.config.allowResend = !this.config.preventResendHex;
}
}
}
restoreStateOrder() { }
correctReloadedState() { }
checkConfig(config) {
const { name, log, logLevel } = this;
if (typeof config !== 'object') {return;}
Object.keys(config).forEach((key) => {
const value = config[key];
if (value === 'true' || value === 'false') {
log(`\x1b[31m[CONFIG ERROR]\x1b[0m ${name}Boolean values should look like this: \x1b[32m"${key}": ${value}\x1b[0m not this \x1b[31m"${key}": "${value}"\x1b[0m`);
process.exit(0);
} else if (Array.isArray(value)) {
value.forEach((item) => {
this.checkConfig(item);
})
} else if (typeof value === 'object') {
this.checkConfig(value);
} else if (value === '0' || (typeof value === 'string' && parseInt(value) !== 0 && !isNaN(parseInt(value)))) {
if (typeof value === 'string' && value.split('.').length - 1 > 1) {return;}
if (typeof value === 'string' && !value.match(/^\d\.{0,1}\d*$/)) {return;}
log(`\x1b[31m[CONFIG ERROR]\x1b[0m ${name}Numeric values should look like this: \x1b[32m"${key}": ${value}\x1b[0m not this \x1b[31m"${key}": "${value}"\x1b[0m`);
process.exit(0);
}
})
}
identify(callback) {
const { name, log, logLevel } = this
if (logLevel <= 1) {log(`Identify requested for ${name}`);}
callback();
}
performSetValueAction({ host, data, log, name }) {
throw new Error('The "performSetValueAction" method must be overridden.');
}
async setCharacteristicValue(props, value, callback) {
const { config, host, log, name, logLevel } = this;
try {
const { delay, resendDataAfterReload, allowResend } = config;
const { service, propertyName, onData, offData, setValuePromise, ignorePreviousValue } = props;
const capitalizedPropertyName = propertyName.charAt(0).toUpperCase() + propertyName.slice(1);
if (delay) {
if (this.logLevel <= 3) {log(`${name} set${capitalizedPropertyName}: ${value} (delaying by ${delay}s)`);}
await delayForDuration(delay);
}
if (this.logLevel <= 2) {log(`${name} set${capitalizedPropertyName}: ${value}`);}
if (this.isReloadingState && !resendDataAfterReload) {
this.state[propertyName] = value;
if (this.logLevel <= 3) {log(`${name} set${capitalizedPropertyName}: already ${value} (no data sent - A)`);}
callback(null);
return;
}
if (!ignorePreviousValue && this.state[propertyName] == value && !this.isReloadingState) {
if (!allowResend) {
if (this.logLevel <= 3) {log(`${name} set${capitalizedPropertyName}: already ${value} (no data sent - B)`);}
callback(null);
return;
}
}
let previousValue = this.state[propertyName];
if (this.isReloadingState && resendDataAfterReload) {
previousValue = undefined
}
this.state[propertyName] = value;
// Set toggle data if this is a toggle
const data = value ? onData : offData;
if (setValuePromise) {
setValuePromise(data, previousValue);
} else if (data) {
this.performSetValueAction({ host, data, log, name });
}
callback(null);
} catch (err) {
if (this.logLevel <= 4) {log('setCharacteristicValue error:', err.message)}
callback(err)
}
}
async getCharacteristicValue(props, callback) {
const { propertyName } = props;
const { log, name, logLevel } = this;
let value;
const capitalizedPropertyName = propertyName.charAt(0).toUpperCase() + propertyName.slice(1);
if (this.state[propertyName] === undefined) {
let thisCharacteristic = this.serviceManager.getCharacteristicTypeForName(propertyName);
if (this.serviceManager.getCharacteristic(thisCharacteristic).props.format != 'bool' && this.serviceManager.getCharacteristic(thisCharacteristic).props.minValue) {
value = this.serviceManager.getCharacteristic(thisCharacteristic).props.minValue;
} else {
value = 0;
}
} else {
value = this.state[propertyName];
}
if (this.logLevel <= 1) {log(`${name} get${capitalizedPropertyName}: ${value}`);}
callback(null, value);
}
loadState() {
const { config, log, logLevel, name, serviceManager } = this;
let { host, resendDataAfterReload, resendDataAfterReloadDelay, persistState } = config;
// Set defaults
if (persistState === undefined) {persistState = true;}
if (!resendDataAfterReloadDelay) {resendDataAfterReloadDelay = 2}
if (!persistState) {return;}
// Load state from file
const restoreStateOrder = this.restoreStateOrder();
const state = persistentState.load({ host, name }) || {};
// Allow each accessory to correct the state if necessary
this.correctReloadedState(state);
// Proxy so that whenever this.state is changed, it will persist to disk
this.state = addSaveProxy(name, state, (state) => {
persistentState.save({ host, name, state });
});
// Refresh the UI and resend data based on existing state
Object.keys(serviceManager.characteristics).forEach((name) => {
if (this.state[name] === undefined) {return;}
const characteristcType = serviceManager.characteristics[name];
// Refresh the UI for any state that's been set once the init has completed
// Use timeout as we want to make sure this doesn't happen until after all child contructor code has run
setTimeout(() => {
if (persistState) {serviceManager.refreshCharacteristicUI(characteristcType);}
}, 200);
// Re-set the value in order to resend
if (resendDataAfterReload) {
// Delay to allow Broadlink to be discovered
setTimeout(() => {
const value = this.state[name];
serviceManager.setCharacteristic(characteristcType, value);
}, (resendDataAfterReloadDelay * 1000));
}
})
if (resendDataAfterReload) {
this.isReloadingState = true;
setTimeout(() => {
this.isReloadingState = false;
if (this.logLevel <= 2) {log(`${name} Accessory Ready`);}
}, (resendDataAfterReloadDelay * 1000) + 300);
} else {
if (this.logLevel <= 2) {log(`${name} Accessory Ready`);}
}
}
getInformationServices() {
const informationService = new Service.AccessoryInformation();
informationService
.setCharacteristic(Characteristic.Manufacturer, this.manufacturer || 'Homebridge Easy Platform')
.setCharacteristic(Characteristic.Model, this.model || 'Unknown')
.setCharacteristic(Characteristic.SerialNumber, this.serialNumber || 'Unknown');
return [informationService];
}
getServices() {
const services = this.getInformationServices();
services.push(this.serviceManager.service);
if (this.historyService && this.config.noHistory !== true) {
//Note that noHistory is not working as intended. Need to pull from platform config
services.push(this.historyService);
}
return services;
}
// MQTT Support
subscribeToMQTT() {
const { config, log, logLevel, name } = this;
let { mqttTopic, mqttURL, mqttUsername, mqttPassword } = config;
if (!mqttTopic || !mqttURL) {return;}
this.mqttValues = {};
this.mqttValuesTemp = {};
// Perform some validation of the mqttTopic option in the config.
if (typeof mqttTopic !== 'string' && !Array.isArray(mqttTopic)) {
if (this.logLevel <= 4) {log(`\x1b[31m[CONFIG ERROR]\x1b[0m ${name} \x1b[33mmqttTopic\x1b[0m value is incorrect. Please check out the documentation for more details.`)}
return;
}
if (Array.isArray(mqttTopic)) {
const erroneousTopics = mqttTopic.filter((mqttTopicObj) => {
if (typeof mqttTopic !== 'object') {return true;}
const { identifier, topic } = mqttTopicObj;
if (!identifier || !topic) {return true;}
if (typeof identifier !== 'string') {return true;}
if (typeof topic !== 'string') {return true;}
});
if (erroneousTopics.length > 0) {
if (this.logLevel <= 4) {log(`\x1b[31m[CONFIG ERROR]\x1b[0m ${name} \x1b[33mmqttTopic\x1b[0m value is incorrect. Please check out the documentation for more details.`)}
return;
}
}
// mqqtTopic may be an array or an array of objects. Add to a new array if string.
if (typeof mqttTopic === 'string') {
const mqttTopicObj = {
identifier: 'unknown',
topic: mqttTopic
}
mqttTopic = [mqttTopicObj]
}
// Create an easily referenced instance variable
const mqttTopicIdentifiersByTopic = {};
mqttTopic.forEach(({ identifier, topic }) => {
mqttTopicIdentifiersByTopic[topic] = identifier;
})
// Connect to mqtt
const mqttClientID = 'mqttjs_' + Math.random().toString(16).substr(2, 8);
const options = {
keepalive: 10,
clientId: this.client_Id,
protocolId: 'MQTT',
protocolVersion: 4,
clean: true,
reconnectPeriod: 1000,
connectTimeout: 30 * 1000,
serialnumber: mqttClientID,
username: mqttUsername,
password: mqttPassword,
will: {
topic: 'WillMsg',
payload: 'Connection Closed abnormally..!',
qos: 0,
retain: false
},
rejectUnauthorized: false
};
const mqttClient = mqtt.connect(mqttURL, options);
this.mqttClient = mqttClient;
// Subscribe to topics
this.isMQTTConnecting = true;
// Timeout isMQTTConnecting - it's used to prevent error messages about not being connected.
setTimeout(() => {
this.isMQTTConnecting = false;
}, 2000)
mqttClient.on('connect', () => {
this.isMQTTConnecting = false;
if (this.logLevel <= 2) {log(`\x1b[35m[INFO]\x1b[0m ${name} MQTT client connected.`)}
mqttTopic.forEach(({ topic }) => {
mqttClient.subscribe(topic)
})
})
mqttClient.on('error', () => {
this.isMQTTConnecting = false;
})
mqttClient.on('message', (topic, message) => {
const identifier = mqttTopicIdentifiersByTopic[topic];
this.onMQTTMessage(identifier, message);
})
}
onMQTTMessage(identifier, message) {
this.mqttValuesTemp[identifier] = message.toString();
}
mqttValueForIdentifier(identifier) {
const { log, logLevel, name } = this;
let value = this.mqttValues[identifier];
// No identifier may have been set in the user's config so let's try "unknown" too
if (value === undefined) {value = this.mqttValues.unknown;}
if (!this.mqttClient.connected) {
if (!this.isMQTTConnecting && logLevel <= 4) {log(`\x1b[31m[ERROR]\x1b[0m ${name} MQTT client is not connected. Value could not be found for topic with identifier "${identifier}".`);}
return;
}
if (value === undefined) {
if (this.logLevel <= 4) {log(`\x1b[31m[ERROR]\x1b[0m ${name} No MQTT value could be found for topic with identifier "${identifier}".`);}
return;
}
return value;
}
}
module.exports = HomebridgeAccessory;
const delayForDuration = (duration) => {
return new Promise((resolve) => {
setTimeout(resolve, duration * 1000)
})
}