mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2024-11-17 02:48:31 -07:00
518 lines
19 KiB
JavaScript
518 lines
19 KiB
JavaScript
const logger = require('../util/logger');
|
|
const utils = require('../util/utils');
|
|
const Extension = require('./extension');
|
|
const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters');
|
|
const settings = require('../util/settings');
|
|
const Transport = require('winston-transport');
|
|
const stringify = require('json-stable-stringify-without-jsonify');
|
|
const objectAssignDeep = require(`object-assign-deep`);
|
|
|
|
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
|
|
|
|
class Bridge extends Extension {
|
|
constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension) {
|
|
super(zigbee, mqtt, state, publishEntityState, eventBus);
|
|
this.enableDisableExtension = enableDisableExtension;
|
|
this.lastJoinedDeviceIeeeAddr = null;
|
|
this.setupMQTTLogging();
|
|
|
|
this.requestLookup = {
|
|
'device/options': this.deviceOptions.bind(this),
|
|
'device/remove': this.deviceRemove.bind(this),
|
|
'device/rename': this.deviceRename.bind(this),
|
|
'group/add': this.groupAdd.bind(this),
|
|
'group/options': this.groupOptions.bind(this),
|
|
'group/remove': this.groupRemove.bind(this),
|
|
'group/rename': this.groupRename.bind(this),
|
|
'permit_join': this.permitJoin.bind(this),
|
|
'config/last_seen': this.configLastSeen.bind(this),
|
|
'config/homeassistant': this.configHomeAssistant.bind(this),
|
|
'config/elapsed': this.configElapsed.bind(this),
|
|
'config/log_level': this.configLogLevel.bind(this),
|
|
'touchlink/factory_reset': this.touchlinkFactoryReset.bind(this),
|
|
'touchlink/identify': this.touchlinkIdentify.bind(this),
|
|
'touchlink/scan': this.touchlinkScan.bind(this),
|
|
'health_check': this.healthCheck.bind(this),
|
|
};
|
|
}
|
|
|
|
async onMQTTConnected() {
|
|
this.zigbee2mqttVersion = await utils.getZigbee2mqttVersion();
|
|
this.coordinatorVersion = await this.zigbee.getCoordinatorVersion();
|
|
|
|
this.eventBus.on(`groupMembersChanged`, () => this.publishGroups());
|
|
this.eventBus.on(`devicesChanged`, () => this.publishDevices());
|
|
this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/#`);
|
|
await this.publishInfo();
|
|
await this.publishDevices();
|
|
await this.publishGroups();
|
|
}
|
|
|
|
setupMQTTLogging() {
|
|
const mqtt = this.mqtt;
|
|
class EventTransport extends Transport {
|
|
log(info, callback) {
|
|
const payload = stringify({message: info.message, level: info.level});
|
|
mqtt.publish(`bridge/logging`, payload, {}, settings.get().mqtt.base_topic, true);
|
|
callback();
|
|
}
|
|
}
|
|
|
|
logger.add(new EventTransport());
|
|
}
|
|
|
|
async onMQTTMessage(topic, message) {
|
|
const match = topic.match(requestRegex);
|
|
if (match && this.requestLookup[match[1].toLowerCase()]) {
|
|
message = utils.parseJSON(message, message);
|
|
|
|
try {
|
|
const response = await this.requestLookup[match[1].toLowerCase()](message);
|
|
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
|
} catch (error) {
|
|
logger.error(`Request '${topic}' failed with error: '${error.message}'`);
|
|
const response = utils.getResponse(message, {}, error.message);
|
|
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
|
|
}
|
|
}
|
|
}
|
|
|
|
async onZigbeeEvent(type, data, resolvedEntity) {
|
|
if (type === 'deviceJoined' && resolvedEntity) {
|
|
this.lastJoinedDeviceIeeeAddr = resolvedEntity.device.ieeeAddr;
|
|
}
|
|
|
|
if (['deviceJoined', 'deviceLeave', 'deviceInterview', 'deviceAnnounce'].includes(type)) {
|
|
let payload;
|
|
const ieeeAddress = data.device ? data.device.ieeeAddr : data.ieeeAddr;
|
|
if (type === 'deviceJoined') {
|
|
payload = {friendly_name: resolvedEntity.settings.friendlyName, ieee_address: ieeeAddress};
|
|
} else if (type === 'deviceInterview') {
|
|
payload = {
|
|
friendly_name: resolvedEntity.settings.friendlyName, status: data.status, ieee_address: ieeeAddress,
|
|
};
|
|
if (data.status === 'successful') {
|
|
const definition = resolvedEntity.definition;
|
|
payload.supported = !!definition;
|
|
payload.definition = this.getDefinitionPayload(definition);
|
|
}
|
|
} else if (type === 'deviceAnnounce') {
|
|
payload = {
|
|
friendly_name: resolvedEntity.settings.friendlyName, ieee_address: ieeeAddress,
|
|
};
|
|
} else payload = {ieee_address: ieeeAddress}; // deviceLeave
|
|
|
|
await this.mqtt.publish(
|
|
'bridge/event',
|
|
stringify({type: utils.toSnakeCase(type), data: payload}),
|
|
{retain: false, qos: 0},
|
|
);
|
|
}
|
|
|
|
if ('deviceLeave' === type || ('deviceInterview' === type && data.status !== 'started')) {
|
|
await this.publishDevices();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Requests
|
|
*/
|
|
|
|
async deviceOptions(message) {
|
|
return this.changeEntityOptions('device', message);
|
|
}
|
|
|
|
async groupOptions(message) {
|
|
return this.changeEntityOptions('group', message);
|
|
}
|
|
|
|
async deviceRemove(message) {
|
|
return this.removeEntity('device', message);
|
|
}
|
|
|
|
async groupRemove(message) {
|
|
return this.removeEntity('group', message);
|
|
}
|
|
|
|
async healthCheck(message) {
|
|
return utils.getResponse(message, {healthy: true}, null);
|
|
}
|
|
|
|
async groupAdd(message) {
|
|
if (typeof message === 'object' && !message.hasOwnProperty('friendly_name')) {
|
|
throw new Error(`Invalid payload`);
|
|
}
|
|
|
|
const friendlyName = typeof message === 'object' ? message.friendly_name : message;
|
|
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : null;
|
|
const group = settings.addGroup(friendlyName, ID);
|
|
this.zigbee.createGroup(group.ID);
|
|
this.publishGroups();
|
|
return utils.getResponse(message, {friendly_name: group.friendlyName, id: group.ID}, null);
|
|
}
|
|
|
|
async deviceRename(message) {
|
|
return this.renameEntity('device', message);
|
|
}
|
|
|
|
async groupRename(message) {
|
|
return this.renameEntity('group', message);
|
|
}
|
|
|
|
async permitJoin(message) {
|
|
const value = this.getValue(message);
|
|
await this.zigbee.permitJoin(value);
|
|
await this.publishInfo();
|
|
return utils.getResponse(message, {value: value}, null);
|
|
}
|
|
|
|
configLastSeen(message) {
|
|
const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local'];
|
|
const value = this.getValue(message);
|
|
if (!allowed.includes(value)) {
|
|
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
|
}
|
|
|
|
settings.set(['advanced', 'last_seen'], message);
|
|
this.publishInfo();
|
|
return utils.getResponse(message, {value}, null);
|
|
}
|
|
|
|
configHomeAssistant(message) {
|
|
const allowed = [true, false];
|
|
const value = this.getValue(message);
|
|
if (!allowed.includes(value)) {
|
|
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
|
}
|
|
|
|
this.enableDisableExtension(value, 'HomeAssistant');
|
|
settings.set(['homeassistant'], value);
|
|
this.publishInfo();
|
|
return utils.getResponse(message, {value}, null);
|
|
}
|
|
|
|
configElapsed(message) {
|
|
const allowed = [true, false];
|
|
const value = this.getValue(message);
|
|
if (!allowed.includes(value)) {
|
|
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
|
}
|
|
|
|
settings.set(['advanced', 'elapsed'], value);
|
|
this.publishInfo();
|
|
return utils.getResponse(message, {value}, null);
|
|
}
|
|
|
|
configLogLevel(message) {
|
|
const allowed = ['error', 'warn', 'info', 'debug'];
|
|
const value = this.getValue(message);
|
|
if (!allowed.includes(value)) {
|
|
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
|
|
}
|
|
|
|
logger.setLevel(value);
|
|
this.publishInfo();
|
|
return utils.getResponse(message, {value}, null);
|
|
}
|
|
|
|
async touchlinkIdentify(message) {
|
|
if (typeof message !== 'object' || !message.hasOwnProperty('ieee_address') ||
|
|
!message.hasOwnProperty('channel')) {
|
|
throw new Error('Invalid payload');
|
|
}
|
|
|
|
logger.info(`Start Touchlink identify of '${message.ieee_address}' on channel ${message.channel}`);
|
|
await this.zigbee.touchlinkIdentify(message.ieee_address, message.channel);
|
|
return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel}, null);
|
|
}
|
|
|
|
async touchlinkFactoryReset(message) {
|
|
let result = false;
|
|
const payload = {};
|
|
if (typeof message === 'object' && message.hasOwnProperty('ieee_address') &&
|
|
message.hasOwnProperty('channel')) {
|
|
logger.info(`Start Touchlink factory reset of '${message.ieee_address}' on channel ${message.channel}`);
|
|
result = await this.zigbee.touchlinkFactoryReset(message.ieee_address, message.channel);
|
|
payload.ieee_address = message.ieee_address;
|
|
payload.channel = message.channel;
|
|
} else {
|
|
logger.info('Start Touchlink factory reset of first found device');
|
|
result = await this.zigbee.touchlinkFactoryResetFirst();
|
|
}
|
|
|
|
if (result) {
|
|
logger.info('Successfully factory reset device through Touchlink');
|
|
return utils.getResponse(message, payload, null);
|
|
} else {
|
|
logger.error('Failed to factory reset device through Touchlink');
|
|
throw new Error('Failed to factory reset device through Touchlink');
|
|
}
|
|
}
|
|
|
|
async touchlinkScan(message) {
|
|
logger.info('Start Touchlink scan');
|
|
const result = await this.zigbee.touchlinkScan();
|
|
const found = result.map((r) => {
|
|
return {ieee_address: r.ieeeAddr, channel: r.channel};
|
|
});
|
|
logger.info('Finished Touchlink scan');
|
|
return utils.getResponse(message, {found}, null);
|
|
}
|
|
|
|
/**
|
|
* Utils
|
|
*/
|
|
|
|
getValue(message) {
|
|
if (typeof message === 'object') {
|
|
if (!message.hasOwnProperty('value')) {
|
|
throw new Error('No value given');
|
|
}
|
|
|
|
return message.value;
|
|
} else {
|
|
return message;
|
|
}
|
|
}
|
|
|
|
changeEntityOptions(entityType, message) {
|
|
if (typeof message !== 'object' || !message.hasOwnProperty('id') || !message.hasOwnProperty('options')) {
|
|
throw new Error(`Invalid payload`);
|
|
}
|
|
|
|
const ID = message.id;
|
|
const entity = this.getEntity(entityType, ID);
|
|
settings.changeEntityOptions(ID, message.options);
|
|
const cleanup = (o) => {
|
|
delete o.friendlyName; delete o.friendly_name; delete o.ID; delete o.type; delete o.devices;
|
|
return o;
|
|
};
|
|
const oldOptions = cleanup(entity.settings);
|
|
const newOptions = cleanup(settings.getEntity(ID));
|
|
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID}, null);
|
|
}
|
|
|
|
renameEntity(entityType, message) {
|
|
const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true;
|
|
if (typeof message !== 'object' || (!message.hasOwnProperty('from') && !deviceAndHasLast) ||
|
|
!message.hasOwnProperty('to')) {
|
|
throw new Error(`Invalid payload`);
|
|
}
|
|
|
|
if (deviceAndHasLast && !this.lastJoinedDeviceIeeeAddr) {
|
|
throw new Error('No device has joined since start');
|
|
}
|
|
|
|
const validationErrors = utils.validateFriendlyName(message.to);
|
|
if (validationErrors.length !== 0) {
|
|
throw new Error(validationErrors[0]);
|
|
}
|
|
|
|
const from = deviceAndHasLast ? this.lastJoinedDeviceIeeeAddr : message.from;
|
|
const to = message.to;
|
|
const homeAssisantRename = message.hasOwnProperty('homeassistant_rename') ?
|
|
message.homeassistant_rename : false;
|
|
const entity = this.getEntity(entityType, from);
|
|
|
|
settings.changeFriendlyName(from, to);
|
|
|
|
// Clear retained messages
|
|
this.mqtt.publish(entity.name, '', {retain: true});
|
|
|
|
if (entity.type === 'device') {
|
|
this.publishDevices();
|
|
this.eventBus.emit(`deviceRenamed`, {device: entity.device, homeAssisantRename});
|
|
} else {
|
|
this.publishGroups();
|
|
this.eventBus.emit(`groupRenamed`, {group: entity.group, homeAssisantRename});
|
|
}
|
|
|
|
return utils.getResponse(
|
|
message,
|
|
{from: entity.settings.friendlyName, to, homeassistant_rename: homeAssisantRename},
|
|
null,
|
|
);
|
|
}
|
|
|
|
async removeEntity(entityType, message) {
|
|
const ID = typeof message === 'object' ? message.id : message.trim();
|
|
const entity = this.getEntity(entityType, ID);
|
|
|
|
let block = false;
|
|
let force = false;
|
|
let blockForceLog = '';
|
|
|
|
if (entityType === 'device' && typeof message === 'object') {
|
|
block = !!message.block;
|
|
force = !!message.force;
|
|
blockForceLog = ` (block: ${block}, force: ${force})`;
|
|
} else if (entityType === 'group' && typeof message === 'object') {
|
|
force = !!message.force;
|
|
blockForceLog = ` (force: ${force})`;
|
|
}
|
|
|
|
try {
|
|
logger.info(`Removing ${entity.type} '${entity.settings.friendlyName}'${blockForceLog}`);
|
|
if (entity.type === 'device') {
|
|
if (block) {
|
|
settings.blockDevice(entity.settings.ID);
|
|
}
|
|
|
|
if (force) {
|
|
await entity.device.removeFromDatabase();
|
|
} else {
|
|
await entity.device.removeFromNetwork();
|
|
}
|
|
} else {
|
|
if (force) {
|
|
await entity.group.removeFromDatabase();
|
|
} else {
|
|
await entity.group.removeFromNetwork();
|
|
}
|
|
}
|
|
|
|
// Fire event
|
|
if (entity.type === 'device') {
|
|
this.eventBus.emit('deviceRemoved', {resolvedEntity: entity});
|
|
}
|
|
|
|
// Remove from configuration.yaml
|
|
if (entity.type === 'device') {
|
|
settings.removeDevice(entity.settings.ID);
|
|
} else {
|
|
settings.removeGroup(entity.settings.ID);
|
|
}
|
|
|
|
// Remove from state
|
|
this.state.remove(entity.settings.ID);
|
|
|
|
// Clear any retained messages
|
|
this.mqtt.publish(entity.name, '', {retain: true});
|
|
|
|
logger.info(`Successfully removed ${entity.type} '${entity.settings.friendlyName}'${blockForceLog}`);
|
|
|
|
if (entity.type === 'device') {
|
|
this.publishGroups();
|
|
this.publishDevices();
|
|
return utils.getResponse(message, {id: ID, block, force}, null);
|
|
} else {
|
|
this.publishGroups();
|
|
return utils.getResponse(message, {id: ID, force: force}, null);
|
|
}
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Failed to remove ${entity.type} '${entity.settings.friendlyName}'${blockForceLog} (${error})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
getEntity(type, ID) {
|
|
const entity = this.zigbee.resolveEntity(ID);
|
|
if (!entity || entity.type !== type) {
|
|
throw new Error(`${utils.capitalize(type)} '${ID}' does not exist`);
|
|
}
|
|
return entity;
|
|
}
|
|
|
|
async publishInfo() {
|
|
const config = objectAssignDeep.noMutate({}, settings.get());
|
|
delete config.advanced.network_key;
|
|
delete config.mqtt.password;
|
|
const payload = {
|
|
version: this.zigbee2mqttVersion.version,
|
|
commit: this.zigbee2mqttVersion.commitHash,
|
|
coordinator: this.coordinatorVersion,
|
|
network: utils.toSnakeCase(await this.zigbee.getNetworkParameters()),
|
|
log_level: logger.getLevel(),
|
|
permit_join: await this.zigbee.getPermitJoin(),
|
|
config,
|
|
};
|
|
|
|
await this.mqtt.publish('bridge/info', stringify(payload), {retain: true, qos: 0});
|
|
}
|
|
|
|
async publishDevices() {
|
|
const devices = this.zigbee.getDevices().map((device) => {
|
|
const definition = zigbeeHerdsmanConverters.findByDevice(device);
|
|
const resolved = this.zigbee.resolveEntity(device);
|
|
const endpoints = {};
|
|
for (const endpoint of device.endpoints) {
|
|
const data = {
|
|
bindings: [],
|
|
clusters: {
|
|
input: endpoint.getInputClusters().map((c) => c.name),
|
|
output: endpoint.getOutputClusters().map((c) => c.name),
|
|
},
|
|
};
|
|
|
|
for (const bind of endpoint.binds) {
|
|
let target;
|
|
|
|
if (bind.target.constructor.name === 'Endpoint') {
|
|
target = {
|
|
type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID,
|
|
};
|
|
} else {
|
|
target = {type: 'group', id: bind.target.groupID};
|
|
}
|
|
|
|
data.bindings.push({cluster: bind.cluster.name, target});
|
|
}
|
|
|
|
endpoints[endpoint.ID] = data;
|
|
}
|
|
|
|
return {
|
|
ieee_address: device.ieeeAddr,
|
|
type: device.type,
|
|
network_address: device.networkAddress,
|
|
supported: !!definition,
|
|
friendly_name: resolved.name,
|
|
definition: this.getDefinitionPayload(definition),
|
|
power_source: device.powerSource,
|
|
software_build_id: device.softwareBuildID,
|
|
date_code: device.dateCode,
|
|
interviewing: device.interviewing,
|
|
interview_completed: device.interviewCompleted,
|
|
endpoints,
|
|
};
|
|
});
|
|
|
|
await this.mqtt.publish('bridge/devices', stringify(devices), {retain: true, qos: 0});
|
|
}
|
|
|
|
async publishGroups() {
|
|
const groups = this.zigbee.getGroups().map((group) => {
|
|
const resolved = this.zigbee.resolveEntity(group);
|
|
return {
|
|
id: group.groupID,
|
|
friendly_name: resolved.name,
|
|
members: group.members.map((m) => {
|
|
return {
|
|
ieee_address: m.deviceIeeeAddress,
|
|
endpoint: m.ID,
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
await this.mqtt.publish('bridge/groups', stringify(groups), {retain: true, qos: 0});
|
|
}
|
|
|
|
getDefinitionPayload(definition) {
|
|
if (definition) {
|
|
return {
|
|
model: definition.model,
|
|
vendor: definition.vendor,
|
|
description: definition.description,
|
|
[definition.hasOwnProperty('exposes') ? 'exposes' : 'supports']:
|
|
definition.hasOwnProperty('exposes') ? definition.exposes : definition.supports,
|
|
};
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = Bridge;
|