homebridge-broadlink-rm-enhanced
Version:
Broadlink RM plugin (including the mini and pro) for homebridge: https://github.com/nfarina/homebridge
380 lines (276 loc) • 11.2 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 : () => {};
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 () { }
restoreStateOrder () { }
correctReloadedState () { }
checkConfig (config) {
const { name, log } = 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 } = this
this.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, debug } = 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) {
log(`${name} set${capitalizedPropertyName}: ${value} (delaying by ${delay}s)`);
await delayForDuration(delay);
}
log(`${name} set${capitalizedPropertyName}: ${value}`);
if (this.isReloadingState && !resendDataAfterReload) {
this.state[propertyName] = value;
log(`${name} set${capitalizedPropertyName}: already ${value} (no data sent - A)`);
callback(null, value);
return;
}
if (!ignorePreviousValue && this.state[propertyName] == value && !this.isReloadingState) {
if (!allowResend) {
log(`${name} set${capitalizedPropertyName}: already ${value} (no data sent - B)`);
callback(null, value);
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, this.state[propertyName]);
} catch (err) {
log('setCharacteristicValue error:', err.message)
if (debug) log(`\x1b[33m[DEBUG]\x1b[0m ${name} setCharacteristicValue error`, err)
callback(err)
}
}
async getCharacteristicValue (props, callback) {
const { propertyName } = props;
const { log, name } = this;
const capitalizedPropertyName = propertyName.charAt(0).toUpperCase() + propertyName.slice(1);
let value = this.state[propertyName];
log(`${name} get${capitalizedPropertyName}: ${value}`);
callback(null, value);
}
loadState () {
const { config, log, 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;
log(`${name} Accessory Ready`);
}, (resendDataAfterReloadDelay * 1000) + 300);
} else {
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);
return services;
}
// MQTT Support
subscribeToMQTT () {
const { config, log, 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)) {
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 !== 'obj') 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) {
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;
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, 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) 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) {
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)
})
}