Refactor!

This commit is contained in:
Koenkk
2018-11-16 20:23:11 +01:00
parent b0d3c2f964
commit dd2147109a
21 changed files with 813 additions and 625 deletions
+170 -420
View File
@@ -3,474 +3,147 @@ const Zigbee = require('./zigbee');
const State = require('./state');
const logger = require('./util/logger');
const settings = require('./util/settings');
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
const objectAssignDeep = require('object-assign-deep');
// Extensions
const ExtensionNetworkMap = require('./extension/networkMap');
const ExtensionSoftReset = require('./extension/softReset');
const ExtensionRouterPollXiaomi = require('./extension/routerPollXiaomi');
const ExtensionDevicePublish = require('./extension/devicePublish');
const zigbeeShepherdConverters = require('zigbee-shepherd-converters');
const homeassistant = require('./homeassistant');
const objectAssignDeep = require('object-assign-deep');
const mqttConfigRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/\\w+`, 'g');
const allowedLogLevels = ['error', 'warn', 'info', 'debug'];
/**
* Home Assistant requires ALL attributes to be present in ALL MQTT messages send by the device.
* https://community.home-assistant.io/t/missing-value-with-mqtt-only-last-data-set-is-shown/47070/9
*
* Therefore zigbee2mqtt BY DEFAULT caches all values and resend it with every message.
* advanced.cache_state in configuration.yaml allows to configure this.
* https://github.com/Koenkk/zigbee2mqtt/wiki/Configuration
*/
const cacheState = settings.get().advanced && settings.get().advanced.cache_state === false ? false : true;
if (settings.get().homeassistant && !cacheState) {
logger.warn('In order for Home Assistant integration to work properly set `cache_state: true');
}
const ExtensionHomeAssistant = require('./extension/homeassistant');
const ExtensionDeviceConfigure = require('./extension/deviceConfigure');
const ExtensionDeviceReceive = require('./extension/deviceReceive');
const ExtensionMarkOnlineXiaomi = require('./extension/markOnlineXiaomi');
const ExtensionBridgeConfig = require('./extension/bridgeConfig');
class Controller {
constructor() {
this.handleZigbeeMessage = this.handleZigbeeMessage.bind(this);
this.handleMQTTMessage = this.handleMQTTMessage.bind(this);
this.zigbee = new Zigbee(this.handleZigbeeMessage);
this.zigbee = new Zigbee();
this.mqtt = new MQTT();
this.state = new State();
this.configured = [];
this.extensions = [];
}
start() {
this.startupLogVersion(() => {
this.zigbee.start((error) => {
if (error) {
logger.error('Failed to start', error);
} else {
// Log zigbee clients on startup and configure.
const devices = this.zigbee.getAllClients();
logger.info(`Currently ${devices.length} devices are joined:`);
devices.forEach((device) => {
logger.info(this.getDeviceStartupLogMessage(device));
this.configureDevice(device);
});
// Enable zigbee join.
if (settings.get().permit_join) {
logger.warn('`permit_join` set to `true` in configuration.yaml.');
logger.warn('Allowing new devices to join.');
logger.warn('Set `permit_join` to `false` once you joined all devices.');
}
this.zigbee.permitJoin(settings.get().permit_join);
// Connect to MQTT broker
const subscriptions = [
`${settings.get().mqtt.base_topic}/bridge/config/+`,
];
if (settings.get().homeassistant) {
subscriptions.push('hass/status');
}
this.mqtt.connect(this.handleMQTTMessage, subscriptions, () => this.handleMQTTConnected());
}
});
});
}
handleMQTTConnected() {
// Home Assistant MQTT discovery on MQTT connected.
if (settings.get().homeassistant) {
// MQTT discovery of all paired devices on startup.
this.zigbee.getAllClients().forEach((device) => {
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
if (mappedModel) {
homeassistant.discover(device.ieeeAddr, mappedModel.model, this.mqtt, true);
}
});
}
// Bind methods
this.onMQTTConnected = this.onMQTTConnected.bind(this);
this.onZigbeeMessage = this.onZigbeeMessage.bind(this);
this.onMQTTMessage = this.onMQTTMessage.bind(this);
// Initialize extensions.
this.extensions = [
new ExtensionDevicePublish(this.zigbee, this.mqtt, this.state, this.mqttPublishDeviceState),
new ExtensionNetworkMap(this.zigbee, this.mqtt, this.state, this.mqttPublishDeviceState),
new ExtensionSoftReset(this.zigbee, this.mqtt, this.state, this.mqttPublishDeviceState),
new ExtensionRouterPollXiaomi(this.zigbee, this.mqtt, this.state, this.mqttPublishDeviceState),
new ExtensionDeviceReceive(this.zigbee, this.mqtt, this.state, this.publishDeviceState),
new ExtensionDeviceConfigure(this.zigbee, this.mqtt, this.state, this.publishDeviceState),
new ExtensionDevicePublish(this.zigbee, this.mqtt, this.state, this.publishDeviceState),
new ExtensionNetworkMap(this.zigbee, this.mqtt, this.state, this.publishDeviceState),
new ExtensionRouterPollXiaomi(this.zigbee, this.mqtt, this.state, this.publishDeviceState),
new ExtensionMarkOnlineXiaomi(this.zigbee, this.mqtt, this.state, this.publishDeviceState),
new ExtensionBridgeConfig(this.zigbee, this.mqtt, this.state, this.publishDeviceState),
];
if (settings.get().homeassistant) {
this.extensions.push(new ExtensionHomeAssistant(
this.zigbee, this.mqtt, this.state, this.publishDeviceState
));
}
if (settings.get().advanced.soft_reset_timeout !== 0) {
this.extensions.push(new ExtensionSoftReset(
this.zigbee, this.mqtt, this.state, this.publishDeviceState
));
}
}
onMQTTConnected() {
// Resend all cached states.
this.sendAllCachedStates();
// Call extensions
this.extensions.filter((e) => e.onMQTTConnected).forEach((e) => e.onMQTTConnected());
}
sendAllCachedStates() {
this.zigbee.getAllClients().forEach((device) => {
if (this.state.exists(device.ieeeAddr)) {
this.mqttPublishDeviceState(device, this.state.get(device.ieeeAddr), false);
}
onZigbeeStarted() {
// Log zigbee clients on startup and configure.
const devices = this.zigbee.getAllClients();
logger.info(`Currently ${devices.length} devices are joined:`);
devices.forEach((device) => {
logger.info(this.getDeviceStartupLogMessage(device));
});
}
stop(callback) {
this.extensions.filter((e) => e.stop).forEach((e) => e.stop());
this.state.save();
this.mqtt.disconnect();
this.zigbee.stop(callback);
}
configureDevice(device) {
let friendlyName = 'unknown';
const ieeeAddr = device.ieeeAddr;
if (settings.getDevice(ieeeAddr)) {
friendlyName = settings.getDevice(ieeeAddr).friendly_name;
}
if (ieeeAddr && device.modelId && !this.configured.includes(ieeeAddr)) {
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
// Call configure function of device.
if (mappedModel && mappedModel.configure) {
mappedModel.configure(ieeeAddr, this.zigbee.shepherd, this.zigbee.getCoordinator(), (ok, msg) => {
if (ok) {
logger.info(`Succesfully configured ${friendlyName} ${ieeeAddr}`);
} else {
logger.error(`Failed to configure ${friendlyName} ${ieeeAddr}`);
}
});
}
// Setup an OnAfIncomingMsg handler if needed.
if (mappedModel && mappedModel.onAfIncomingMsg) {
mappedModel.onAfIncomingMsg.forEach((ep) => this.zigbee.registerOnAfIncomingMsg(ieeeAddr, ep));
}
this.configured.push(ieeeAddr);
}
}
getDeviceStartupLogMessage(device) {
let friendlyName = 'unknown';
let type = 'unknown';
let friendlyDevice = {model: 'unkown', description: 'unknown'};
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
if (mappedModel) {
friendlyDevice = mappedModel;
// Enable zigbee join.
if (settings.get().permit_join) {
logger.warn('`permit_join` set to `true` in configuration.yaml.');
logger.warn('Allowing new devices to join.');
logger.warn('Set `permit_join` to `false` once you joined all devices.');
}
if (settings.getDevice(device.ieeeAddr)) {
friendlyName = settings.getDevice(device.ieeeAddr).friendly_name;
}
this.zigbee.permitJoin(settings.get().permit_join);
if (device.type) {
type = device.type;
}
// Connect to MQTT broker
this.mqtt.connect(this.onMQTTMessage, this.onMQTTConnected);
return `${friendlyName} (${device.ieeeAddr}): ${friendlyDevice.model} - ` +
`${friendlyDevice.vendor} ${friendlyDevice.description} (${type})`;
// Call extensions
this.extensions.filter((e) => e.onZigbeeStarted).forEach((e) => e.onZigbeeStarted());
}
getDeviceInfoForMqtt(device) {
const {type, ieeeAddr, nwkAddr, manufId, manufName, powerSource, modelId, status} = device;
const deviceSettings = settings.getDevice(device.ieeeAddr);
onZigbeeMessage(message) {
// Variables
let device = null;
let mappedDevice = null;
return {
ieeeAddr,
friendlyName: deviceSettings.friendly_name || '',
type,
nwkAddr,
manufId,
manufName,
powerSource,
modelId,
status,
};
}
handleZigbeeMessage(message) {
// Call extensions.
this.extensions.filter((e) => e.handleZigbeeMessage).forEach((e) => e.handleZigbeeMessage(message));
// Log the message.
let logMessage = `Received zigbee message of type '${message.type}' ` +
`with data '${JSON.stringify(message.data)}'`;
// Check if message has a device
if (message.endpoints && message.endpoints[0].device) {
const device = message.endpoints[0].device;
logMessage += ` of device '${device.modelId}' (${device.ieeeAddr})`;
}
logger.debug(logMessage);
if (message.type == 'devInterview' && !settings.getDevice(message.data)) {
logger.info('Connecting with device...');
this.mqtt.log('pairing', 'connecting with device');
device = message.endpoints[0].device;
}
if (message.type == 'devIncoming') {
logger.info('Device incoming...');
this.mqtt.log('pairing', 'device incoming');
// Retrieve modelId from message
if (device && device.modelId) {
mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
}
// We dont handle messages without endpoints.
if (!message.endpoints) {
return;
}
// Log
logger.debug(
`Received zigbee message of type '${message.type}' with data '${JSON.stringify(message.data)}'` +
(device ? ` of device '${device.modelId}' (${device.ieeeAddr})` : '')
);
const device = message.endpoints[0].device;
if (!device) {
logger.warn('Message without device!');
return;
}
// Check if this is a new device.
if (!settings.getDevice(device.ieeeAddr)) {
logger.info(`New device with address ${device.ieeeAddr} connected!`);
settings.addDevice(device.ieeeAddr);
this.mqtt.log('device_connected', device.ieeeAddr);
}
// We can't handle devices without modelId.
if (!device.modelId) {
return;
}
// Map Zigbee modelID to vendor modelID.
const modelID = message.endpoints[0].device.modelId;
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(modelID);
if (!mappedModel) {
logger.warn(`Device with modelID '${modelID}' is not supported.`);
logger.warn(`Please see: https://github.com/Koenkk/zigbee2mqtt/wiki/How-to-support-new-devices`);
return;
}
// Configure device.
this.configureDevice(device);
// Home Assistant MQTT discovery
if (settings.get().homeassistant) {
homeassistant.discover(device.ieeeAddr, mappedModel.model, this.mqtt, false);
}
// After this point we cant handle message withoud cid anymore.
if (!message.data || (!message.data.cid && !message.data.cmdId)) {
return;
}
// Find a conveter for this message.
const cid = message.data.cid;
const cmdId = message.data.cmdId;
const converters = mappedModel.fromZigbee.filter((c) => {
if (cid) {
return c.cid === cid && c.type === message.type;
} else if (cmdId) {
return c.cmd === cmdId;
}
return false;
});
if (!converters.length) {
if (cid) {
logger.warn(
`No converter available for '${mappedModel.model}' with cid '${cid}', ` +
`type '${message.type}' and data '${JSON.stringify(message.data)}'`
);
} else if (cmdId) {
logger.warn(
`No converter available for '${mappedModel.model}' with cmd '${cmdId}' ` +
`and data '${JSON.stringify(message.data)}'`
);
}
logger.warn(`Please see: https://github.com/Koenkk/zigbee2mqtt/wiki/How-to-support-new-devices.`);
return;
}
// Convert this Zigbee message to a MQTT message.
// Get payload for the message.
// - If a payload is returned publish it to the MQTT broker
// - If NO payload is returned do nothing. This is for non-standard behaviour
// for e.g. click switches where we need to count number of clicks and detect long presses.
converters.forEach((converter) => {
const publish = (payload) => {
// Don't cache messages with following properties:
const dontCacheProperties = ['click', 'action', 'button', 'button_left', 'button_right'];
let cache = true;
dontCacheProperties.forEach((property) => {
if (payload.hasOwnProperty(property)) {
cache = false;
}
});
// Add device linkquality.
if (message.hasOwnProperty('linkquality')) {
payload.linkquality = message.linkquality;
}
this.mqttPublishDeviceState(device, payload, cache);
};
const payload = converter.convert(mappedModel, message, publish, settings.getDevice(device.ieeeAddr));
if (payload) {
publish(payload);
}
});
// Call extensions.
this.extensions
.filter((e) => e.onZigbeeMessage)
.forEach((e) => e.onZigbeeMessage(message, device, mappedDevice));
}
handleMQTTMessage(topic, message) {
onMQTTMessage(topic, message) {
logger.debug(`Received MQTT message on '${topic}' with data '${message}'`);
// Find extensions that can handle MQTT messages and get results
// Call extensions
const results = this.extensions
.filter((e) => e.handleMQTTMessage)
.map((e) => e.handleMQTTMessage(topic, message));
.filter((e) => e.onMQTTMessage)
.map((e) => e.onMQTTMessage(topic, message));
if (topic.match(mqttConfigRegex)) {
this.handleMQTTMessageConfig(topic, message);
} else if (topic === 'hass/status') {
if (message.toString().toLowerCase() === 'online') {
const timer = setTimeout(() => {
this.sendAllCachedStates();
clearTimeout(timer);
}, 20000);
}
} else if (!results.includes(true)) {
if (!results.includes(true)) {
logger.warn(`Cannot handle MQTT message on '${topic}' with data '${message}'`);
}
}
handleMQTTMessageConfig(topic, message) {
const option = topic.split('/').slice(-1)[0];
if (option === 'permit_join') {
this.zigbee.permitJoin(message.toString().toLowerCase() === 'true');
} else if (option === 'log_level') {
const level = message.toString().toLowerCase();
if (allowedLogLevels.includes(level)) {
logger.info(`Switching log level to '${level}'`);
logger.transports.console.level = level;
logger.transports.file.level = level;
} else {
logger.error(`Could not set log level to '${level}'. Allowed level: '${allowedLogLevels.join(',')}'`);
}
} else if (option === 'devices') {
const devices = this.zigbee.getAllClients().map((device) => {
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
const friendlyDevice = settings.getDevice(device.ieeeAddr);
return {
ieeeAddr: device.ieeeAddr,
type: device.type,
model: mappedModel ? mappedModel.model : device.modelId,
friendly_name: friendlyDevice ? friendlyDevice.friendly_name : device.ieeeAddr,
};
start() {
this.startupLogVersion(() => {
this.zigbee.start(this.onZigbeeMessage, (error) => {
if (error) {
logger.error('Failed to start', error);
} else {
this.onZigbeeStarted();
}
});
this.mqtt.log('devices', devices);
} else if (option === 'remove') {
message = message.toString();
const IDByFriendlyName = settings.getIDByFriendlyName(message);
const deviceID = IDByFriendlyName ? IDByFriendlyName : message;
const device = this.zigbee.getDevice(deviceID);
const cleanup = () => {
// Clear Home Assistant MQTT discovery message
if (settings.get().homeassistant && device) {
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
if (mappedModel) {
homeassistant.clear(deviceID, mappedModel.model, this.mqtt);
}
}
// Remove from configuration.yaml
settings.removeDevice(deviceID);
// Remove from state
this.state.remove(deviceID);
logger.info(`Successfully removed ${deviceID}`);
this.mqtt.log('device_removed', message);
};
// Remove from zigbee network.
if (device) {
this.zigbee.removeDevice(deviceID, (error) => {
if (!error) {
cleanup();
} else {
logger.error(`Failed to remove ${deviceID}`);
}
});
} else {
cleanup();
}
} else if (option === 'rename') {
const invalid = `Invalid rename message format expected {old: 'friendly_name', new: 'new_name} ` +
`got ${message.toString()}`;
let json = null;
try {
json = JSON.parse(message.toString());
} catch (e) {
logger.error(invalid);
return;
}
// Validate message
if (!json.new || !json.old) {
logger.error(invalid);
return;
}
if (settings.changeFriendlyName(json.old, json.new)) {
logger.info(`Successfully renamed - ${json.old} to ${json.new} `);
} else {
logger.error(`Failed to renamed - ${json.old} to ${json.new}`);
return;
}
// Homeassistant rediscover
if (settings.get().homeassistant) {
const ID = settings.getIDByFriendlyName(json.new);
const device = this.zigbee.getDevice(ID);
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
if (mappedModel) {
homeassistant.discover(device.ieeeAddr, mappedModel.model, this.mqtt, true);
}
}
} else {
logger.warn(`Cannot handle MQTT config option '${option}' with message '${message}'`);
}
});
}
mqttPublishDeviceState(device, payload, cache) {
const deviceID = device.ieeeAddr;
const appSettings = settings.get();
let messagePayload = {...payload};
stop(callback) {
// Call extensions
this.extensions.filter((e) => e.stop).forEach((e) => e.stop());
if (cacheState) {
// Add cached state to payload
if (this.state.exists(deviceID)) {
messagePayload = objectAssignDeep.noMutate(this.state.get(deviceID), payload);
}
// Update state cache with new state.
if (cache) {
this.state.set(deviceID, messagePayload);
}
}
const deviceSettings = settings.getDevice(deviceID);
const friendlyName = deviceSettings ? deviceSettings.friendly_name : deviceID;
const options = {
retain: deviceSettings ? deviceSettings.retain : false,
qos: deviceSettings && deviceSettings.qos ? deviceSettings.qos : 0,
};
if (appSettings.mqtt.include_device_information) {
messagePayload.device = this.getDeviceInfoForMqtt(device);
}
this.mqtt.publish(friendlyName, JSON.stringify(messagePayload), options);
// Wrap-up
this.state.save();
this.mqtt.disconnect();
this.zigbee.stop(callback);
}
startupLogVersion(callback) {
@@ -496,6 +169,83 @@ class Controller {
callback();
});
}
getDeviceStartupLogMessage(device) {
let friendlyName = 'unknown';
let type = 'unknown';
let friendlyDevice = {model: 'unkown', description: 'unknown'};
const mappedModel = zigbeeShepherdConverters.findByZigbeeModel(device.modelId);
if (mappedModel) {
friendlyDevice = mappedModel;
}
if (settings.getDevice(device.ieeeAddr)) {
friendlyName = settings.getDevice(device.ieeeAddr).friendly_name;
}
if (device.type) {
type = device.type;
}
return `${friendlyName} (${device.ieeeAddr}): ${friendlyDevice.model} - ` +
`${friendlyDevice.vendor} ${friendlyDevice.description} (${type})`;
}
sendAllCachedStates() {
this.zigbee.getAllClients().forEach((device) => {
if (this.state.exists(device.ieeeAddr)) {
this.publishDeviceState(device, this.state.get(device.ieeeAddr), false);
}
});
}
publishDeviceState(device, payload, cache) {
const deviceID = device.ieeeAddr;
const appSettings = settings.get();
let messagePayload = {...payload};
if (appSettings.advanced.cache_state) {
// Add cached state to payload
if (this.state.exists(deviceID)) {
messagePayload = objectAssignDeep.noMutate(this.state.get(deviceID), payload);
}
// Update state cache with new state.
if (cache) {
this.state.set(deviceID, messagePayload);
}
}
const deviceSettings = settings.getDevice(deviceID);
const friendlyName = deviceSettings ? deviceSettings.friendly_name : deviceID;
const options = {
retain: deviceSettings ? deviceSettings.retain : false,
qos: deviceSettings && deviceSettings.qos ? deviceSettings.qos : 0,
};
if (appSettings.mqtt.include_device_information) {
messagePayload.device = this.getDeviceInfoForMqtt(device);
}
this.mqtt.publish(friendlyName, JSON.stringify(messagePayload), options);
}
getDeviceInfoForMqtt(device) {
const {type, ieeeAddr, nwkAddr, manufId, manufName, powerSource, modelId, status} = device;
const deviceSettings = settings.getDevice(device.ieeeAddr);
return {
ieeeAddr,
friendlyName: deviceSettings.friendly_name || '',
type,
nwkAddr,
manufId,
manufName,
powerSource,
modelId,
status,
};
}
}
module.exports = Controller;