zigbee2mqtt/lib/zigbee.js

562 lines
20 KiB
JavaScript
Raw Normal View History

2019-06-15 13:20:54 -07:00
const ZShepherd = require('zigbee-herdsman');
2018-04-18 09:25:40 -07:00
const logger = require('./util/logger');
const settings = require('./util/settings');
2018-05-11 20:04:15 -07:00
const data = require('./util/data');
const utils = require('./util/utils');
2019-02-23 07:18:41 -07:00
const ZigbeeQueue = require('./util/zigbeeQueue');
const cieApp = require('./zapp/cie');
const objectAssignDeep = require('object-assign-deep');
2019-06-15 13:20:54 -07:00
const zclId = require('zigbee-herdsman/zcl-id');
2018-04-18 09:25:40 -07:00
const advancedSettings = settings.get().advanced;
if (advancedSettings.channel < 11 || advancedSettings.channel > 26) {
throw new Error(`'${advancedSettings.channel}' is an invalid channel, use a channel between 11 - 26.`);
}
2018-04-18 09:25:40 -07:00
const shepherdSettings = {
net: {
2018-11-16 12:23:11 -07:00
panId: advancedSettings.pan_id,
extPanId: advancedSettings.ext_pan_id,
2018-11-16 12:23:11 -07:00
channelList: [advancedSettings.channel],
precfgkey: settings.get().advanced.network_key,
},
2018-05-17 08:48:41 -07:00
dbPath: data.joinPath('database.db'),
2019-04-14 07:27:02 -07:00
coordBackupPath: data.joinPath('coordinator_backup.json'),
2018-06-14 12:37:19 -07:00
sp: {
2018-11-16 12:23:11 -07:00
baudRate: advancedSettings.baudrate,
rtscts: advancedSettings.rtscts,
2018-06-14 12:37:19 -07:00
},
2018-04-18 09:25:40 -07:00
};
const defaultCfg = {
manufSpec: 0,
disDefaultRsp: 0,
};
// Don't print network key.
const shepherdSettingsLog = objectAssignDeep.noMutate(shepherdSettings);
shepherdSettingsLog.net.precfgkey = 'HIDDEN';
2018-06-14 12:37:19 -07:00
2018-04-18 09:25:40 -07:00
class Zigbee {
2018-11-16 12:23:11 -07:00
constructor() {
this.onReady = this.onReady.bind(this);
this.onMessage = this.onMessage.bind(this);
this.onError = this.onError.bind(this);
this.messageHandler = null;
this.permitJoinTimer = null;
2019-02-01 11:04:49 -07:00
2019-02-23 07:18:41 -07:00
this.queue = new ZigbeeQueue();
2018-04-18 09:25:40 -07:00
}
2018-11-16 12:23:11 -07:00
start(messageHandler, callback) {
2018-04-18 09:25:40 -07:00
logger.info(`Starting zigbee-shepherd`);
2019-06-09 15:01:48 -07:00
logger.debug(`Using zigbee-shepherd with settings: '${JSON.stringify(shepherdSettingsLog)}'`);
2018-11-16 12:23:11 -07:00
this.messageHandler = messageHandler;
2018-04-18 09:25:40 -07:00
this.shepherd = new ZShepherd(settings.get().serial.port, shepherdSettings);
this.shepherd.start((error) => {
if (error) {
2019-04-14 07:27:02 -07:00
logger.info(`Error while starting zigbee-shepherd, attempting to fix... (takes 60 seconds) (${error})`);
this.shepherd.controller._znp.close((() => null));
setTimeout(() => {
2018-06-04 08:36:46 -07:00
logger.info(`Starting zigbee-shepherd`);
this.shepherd.start((error) => {
if (error) {
2019-04-14 07:27:02 -07:00
logger.error(`Error while starting zigbee-shepherd! (${error})`);
logger.error(
'Press the reset button on the stick (the one closest to the USB) and start again'
);
callback(error);
} else {
2019-03-26 13:34:58 -07:00
this._handleStarted();
callback(null);
}
});
2018-11-16 12:23:11 -07:00
}, utils.secondsToMilliseconds(60));
2018-04-18 09:25:40 -07:00
} else {
2019-03-26 13:34:58 -07:00
this._handleStarted();
callback(null);
2018-04-18 09:25:40 -07:00
}
});
// Register callbacks.
2018-11-16 12:23:11 -07:00
this.shepherd.on('ready', this.onReady);
this.shepherd.on('ind', this.onMessage);
this.shepherd.on('error', this.onError);
2019-03-26 13:34:58 -07:00
this._acceptDevIncoming = this._acceptDevIncoming.bind(this);
this.shepherd.acceptDevIncoming = this._acceptDevIncoming;
}
_handleStarted() {
this.logStartupInfo();
this.getAllClients().forEach((device) => {
if (settings.get().ban.includes(device.ieeeAddr)) {
logger.warn(`Banned device is connected (${device.ieeeAddr}), removing...`);
2019-03-26 13:34:58 -07:00
this.removeDevice(device.ieeeAddr, false, () => {});
}
});
2019-04-14 09:53:34 -07:00
this.shepherd.backupCoordinator(() => {});
2019-03-26 13:34:58 -07:00
}
_acceptDevIncoming(devInfo, callback) {
logger.debug(
`Accept device incoming with ieeeAddr '${devInfo.ieeeAddr}' permit join is '${this.getPermitJoin()}'`
);
2019-04-01 11:30:27 -07:00
if (settings.get().ban.includes(devInfo.ieeeAddr)) {
logger.info(`Banned device tried to connect (${devInfo.ieeeAddr})`);
callback(null, false);
2019-03-26 13:34:58 -07:00
} else {
2019-04-01 11:30:27 -07:00
logger.debug(`Allowing device '${devInfo.ieeeAddr}' to join`);
callback(null, true);
2019-03-26 13:34:58 -07:00
}
2018-04-18 09:25:40 -07:00
}
2018-11-16 12:23:11 -07:00
logStartupInfo() {
logger.info('zigbee-shepherd started');
logger.info(`Coordinator firmware version: '${this.getFirmwareVersion()}'`);
logger.debug(`zigbee-shepherd info: ${JSON.stringify(this.shepherd.info())}`);
}
getFirmwareVersion() {
return this.shepherd.info().firmware.revision;
}
2018-05-21 02:49:02 -07:00
softReset(callback) {
this.shepherd.reset('soft', callback);
}
2018-04-18 09:25:40 -07:00
stop(callback) {
if (this.permitJoinTimer) {
clearInterval(this.permitJoinTimer);
}
2019-02-01 16:57:51 -07:00
this.queue.stop();
2019-04-14 07:27:02 -07:00
// Backup coordinator
this.shepherd.backupCoordinator(() => {
this.shepherd.stop((error) => {
logger.info('zigbee-shepherd stopped');
callback(error);
});
2018-04-18 09:25:40 -07:00
});
}
2018-11-16 12:23:11 -07:00
onReady() {
// Mount cieApp
this.shepherd.mount(cieApp, (err, epId) => {
if (!err) {
2019-01-07 10:18:14 -07:00
logger.debug(`Mounted the cieApp (epId ${epId})`);
} else {
logger.error(`Failed to mount the cieApp`);
}
});
// Check if we have to turn off the led
2018-07-21 12:13:28 -07:00
if (settings.get().serial.disable_led) {
this.shepherd.controller.request('UTIL', 'ledControl', {ledid: 3, mode: 0});
}
2019-02-13 12:43:22 -07:00
// Wait some time before we start the queue, many calls skip this queue which hangs the stick
setTimeout(() => {
this.queue.start();
}, 2000);
logger.info('zigbee-shepherd ready');
}
2018-11-16 12:23:11 -07:00
onError(message) {
2018-05-17 08:48:41 -07:00
// This event may appear if zigbee-shepherd cannot decode bad packets (invalid checksum).
logger.error(message);
}
permitJoin(permit, callback) {
if (permit) {
logger.info('Zigbee: allowing new devices to join.');
} else {
logger.info('Zigbee: disabling joining new devices.');
2018-04-18 09:25:40 -07:00
}
// In zigbee 3.0 a network automatically closes after 254 seconds.
// As a workaround, we enable joining again.
if (this.permitJoinTimer) {
clearInterval(this.permitJoinTimer);
}
if (permit) {
this.permitJoinTimer = setInterval(() => {
this.shepherd.permitJoin(255, (error) => {
if (error) {
logger.error('Failed to reenable joining');
} else {
logger.info('Successfully reenabled joining');
}
});
}, utils.secondsToMilliseconds(160));
}
this.shepherd.permitJoin(permit ? 255 : 0, (error) => {
2018-04-18 09:25:40 -07:00
if (error) {
logger.info(error);
}
if (callback) {
callback();
}
2018-04-18 09:25:40 -07:00
});
}
getPermitJoin() {
return this.shepherd.controller._permitJoinTime === 255;
}
getAllClients() {
return this.getDevices().filter((device) => device.type !== 'Coordinator');
}
2019-01-08 11:00:02 -07:00
removeDevice(deviceID, ban, callback) {
2019-03-26 13:34:58 -07:00
if (ban) {
settings.banDevice(deviceID);
}
const friendlyName = this.getDeviceFriendlyName(deviceID);
2019-03-26 13:34:58 -07:00
this.shepherd.remove(deviceID, {reJoin: true}, (error) => {
if (error) {
logger.warn(`Failed to remove '${friendlyName}', trying force remove...`);
2018-11-16 12:23:11 -07:00
this.forceRemove(deviceID, callback);
2018-06-06 12:13:32 -07:00
} else {
logger.info(`Removed ${friendlyName}`);
callback(null);
2018-06-06 12:13:32 -07:00
}
});
}
2018-11-16 12:23:11 -07:00
forceRemove(deviceID, callback) {
2018-06-06 12:13:32 -07:00
const device = this.shepherd._findDevByAddr(deviceID);
2018-06-07 10:41:11 -07:00
if (device) {
const friendlyName = this.getDeviceFriendlyName(deviceID);
2019-03-26 13:34:58 -07:00
return this.shepherd._unregisterDev(device, (error) => {
logger.info(`Force removed ${friendlyName}`);
2019-03-26 13:34:58 -07:00
callback(error);
});
2018-06-07 10:41:11 -07:00
} else {
logger.warn(`Could not find ${deviceID} for force removal`);
callback(true);
2018-06-07 10:41:11 -07:00
}
}
2018-06-06 12:19:50 -07:00
2018-11-16 12:23:11 -07:00
onMessage(message) {
if (this.messageHandler) {
this.messageHandler(message);
2018-04-18 09:25:40 -07:00
}
}
getDevices() {
return this.shepherd.list();
}
getDevice(ieeeAddr) {
return this.getDevices().find((d) => d.ieeeAddr === ieeeAddr);
2018-04-27 14:58:46 -07:00
}
getDeviceFriendlyName(ieeeAddr) {
const device = settings.getDevice(ieeeAddr);
return device ? device.friendly_name || ieeeAddr : ieeeAddr;
}
getCoordinator() {
const device = this.getDevices().find((d) => d.type === 'Coordinator');
return this.shepherd.find(device.ieeeAddr, 1);
}
getScanable() {
return this.getDevices().filter((d) => d.type != 'EndDevice');
}
getGroup(ID) {
return this.shepherd.getGroup(ID);
}
getGroupFriendlyName(ID) {
let friendlyName = null;
friendlyName = settings.getGroup(ID).friendly_name;
return (friendlyName ? friendlyName : ID);
}
networkScan(callback) {
logger.info('Starting network scan...');
const scanList = new Set();
const linkMap = [];
const processResponse = (error, rsp, parent) => {
if (error) {
logger.warn(`Failed network scan for device: '${parent}' with error: '${error}'`);
} else {
if (scanList.has(parent)) {
// Haven't processed this one yet
if (rsp && rsp.status === 0 && rsp.neighborlqilist) {
logger.debug(`Network scan ok for: '${parent}' with '${rsp.neighborlqilistcount}' neighbors`);
rsp.neighborlqilist.forEach(function(neighbor) {
linkMap.push({
parent: parent, ieeeAddr: neighbor.extAddr, nwkAddr: neighbor.nwkAddr,
lqi: neighbor.lqi, depth: neighbor.depth});
});
// Remove from list and if this was the last one return the completed network map
scanList.delete(parent);
if (scanList.size === 0) {
logger.info('Network scan completed');
linkMap.sort((a, b) => ((a.parent + '|' + a.ieeeAddr) > (b.parent + '|' + b.ieeeAddr)) ? 1
: (((b.parent + '|' + b.ieeeAddr) > (a.parent + '|' + a.ieeeAddr)) ? -1 : 0));
logger.debug(`Link map (complete): %j`, linkMap);
callback(linkMap);
} else {
logger.debug(`Still waiting for network scans for devices: '${[...scanList].join(' ')}'`);
}
} else {
logger.warn(`Empty network scan result for: '${parent}'`);
}
} else {
// This ieeeAddr has already been removed due to timeout so don't add to result network map
logger.warn(`Ignoring late network scan result for: '${parent}'`);
}
}
};
// Queue up an lqi scan for coordinator and each router
this.getScanable().forEach((dev) => {
logger.debug(`Queing network scan for device: '${dev.ieeeAddr}'`);
scanList.add(dev.ieeeAddr);
this.queue.push(dev.ieeeAddr, (queueCallback) => {
this.shepherd.controller.request('ZDO', 'mgmtLqiReq', {dstaddr: dev.nwkAddr, startindex: 0},
(error, rsp, parent) => {
processResponse(error, rsp, dev.ieeeAddr);
queueCallback(error);
});
});
});
// Wait for all device scans before forcing map with whatever results are already in
setTimeout(() => {
if (scanList.size === 0) {
logger.info('Network scan timeout no outstanding requests');
} else {
logger.warn(`Network scan timeout, skipping outstanding scans for '${[...scanList].join(' ')}'`);
// Clear remaining devices so they don't process when/if they eventually complete
scanList.clear();
logger.debug(`Link map (timeout): %j`, linkMap);
linkMap.sort((a, b) => ((a.parent + '|' + a.ieeeAddr) > (b.parent + '|' + b.ieeeAddr)) ? 1
: (((b.parent + '|' + b.ieeeAddr) > (a.parent + '|' + a.ieeeAddr)) ? -1 : 0));
callback(linkMap);
}
}, scanList.size * 1000);
}
2019-02-01 11:04:49 -07:00
getEndpoint(ieeeAddr, ep) {
// If no ep is given, the first endpoint will be returned
2018-05-30 13:28:08 -07:00
// Find device in zigbee-shepherd
2019-02-01 11:04:49 -07:00
const device = this.getDevice(ieeeAddr);
2018-05-30 13:28:08 -07:00
if (!device || !device.epList || !device.epList.length) {
2019-02-01 11:04:49 -07:00
logger.error(`Zigbee cannot determine endpoint for '${ieeeAddr}'`);
2018-05-30 13:28:08 -07:00
return null;
}
ep = ep ? ep : device.epList[0];
2019-02-01 11:04:49 -07:00
const endpoint = this.shepherd.find(ieeeAddr, ep);
return endpoint;
}
publish(entityID, entityType, cid, cmd, cmdType, zclData, cfg=defaultCfg, ep, callback) {
let entity = null;
let friendlyName = null;
if (entityType === 'device') {
entity = this.getEndpoint(entityID, ep);
friendlyName = this.getDeviceFriendlyName(entityID);
} else if (entityType === 'group') {
entity = this.getGroup(entityID);
friendlyName = this.getGroupFriendlyName(entityID);
}
if (!entity) {
logger.error(
`Cannot publish message to ${entityType} because '${entityID}' is not known by zigbee-shepherd`
);
return;
}
2019-02-23 07:18:41 -07:00
this.queue.push(entityID, (queueCallback) => {
logger.info(
`Zigbee publish to ${entityType} '${friendlyName}', ${cid} - ${cmd} - ` +
`${JSON.stringify(zclData)} - ${JSON.stringify(cfg)} - ${ep}`
);
const callback_ = (error, rsp) => {
if (error) {
logger.error(
`Zigbee publish to ${entityType} '${friendlyName}', ${cid} ` +
`- ${cmd} - ${JSON.stringify(zclData)} ` +
`- ${JSON.stringify(cfg)} - ${ep} ` +
`failed with error ${error}`);
}
2019-02-01 18:14:31 -07:00
if (callback) {
callback(error, rsp);
}
2019-02-23 07:18:41 -07:00
queueCallback(error);
};
if (cmdType === 'functional' && entity.functional) {
entity.functional(cid, cmd, zclData, cfg, callback_);
} else if (cmdType === 'foundation' && entity.foundation) {
entity.foundation(cid, cmd, zclData, cfg, callback_);
} else {
logger.error(`Unknown zigbee publish cmdType ${cmdType}`);
}
});
}
ping(ieeeAddr, errorLogLevel='error', cb, mechanism='default') {
const friendlyName = this.getDeviceFriendlyName(ieeeAddr);
const callback = (error) => {
if (error) {
logger[errorLogLevel](`Failed to ping '${friendlyName}'`);
} else {
logger.debug(`Successfully pinged '${friendlyName}'`);
}
2019-02-01 16:57:51 -07:00
if (cb) {
cb(error);
}
};
if (mechanism === 'default') {
const device = this.shepherd._findDevByAddr(ieeeAddr);
if (device) {
logger.debug(`Ping ${ieeeAddr} (default)`);
this.queue.push(ieeeAddr, (queueCallback) => {
this.shepherd.controller.checkOnline(device, (error) => {
callback(error);
queueCallback(error);
});
2019-02-23 07:18:41 -07:00
});
}
} else if (mechanism === 'basic') {
const endpoint = this.getEndpoint(ieeeAddr, null);
if (endpoint) {
logger.debug(`Ping ${ieeeAddr} (basic)`);
this.queue.push(ieeeAddr, (queueCallback) => {
endpoint.foundation('genBasic', 'read', [{attrId: 0}], (error) => {
callback(error);
queueCallback(error);
});
});
}
2019-02-01 16:57:51 -07:00
}
}
2019-03-15 13:19:42 -07:00
bind(ep, cluster, target, callback) {
const friendlyName = this.getDeviceFriendlyName(ep.device.ieeeAddr);
const log = ` '${friendlyName}' - ${cluster}`;
2019-03-15 13:19:42 -07:00
target = !target ? this.getCoordinator() : target;
2019-02-01 11:43:22 -07:00
2019-02-23 07:18:41 -07:00
this.queue.push(ep.device.ieeeAddr, (queueCallback) => {
2019-03-15 13:19:42 -07:00
logger.debug(`Binding ${log}`);
ep.bind(cluster, target, (error) => {
2019-02-01 11:43:22 -07:00
if (error) {
2019-03-15 13:19:42 -07:00
logger.error(`Failed to bind ${log} - (${error})`);
2019-02-01 11:43:22 -07:00
} else {
2019-03-15 13:19:42 -07:00
logger.debug(`Successfully bound ${log}`);
2019-02-01 11:43:22 -07:00
}
2019-03-15 13:19:42 -07:00
callback(error);
queueCallback(error);
});
});
}
unbind(ep, cluster, target, callback) {
const friendlyName = this.getDeviceFriendlyName(ep.device.ieeeAddr);
const log = ` '${friendlyName}' - ${cluster}`;
2019-03-15 13:19:42 -07:00
target = !target ? this.getCoordinator() : target;
this.queue.push(ep.device.ieeeAddr, (queueCallback) => {
logger.debug(`Unbinding ${log}`);
ep.unbind(cluster, target, (error) => {
if (error) {
logger.error(`Failed to unbind ${log} - (${error})`);
} else {
logger.debug(`Successfully unbound ${log}`);
}
callback(error);
2019-02-23 07:18:41 -07:00
queueCallback(error);
});
2019-02-01 11:43:22 -07:00
});
}
/*
* Setup reporting.
* Attributes is an array of attribute objects.
* each attribute object should contain the following properties:
* attr the attribute name,
* min the minimal time between reports in seconds,
* max the maximum time between reports in seconds,
* change the minimum amount of change before sending a report
*/
report(ep, cluster, attributes) {
const friendlyName = this.getDeviceFriendlyName(ep.device.ieeeAddr);
2019-02-26 12:21:35 -07:00
const cfgArr = attributes.map((attribute) => {
const attrId = zclId.attr(cluster, attribute.attr).value;
const dataType = zclId.attrType(cluster, attribute.attr).value;
2019-02-26 12:21:35 -07:00
return {
direction: 0,
attrId,
dataType,
minRepIntval: attribute.min,
maxRepIntval: attribute.max,
repChange: attribute.change,
2019-02-26 12:21:35 -07:00
};
});
const log=`for '${friendlyName}' - ${cluster} - ${attributes.length}`;
2019-02-01 11:04:49 -07:00
const configReport = () => {
this.queue.push(ep.device.ieeeAddr, (queueCallback) => {
ep.foundation(cluster, 'configReport', cfgArr, defaultCfg, (error) => {
if (error) {
logger.error(`Failed to setup reporting ${log} - (${error})`);
} else {
logger.debug(`Successfully setup reporting ${log}`);
}
queueCallback(error);
});
});
};
this.queue.push(ep.device.ieeeAddr, (queueCallback) => {
2019-02-01 11:04:49 -07:00
logger.debug(`Setup reporting ${log}`);
ep.bind(cluster, this.getCoordinator(), (error) => {
2019-02-01 11:04:49 -07:00
if (error) {
logger.error(`Failed to bind for reporting ${log} - (${error})`);
2019-02-01 11:04:49 -07:00
} else {
// Only if binding succeeds, setting-up reporting makes sense.
configReport();
2019-02-01 11:04:49 -07:00
}
queueCallback(error);
2019-02-22 10:58:50 -07:00
});
2019-02-01 11:04:49 -07:00
});
2018-04-18 09:25:40 -07:00
}
}
module.exports = Zigbee;