feat: Typing for MQTT API (#25098)

This commit is contained in:
Nerivec
2024-12-07 20:21:30 +00:00
committed by GitHub
parent d891320314
commit 1a9c79bb2f
18 changed files with 1089 additions and 292 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
import type {IClientPublishOptions} from 'mqtt';
import type * as SdNotify from 'sd-notify';
import type {Zigbee2MQTTAPI} from './types/api';
import assert from 'assert';
import bind from 'bind-decorator';
@@ -253,7 +255,7 @@ export class Controller {
}
@bind async publishEntityState(entity: Group | Device, payload: KeyValue, stateChangeReason?: StateChangeReason): Promise<void> {
let message = {...payload};
let message: Zigbee2MQTTAPI['{friendlyName}'] = {...payload};
// Update state cache with new state.
const newState = this.state.set(entity, payload, stateChangeReason);
+4 -2
View File
@@ -1,3 +1,5 @@
import type {Zigbee2MQTTAPI} from 'lib/types/api';
import assert from 'assert';
import bind from 'bind-decorator';
@@ -188,9 +190,9 @@ export default class Availability extends Extension {
}
const topic = `${entity.name}/availability`;
const payload = JSON.stringify({state: available ? 'online' : 'offline'});
const payload: Zigbee2MQTTAPI['{friendlyName}/availability'] = {state: available ? 'online' : 'offline'};
this.availabilityCache[entity.ID] = available;
await this.mqtt.publish(topic, payload, {retain: true, qos: 1});
await this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 1});
if (!skipGroups && entity.isDevice()) {
for (const group of this.zigbee.groupsIterator()) {
+15 -17
View File
@@ -1,3 +1,5 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
import assert from 'assert';
import bind from 'bind-decorator';
@@ -207,15 +209,6 @@ interface ParsedMQTTMessage {
resolvedBindTarget?: number | zh.Endpoint | zh.Group;
}
interface DataMessage {
from: ParsedMQTTMessage['sourceKey'];
from_endpoint?: ParsedMQTTMessage['sourceEndpointKey'];
to: ParsedMQTTMessage['targetKey'];
to_endpoint: ParsedMQTTMessage['targetEndpointKey'];
clusters: ParsedMQTTMessage['clusters'];
skip_disable_reporting?: ParsedMQTTMessage['skipDisableReporting'];
}
export default class Bind extends Extension {
private pollDebouncers: {[s: string]: () => void} = {};
@@ -231,7 +224,7 @@ export default class Bind extends Extension {
if (data.topic.match(TOPIC_REGEX)) {
const type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
let skipDisableReporting = false;
const message: DataMessage = JSON.parse(data.message);
const message = JSON.parse(data.message) as Zigbee2MQTTAPI['bridge/request/device/bind'];
if (typeof message !== 'object' || message.from == undefined || message.to == undefined) {
return [message, {type, skipDisableReporting}, `Invalid payload`];
@@ -388,10 +381,10 @@ export default class Bind extends Extension {
return;
}
const responseData: KeyValue = {
from: sourceKey,
from_endpoint: sourceEndpointKey,
to: targetKey,
const responseData: Zigbee2MQTTAPI['bridge/response/device/bind'] | Zigbee2MQTTAPI['bridge/response/device/unbind'] = {
from: sourceKey!, // valid with assert above on `resolvedSource`
from_endpoint: sourceEndpointKey!, // valid with assert above on `resolvedSourceEndpoint`
to: targetKey!, // valid with assert above on `resolvedTarget`
to_endpoint: targetEndpointKey,
clusters: successfulClusters,
failed: failedClusters,
@@ -412,9 +405,14 @@ export default class Bind extends Extension {
this.eventBus.emitDevicesChanged();
}
private async publishResponse(type: ParsedMQTTMessage['type'], request: KeyValue, data: KeyValue, error?: string): Promise<void> {
const response = stringify(utils.getResponse(request, data, error));
await this.mqtt.publish(`bridge/response/device/${type}`, response);
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
type: ParsedMQTTMessage['type'],
request: KeyValue,
data: Zigbee2MQTTAPI[T],
error?: string,
): Promise<void> {
const response = utils.getResponse(request, data, error);
await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response));
if (error) {
logger.error(error);
+137 -125
View File
@@ -1,3 +1,5 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTDevice, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
import fs from 'fs';
import bind from 'bind-decorator';
@@ -9,7 +11,6 @@ import Transport from 'winston-transport';
import * as zhc from 'zigbee-herdsman-converters';
import {Clusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/cluster';
import {ClusterDefinition, ClusterName, CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import Device from '../model/device';
import Group from '../model/group';
@@ -19,59 +20,41 @@ import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
type DefinitionPayload = {
model: string;
vendor: string;
description: string;
exposes: zhc.Expose[];
supports_ota: boolean;
icon: string;
options: zhc.Option[];
};
const REQUEST_REGEX = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
export default class Bridge extends Extension {
// @ts-expect-error initialized in `start`
private zigbee2mqttVersion: {commitHash?: string; version: string};
// @ts-expect-error initialized in `start`
private zigbeeHerdsmanVersion: {version: string};
// @ts-expect-error initialized in `start`
private zigbeeHerdsmanConvertersVersion: {version: string};
// @ts-expect-error initialized in `start`
private coordinatorVersion: zh.CoordinatorVersion;
private zigbee2mqttVersion!: {commitHash?: string; version: string};
private zigbeeHerdsmanVersion!: {version: string};
private zigbeeHerdsmanConvertersVersion!: {version: string};
private coordinatorVersion!: zh.CoordinatorVersion;
private restartRequired = false;
private lastJoinedDeviceIeeeAddr?: string;
private lastBridgeLoggingPayload?: string;
// @ts-expect-error initialized in `start`
private logTransport: winston.transport;
// @ts-expect-error initialized in `start`
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<MQTTResponse>};
private logTransport!: winston.transport;
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<Zigbee2MQTTResponse<Zigbee2MQTTResponseEndpoints>>} = {
'device/options': this.deviceOptions,
'device/configure_reporting': this.deviceConfigureReporting,
'device/remove': this.deviceRemove,
'device/interview': this.deviceInterview,
'device/generate_external_definition': this.deviceGenerateExternalDefinition,
'device/rename': this.deviceRename,
'group/add': this.groupAdd,
'group/options': this.groupOptions,
'group/remove': this.groupRemove,
'group/rename': this.groupRename,
permit_join: this.permitJoin,
restart: this.restart,
backup: this.backup,
'touchlink/factory_reset': this.touchlinkFactoryReset,
'touchlink/identify': this.touchlinkIdentify,
'install_code/add': this.installCodeAdd,
'touchlink/scan': this.touchlinkScan,
health_check: this.healthCheck,
coordinator_check: this.coordinatorCheck,
options: this.bridgeOptions,
};
override async start(): Promise<void> {
this.requestLookup = {
'device/options': this.deviceOptions,
'device/configure_reporting': this.deviceConfigureReporting,
'device/remove': this.deviceRemove,
'device/interview': this.deviceInterview,
'device/generate_external_definition': this.deviceGenerateExternalDefinition,
'device/rename': this.deviceRename,
'group/add': this.groupAdd,
'group/options': this.groupOptions,
'group/remove': this.groupRemove,
'group/rename': this.groupRename,
permit_join: this.permitJoin,
restart: this.restart,
backup: this.backup,
'touchlink/factory_reset': this.touchlinkFactoryReset,
'touchlink/identify': this.touchlinkIdentify,
'install_code/add': this.installCodeAdd,
'touchlink/scan': this.touchlinkScan,
health_check: this.healthCheck,
coordinator_check: this.coordinatorCheck,
options: this.bridgeOptions,
};
const debugToMQTTFrontend = settings.get().advanced.log_debug_to_mqtt_frontend;
const baseTopic = settings.get().mqtt.base_topic;
@@ -135,35 +118,62 @@ export default class Bridge extends Extension {
});
// Zigbee events
const publishEvent = async (type: string, data: KeyValue): Promise<void> =>
await this.mqtt.publish('bridge/event', stringify({type, data}), {retain: false, qos: 0});
this.eventBus.onDeviceJoined(this, async (data) => {
this.lastJoinedDeviceIeeeAddr = data.device.ieeeAddr;
await this.publishDevices();
await publishEvent('device_joined', {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr});
const payload: Zigbee2MQTTAPI['bridge/event'] = {
type: 'device_joined',
data: {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr},
};
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
});
this.eventBus.onDeviceLeave(this, async (data) => {
await this.publishDevices();
await this.publishDefinitions();
await publishEvent('device_leave', {ieee_address: data.ieeeAddr, friendly_name: data.name});
const payload: Zigbee2MQTTAPI['bridge/event'] = {type: 'device_leave', data: {ieee_address: data.ieeeAddr, friendly_name: data.name}};
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
});
this.eventBus.onDeviceNetworkAddressChanged(this, async () => {
await this.publishDevices();
});
this.eventBus.onDeviceInterview(this, async (data) => {
await this.publishDevices();
const payload: KeyValue = {friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr};
let payload: Zigbee2MQTTAPI['bridge/event'];
if (data.status === 'successful') {
payload.supported = data.device.isSupported;
payload.definition = this.getDefinitionPayload(data.device);
payload = {
type: 'device_interview',
data: {
friendly_name: data.device.name,
status: data.status,
ieee_address: data.device.ieeeAddr,
supported: data.device.isSupported,
definition: this.getDefinitionPayload(data.device),
},
};
} else {
payload = {
type: 'device_interview',
data: {friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr},
};
}
await publishEvent('device_interview', payload);
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
});
this.eventBus.onDeviceAnnounce(this, async (data) => {
await this.publishDevices();
await publishEvent('device_announce', {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr});
const payload: Zigbee2MQTTAPI['bridge/event'] = {
type: 'device_announce',
data: {friendly_name: data.device.name, ieee_address: data.device.ieeeAddr},
};
await this.mqtt.publish('bridge/event', stringify(payload), {retain: false, qos: 0});
});
await this.publishInfo();
@@ -180,7 +190,7 @@ export default class Bridge extends Extension {
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const match = data.topic.match(requestRegex);
const match = data.topic.match(REQUEST_REGEX);
if (!match) {
return;
@@ -207,15 +217,15 @@ export default class Bridge extends Extension {
* Requests
*/
@bind async deviceOptions(message: KeyValue | string): Promise<MQTTResponse> {
@bind async deviceOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/device/options'>> {
return await this.changeEntityOptions('device', message);
}
@bind async groupOptions(message: KeyValue | string): Promise<MQTTResponse> {
@bind async groupOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/group/options'>> {
return await this.changeEntityOptions('group', message);
}
@bind async bridgeOptions(message: KeyValue | string): Promise<MQTTResponse> {
@bind async bridgeOptions(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/options'>> {
if (typeof message !== 'object' || typeof message.options !== 'object') {
throw new Error(`Invalid payload`);
}
@@ -246,19 +256,19 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {restart_required: this.restartRequired});
}
@bind async deviceRemove(message: string | KeyValue): Promise<MQTTResponse> {
@bind async deviceRemove(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/remove'>> {
return await this.removeEntity('device', message);
}
@bind async groupRemove(message: string | KeyValue): Promise<MQTTResponse> {
@bind async groupRemove(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/group/remove'>> {
return await this.removeEntity('group', message);
}
@bind async healthCheck(message: string | KeyValue): Promise<MQTTResponse> {
@bind async healthCheck(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/health_check'>> {
return utils.getResponse(message, {healthy: true});
}
@bind async coordinatorCheck(message: string | KeyValue): Promise<MQTTResponse> {
@bind async coordinatorCheck(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/coordinator_check'>> {
const result = await this.zigbee.coordinatorCheck();
const missingRouters = result.missingRouters.map((d) => {
return {ieee_address: d.ieeeAddr, friendly_name: d.name};
@@ -266,7 +276,7 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {missing_routers: missingRouters});
}
@bind async groupAdd(message: string | KeyValue): Promise<MQTTResponse> {
@bind async groupAdd(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/group/add'>> {
if (typeof message === 'object' && message.friendly_name === undefined) {
throw new Error(`Invalid payload`);
}
@@ -279,22 +289,22 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID});
}
@bind async deviceRename(message: string | KeyValue): Promise<MQTTResponse> {
@bind async deviceRename(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/rename'>> {
return await this.renameEntity('device', message);
}
@bind async groupRename(message: string | KeyValue): Promise<MQTTResponse> {
@bind async groupRename(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/group/rename'>> {
return await this.renameEntity('group', message);
}
@bind async restart(message: string | KeyValue): Promise<MQTTResponse> {
@bind async restart(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/restart'>> {
// Wait 500 ms before restarting so response can be send.
setTimeout(this.restartCallback, 500);
logger.info('Restarting Zigbee2MQTT');
return utils.getResponse(message, {});
}
@bind async backup(message: string | KeyValue): Promise<MQTTResponse> {
@bind async backup(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/backup'>> {
await this.zigbee.backup();
const dataPath = data.getPath();
const files = utils
@@ -307,7 +317,7 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {zip: base64Zip});
}
@bind async installCodeAdd(message: KeyValue | string): Promise<MQTTResponse> {
@bind async installCodeAdd(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/install_code/add'>> {
if (typeof message === 'object' && message.value === undefined) {
throw new Error('Invalid payload');
}
@@ -318,7 +328,7 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {value});
}
@bind async permitJoin(message: KeyValue | string): Promise<MQTTResponse> {
@bind async permitJoin(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/permit_join'>> {
let time: number | undefined;
let device: Device | undefined;
@@ -353,7 +363,7 @@ export default class Bridge extends Extension {
return utils.getResponse(message, response);
}
@bind async touchlinkIdentify(message: KeyValue | string): Promise<MQTTResponse> {
@bind async touchlinkIdentify(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/touchlink/identify'>> {
if (typeof message !== 'object' || message.ieee_address === undefined || message.channel === undefined) {
throw new Error('Invalid payload');
}
@@ -363,14 +373,18 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel});
}
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<MQTTResponse> {
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/touchlink/factory_reset'>> {
let result = false;
const payload: {ieee_address?: string; channel?: number} = {};
let payload: Zigbee2MQTTAPI['bridge/response/touchlink/factory_reset'] = {};
if (typeof message === 'object' && message.ieee_address !== undefined && message.channel !== undefined) {
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;
payload = {
ieee_address: message.ieee_address,
channel: message.channel,
};
} else {
logger.info('Start Touchlink factory reset of first found device');
result = await this.zigbee.touchlinkFactoryResetFirst();
@@ -385,7 +399,7 @@ export default class Bridge extends Extension {
}
}
@bind async touchlinkScan(message: KeyValue | string): Promise<MQTTResponse> {
@bind async touchlinkScan(message: KeyValue | string): Promise<Zigbee2MQTTResponse<'bridge/response/touchlink/scan'>> {
logger.info('Start Touchlink scan');
const result = await this.zigbee.touchlinkScan();
const found = result.map((r) => {
@@ -399,7 +413,10 @@ export default class Bridge extends Extension {
* Utils
*/
async changeEntityOptions(entityType: 'device' | 'group', message: KeyValue | string): Promise<MQTTResponse> {
async changeEntityOptions<T extends 'device' | 'group'>(
entityType: T,
message: KeyValue | string,
): Promise<Zigbee2MQTTResponse<T extends 'device' ? 'bridge/response/device/options' : 'bridge/response/group/options'>> {
if (typeof message !== 'object' || message.id === undefined || message.options === undefined) {
throw new Error(`Invalid payload`);
}
@@ -427,7 +444,7 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired});
}
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<MQTTResponse> {
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/configure_reporting'>> {
if (
typeof message !== 'object' ||
message.id === undefined ||
@@ -479,7 +496,7 @@ export default class Bridge extends Extension {
});
}
@bind async deviceInterview(message: string | KeyValue): Promise<MQTTResponse> {
@bind async deviceInterview(message: string | KeyValue): Promise<Zigbee2MQTTResponse<'bridge/response/device/interview'>> {
if (typeof message !== 'object' || message.id === undefined) {
throw new Error(`Invalid payload`);
}
@@ -502,7 +519,9 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {id: message.id});
}
@bind async deviceGenerateExternalDefinition(message: string | KeyValue): Promise<MQTTResponse> {
@bind async deviceGenerateExternalDefinition(
message: string | KeyValue,
): Promise<Zigbee2MQTTResponse<'bridge/response/device/generate_external_definition'>> {
if (typeof message !== 'object' || message.id === undefined) {
throw new Error(`Invalid payload`);
}
@@ -513,7 +532,10 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {id: message.id, source});
}
async renameEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
async renameEntity<T extends 'device' | 'group'>(
entityType: T,
message: string | KeyValue,
): Promise<Zigbee2MQTTResponse<T extends 'device' ? 'bridge/response/device/rename' : 'bridge/response/group/rename'>> {
const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true;
if (typeof message !== 'object' || (message.from === undefined && !deviceAndHasLast) || message.to === undefined) {
@@ -550,7 +572,10 @@ export default class Bridge extends Extension {
return utils.getResponse(message, {from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename});
}
async removeEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
async removeEntity<T extends 'device' | 'group'>(
entityType: T,
message: string | KeyValue,
): Promise<Zigbee2MQTTResponse<T extends 'device' ? 'bridge/response/device/remove' : 'bridge/response/group/remove'>> {
const ID = typeof message === 'object' ? message.id : message.trim();
const entity = this.getEntity(entityType, ID);
const friendlyName = entity.name;
@@ -583,25 +608,17 @@ export default class Bridge extends Extension {
} else {
await entity.zh.removeFromNetwork();
}
this.eventBus.emitEntityRemoved({id: entityID, name, type: 'device'});
settings.removeDevice(entityID as string);
} else {
if (force) {
entity.zh.removeFromDatabase();
} else {
await entity.zh.removeFromNetwork();
}
}
// Fire event
if (entity instanceof Device) {
this.eventBus.emitEntityRemoved({id: entityID, name, type: 'device'});
} else {
this.eventBus.emitEntityRemoved({id: entityID, name, type: 'group'});
}
// Remove from configuration.yaml
if (entity instanceof Device) {
settings.removeDevice(entityID as string);
} else {
settings.removeGroup(entityID);
}
@@ -618,10 +635,20 @@ export default class Bridge extends Extension {
await this.publishDevices();
// Refresh Cluster definition
await this.publishDefinitions();
return utils.getResponse(message, {id: ID, block, force});
const responseData: Zigbee2MQTTAPI['bridge/response/device/remove'] = {id: ID, block, force};
return utils.getResponse(message, responseData);
} else {
await this.publishGroups();
return utils.getResponse(message, {id: ID, force: force});
const responseData: Zigbee2MQTTAPI['bridge/response/group/remove'] = {id: ID, force};
return utils.getResponse(
message,
// @ts-expect-error typing infer does not work here
responseData,
);
}
} catch (error) {
throw new Error(`Failed to remove ${entityType} '${friendlyName}'${blockForceLog} (${error})`);
@@ -649,7 +676,8 @@ export default class Bridge extends Extension {
delete config.frontend.auth_token;
}
const payload = {
const networkParams = await this.zigbee.getNetworkParameters();
const payload: Zigbee2MQTTAPI['bridge/info'] = {
version: this.zigbee2mqttVersion.version,
commit: this.zigbee2mqttVersion.commitHash,
zigbee_herdsman_converters: this.zigbeeHerdsmanConvertersVersion,
@@ -658,7 +686,11 @@ export default class Bridge extends Extension {
ieee_address: this.zigbee.firstCoordinatorEndpoint().getDevice().ieeeAddr,
...this.coordinatorVersion,
},
network: utils.toSnakeCaseObject(await this.zigbee.getNetworkParameters()),
network: {
pan_id: networkParams.panID,
extended_pan_id: networkParams.extendedPanID,
channel: networkParams.channel,
},
log_level: logger.getLevel(),
permit_join_timeout: this.zigbee.getPermitJoinTimeout(),
restart_required: this.restartRequired,
@@ -670,27 +702,13 @@ export default class Bridge extends Extension {
}
async publishDevices(): Promise<void> {
interface Data {
bindings: {cluster: string; target: {type: string; endpoint?: number; ieee_address?: string; id?: number}}[];
configured_reportings: {
cluster: string;
attribute: string | number;
minimum_report_interval: number;
maximum_report_interval: number;
reportable_change: number;
}[];
clusters: {input: string[]; output: string[]};
scenes: Scene[];
}
// XXX: definition<>DefinitionPayload don't match to use `Device[]` type here
const devices: KeyValue[] = [];
const devices: Zigbee2MQTTAPI['bridge/devices'] = [];
for (const device of this.zigbee.devicesIterator()) {
const endpoints: {[s: number]: Data} = {};
const endpoints: (typeof devices)[number]['endpoints'] = {};
for (const endpoint of device.zh.endpoints) {
const data: Data = {
const data: (typeof endpoints)[keyof typeof endpoints] = {
scenes: utils.getScenes(endpoint),
bindings: [],
configured_reportings: [],
@@ -744,8 +762,7 @@ export default class Bridge extends Extension {
}
async publishGroups(): Promise<void> {
// XXX: id<>ID can't use `Group[]` type
const groups: KeyValue[] = [];
const groups: Zigbee2MQTTAPI['bridge/groups'] = [];
for (const group of this.zigbee.groupsIterator()) {
const members = [];
@@ -767,12 +784,7 @@ export default class Bridge extends Extension {
}
async publishDefinitions(): Promise<void> {
interface ClusterDefinitionPayload {
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
custom_clusters: {[key: string]: CustomClusters};
}
const data: ClusterDefinitionPayload = {
const data: Zigbee2MQTTAPI['bridge/definition'] = {
clusters: Clusters,
custom_clusters: {},
};
@@ -784,7 +796,7 @@ export default class Bridge extends Extension {
await this.mqtt.publish('bridge/definitions', stringify(data), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
}
getDefinitionPayload(device: Device): DefinitionPayload | undefined {
getDefinitionPayload(device: Device): Zigbee2MQTTDevice['definition'] | undefined {
if (!device.definition) {
return undefined;
}
@@ -800,7 +812,7 @@ export default class Bridge extends Extension {
icon = icon.replace('${model}', utils.sanitizeImageParameter(device.definition.model));
}
const payload: DefinitionPayload = {
const payload: Zigbee2MQTTDevice['definition'] = {
model: device.definition.model,
vendor: device.definition.vendor,
description: device.definition.description,
+20 -12
View File
@@ -1,3 +1,5 @@
import type {Zigbee2MQTTAPI} from 'lib/types/api';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
@@ -29,24 +31,30 @@ export default class Configure extends Extension {
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
if (data.topic === this.topic) {
const message = utils.parseJSON(data.message, data.message);
const ID = typeof message === 'object' && message.id !== undefined ? message.id : message;
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI['bridge/request/device/configure'];
const ID = typeof message === 'object' ? message.id : message;
let error: string | undefined;
const device = this.zigbee.resolveEntity(ID);
if (!device || !(device instanceof Device)) {
error = `Device '${ID}' does not exist`;
} else if (!device.definition || !device.definition.configure) {
error = `Device '${device.name}' cannot be configured`;
if (ID === undefined) {
error = `Invalid payload`;
} else {
try {
await this.configure(device, 'mqtt_message', true, true);
} catch (e) {
error = `Failed to configure (${(e as Error).message})`;
const device = this.zigbee.resolveEntity(ID);
if (!device || !(device instanceof Device)) {
error = `Device '${ID}' does not exist`;
} else if (!device.definition || !device.definition.configure) {
error = `Device '${device.name}' cannot be configured`;
} else {
try {
await this.configure(device, 'mqtt_message', true, true);
} catch (e) {
error = `Failed to configure (${(e as Error).message})`;
}
}
}
const response = utils.getResponse(message, {id: ID}, error);
const response = utils.getResponse<'bridge/response/device/configure'>(message, {id: ID}, error);
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
}
}
+31 -10
View File
@@ -1,3 +1,5 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse} from 'lib/types/api';
import fs from 'fs';
import path from 'path';
import {Context, runInNewContext} from 'vm';
@@ -11,12 +13,9 @@ import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
export default abstract class ExternalJSExtension<M> extends Extension {
private requestLookup: {[s: string]: (message: KeyValue) => Promise<MQTTResponse>} = {
save: this.save,
remove: this.remove,
};
const SUPPORTED_OPERATIONS = ['save', 'remove'];
export default abstract class ExternalJSExtension<M> extends Extension {
protected mqttTopic: string;
protected requestRegex: RegExp;
protected basePath: string;
@@ -75,11 +74,21 @@ export default abstract class ExternalJSExtension<M> extends Extension {
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const match = data.topic.match(this.requestRegex);
if (match && this.requestLookup[match[1].toLowerCase()]) {
const message = utils.parseJSON(data.message, data.message) as KeyValue;
if (match && SUPPORTED_OPERATIONS.includes(match[1].toLowerCase())) {
const message = utils.parseJSON(data.message, data.message);
try {
const response = await this.requestLookup[match[1].toLowerCase()](message);
let response;
if (match[1].toLowerCase() === 'save') {
response = await this.save(
message as Zigbee2MQTTAPI['bridge/request/converter/save'] | Zigbee2MQTTAPI['bridge/request/extension/save'],
);
} else {
response = await this.remove(
message as Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'],
);
}
await this.mqtt.publish(`bridge/response/${this.mqttTopic}/${match[1]}`, stringify(response));
} catch (error) {
@@ -96,7 +105,13 @@ export default abstract class ExternalJSExtension<M> extends Extension {
protected abstract loadJS(name: string, module: M): Promise<void>;
@bind private async remove(message: KeyValue): Promise<MQTTResponse> {
@bind private async remove(
message: Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'],
): Promise<Zigbee2MQTTResponse<'bridge/response/converter/remove' | 'bridge/response/extension/remove'>> {
if (!message.name) {
return utils.getResponse(message, {}, `Invalid payload`);
}
const {name} = message;
const toBeRemoved = this.getFilePath(name);
@@ -113,7 +128,13 @@ export default abstract class ExternalJSExtension<M> extends Extension {
}
}
@bind private async save(message: KeyValue): Promise<MQTTResponse> {
@bind private async save(
message: Zigbee2MQTTAPI['bridge/request/converter/save'] | Zigbee2MQTTAPI['bridge/request/extension/save'],
): Promise<Zigbee2MQTTResponse<'bridge/response/converter/save' | 'bridge/response/extension/save'>> {
if (!message.name || !message.code) {
return utils.getResponse(message, {}, `Invalid payload`);
}
const {name, code} = message;
try {
+25 -19
View File
@@ -1,3 +1,5 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponseEndpoints} from 'lib/types/api';
import assert from 'assert';
import bind from 'bind-decorator';
@@ -39,13 +41,6 @@ interface ParsedMQTTMessage {
skipDisableReporting: boolean;
}
interface DataMessage {
device: ParsedMQTTMessage['deviceKey'];
group: ParsedMQTTMessage['groupKey'];
endpoint: ParsedMQTTMessage['endpointKey'];
skip_disable_reporting?: ParsedMQTTMessage['skipDisableReporting'];
}
export default class Groups extends Extension {
private lastOptimisticState: {[s: string]: KeyValue} = {};
@@ -194,7 +189,7 @@ export default class Groups extends Extension {
let resolvedGroup;
let groupKey;
let skipDisableReporting = false;
const message: DataMessage = JSON.parse(data.message);
const message = JSON.parse(data.message) as Zigbee2MQTTAPI['bridge/request/group/members/add'];
if (typeof message !== 'object' || message.device == undefined) {
return [message, {type, skipDisableReporting}, 'Invalid payload'];
@@ -273,11 +268,21 @@ export default class Groups extends Extension {
logger.info(`Adding '${resolvedDevice.name}' to '${resolvedGroup.name}'`);
await resolvedEndpoint.addToGroup(resolvedGroup.zh);
changedGroups.push(resolvedGroup);
await this.publishResponse<'bridge/response/group/members/add'>(parsed.type, raw, {
device: deviceKey!, // valid from resolved asserts
endpoint: endpointKey!, // valid from resolved asserts
group: groupKey!, // valid from resolved asserts
});
} else if (type === 'remove') {
assert(resolvedGroup, '`resolvedGroup` is missing');
logger.info(`Removing '${resolvedDevice.name}' from '${resolvedGroup.name}'`);
await resolvedEndpoint.removeFromGroup(resolvedGroup.zh);
changedGroups.push(resolvedGroup);
await this.publishResponse<'bridge/response/group/members/remove'>(parsed.type, raw, {
device: deviceKey!, // valid from resolved asserts
endpoint: endpointKey!, // valid from resolved asserts
group: groupKey!, // valid from resolved asserts
});
} else {
// remove_all
logger.info(`Removing '${resolvedDevice.name}' from all groups`);
@@ -287,6 +292,10 @@ export default class Groups extends Extension {
}
await resolvedEndpoint.removeFromAllGroups();
await this.publishResponse<'bridge/response/group/members/remove_all'>(parsed.type, raw, {
device: deviceKey!, // valid from resolved asserts
endpoint: endpointKey!, // valid from resolved asserts
});
}
} catch (e) {
const errorMsg = `Failed to ${type} from group (${(e as Error).message})`;
@@ -295,22 +304,19 @@ export default class Groups extends Extension {
return;
}
const responseData: KeyValue = {device: deviceKey, endpoint: endpointKey};
if (groupKey) {
responseData.group = groupKey;
}
await this.publishResponse(parsed.type, raw, responseData);
for (const group of changedGroups) {
this.eventBus.emitGroupMembersChanged({group, action: type, endpoint: resolvedEndpoint, skipDisableReporting});
}
}
private async publishResponse(type: ParsedMQTTMessage['type'], request: KeyValue, data: KeyValue, error?: string): Promise<void> {
const response = stringify(utils.getResponse(request, data, error));
await this.mqtt.publish(`bridge/response/group/members/${type}`, response);
private async publishResponse<T extends Zigbee2MQTTResponseEndpoints>(
type: ParsedMQTTMessage['type'],
request: KeyValue,
data: Zigbee2MQTTAPI[T],
error?: string,
): Promise<void> {
const response = utils.getResponse(request, data, error);
await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(response));
if (error) {
logger.error(error);
+32 -43
View File
@@ -1,3 +1,5 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTNetworkMap} from 'lib/types/api';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
@@ -6,44 +8,13 @@ import * as settings from '../util/settings';
import utils from '../util/utils';
import Extension from './extension';
interface Link {
source: {ieeeAddr: string; networkAddress: number};
target: {ieeeAddr: string; networkAddress: number};
linkquality: number;
depth: number;
routes: zh.RoutingTableEntry[];
sourceIeeeAddr: string;
targetIeeeAddr: string;
sourceNwkAddr: number;
lqi: number;
relationship: number;
}
interface Topology {
nodes: {
ieeeAddr: string;
friendlyName: string;
type: string;
networkAddress: number;
manufacturerName: string | undefined;
modelID: string | undefined;
failed: string[];
lastSeen: number | undefined;
definition?: {model: string; vendor: string; supports: string; description: string};
}[];
links: Link[];
}
const SUPPORTED_FORMATS = ['raw', 'graphviz', 'plantuml'];
/**
* This extension creates a network map
*/
export default class NetworkMap extends Extension {
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
private supportedFormats: {[s: string]: (topology: Topology) => KeyValue | string} = {
raw: this.raw,
graphviz: this.graphviz,
plantuml: this.plantuml,
};
override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
@@ -51,28 +22,46 @@ export default class NetworkMap extends Extension {
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
if (data.topic === this.topic) {
const message = utils.parseJSON(data.message, data.message);
const message = utils.parseJSON(data.message, data.message) as Zigbee2MQTTAPI['bridge/request/networkmap'];
try {
const type = typeof message === 'object' ? message.type : message;
if (this.supportedFormats[type] === undefined) {
throw new Error(`Type '${type}' not supported, allowed are: ${Object.keys(this.supportedFormats)}`);
if (!SUPPORTED_FORMATS.includes(type)) {
throw new Error(`Type '${type}' not supported, allowed are: ${SUPPORTED_FORMATS.join(',')}`);
}
const routes = typeof message === 'object' && message.routes;
const topology = await this.networkScan(routes);
const value = this.supportedFormats[type](topology);
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {routes, type, value})));
let responseData: Zigbee2MQTTAPI['bridge/response/networkmap'];
switch (type) {
case 'raw': {
responseData = {type, routes, value: this.raw(topology)};
break;
}
case 'graphviz': {
responseData = {type, routes, value: this.graphviz(topology)};
break;
}
case 'plantuml': {
responseData = {type, routes, value: this.plantuml(topology)};
break;
}
}
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, responseData)));
} catch (error) {
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {}, (error as Error).message)));
}
}
}
@bind raw(topology: Topology): KeyValue {
raw(topology: Zigbee2MQTTNetworkMap): Zigbee2MQTTNetworkMap {
return topology;
}
@bind graphviz(topology: Topology): string {
graphviz(topology: Zigbee2MQTTNetworkMap): string {
const colors = settings.get().map_options.graphviz.colors;
let text = 'digraph G {\nnode[shape=record];\n';
@@ -138,7 +127,7 @@ export default class NetworkMap extends Extension {
return text.replace(/\0/g, '');
}
@bind plantuml(topology: Topology): string {
plantuml(topology: Zigbee2MQTTNetworkMap): string {
const text = [];
text.push(`' paste into: https://www.planttext.com/`);
@@ -193,7 +182,7 @@ export default class NetworkMap extends Extension {
return text.join(`\n`);
}
async networkScan(includeRoutes: boolean): Promise<Topology> {
async networkScan(includeRoutes: boolean): Promise<Zigbee2MQTTNetworkMap> {
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
const lqis: Map<Device, zh.LQI> = new Map();
const routingTables: Map<Device, zh.RoutingTable> = new Map();
@@ -244,7 +233,7 @@ export default class NetworkMap extends Extension {
logger.info(`Network scan finished`);
const topology: Topology = {nodes: [], links: []};
const topology: Zigbee2MQTTNetworkMap = {nodes: [], links: []};
// XXX: display GP/disabled devices in the map, better feedback than just hiding them?
for (const device of this.zigbee.devicesIterator((d) => d.type !== 'GreenPower')) {
@@ -300,7 +289,7 @@ export default class NetworkMap extends Extension {
}
}
const link: Link = {
const link: Zigbee2MQTTNetworkMap['links'][number] = {
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress},
linkquality: neighbor.linkquality,
+32 -12
View File
@@ -1,3 +1,4 @@
import type {Zigbee2MQTTAPI} from 'lib/types/api';
import type {Ota} from 'zigbee-herdsman-converters';
import assert from 'assert';
@@ -169,12 +170,15 @@ export default class OTAUpdate extends Extension {
return;
}
const message = utils.parseJSON(data.message, data.message);
const message = utils.parseJSON(data.message, data.message) as
| Zigbee2MQTTAPI['bridge/request/device/ota_update/check']
| Zigbee2MQTTAPI['bridge/request/device/ota_update/check/downgrade']
| Zigbee2MQTTAPI['bridge/request/device/ota_update/update']
| Zigbee2MQTTAPI['bridge/request/device/ota_update/update/downgrade'];
const ID = (typeof message === 'object' && message['id'] !== undefined ? message.id : message) as string;
const device = this.zigbee.resolveEntity(ID);
const type = topicMatch[1];
const downgrade = Boolean(topicMatch[2]);
const responseData: {id: string; update_available?: boolean; from?: KeyValue | null; to?: KeyValue | null} = {id: ID};
let error: string | undefined;
let errorStack: string | undefined;
@@ -197,8 +201,14 @@ export default class OTAUpdate extends Extension {
logger.info(msg);
await this.publishEntityState(device, this.getEntityPublishPayload(device, availableResult));
this.lastChecked[device.ieeeAddr] = Date.now();
responseData.update_available = availableResult.available;
const response = utils.getResponse<'bridge/response/device/ota_update/check'>(message, {
id: ID,
update_available: availableResult.available,
});
await this.mqtt.publish(`bridge/response/device/ota_update/check`, stringify(response));
} catch (e) {
error = `Failed to check if update available for '${device.name}' (${(e as Error).message})`;
errorStack = (e as Error).stack;
@@ -209,9 +219,10 @@ export default class OTAUpdate extends Extension {
logger.info(msg);
try {
const from_ = await this.readSoftwareBuildIDAndDateCode(device, 'immediate');
const firmwareFrom = await this.readSoftwareBuildIDAndDateCode(device, 'immediate');
const fileVersion = await ota.update(device.zh, device.otaExtraMetas, downgrade, async (progress, remaining) => {
let msg = `Update of '${device.name}' at ${progress.toFixed(2)}%`;
if (remaining) {
msg += `, ≈ ${Math.round(remaining / 60)} minutes remaining`;
}
@@ -220,23 +231,32 @@ export default class OTAUpdate extends Extension {
await this.publishEntityState(device, this.getEntityPublishPayload(device, 'updating', progress, remaining ?? undefined));
});
logger.info(`Finished update of '${device.name}'`);
this.removeProgressAndRemainingFromState(device);
await this.publishEntityState(
device,
this.getEntityPublishPayload(device, {available: false, currentFileVersion: fileVersion, otaFileVersion: fileVersion}),
);
const to = await this.readSoftwareBuildIDAndDateCode(device);
const [fromS, toS] = [stringify(from_), stringify(to)];
logger.info(`Device '${device.name}' was updated from '${fromS}' to '${toS}'`);
responseData.from = from_ ? utils.toSnakeCaseObject(from_) : null;
responseData.to = to ? utils.toSnakeCaseObject(to) : null;
const firmwareTo = await this.readSoftwareBuildIDAndDateCode(device);
logger.info(() => `Device '${device.name}' was updated from '${stringify(firmwareFrom)}' to '${stringify(firmwareTo)}'`);
/**
* Re-configure after reading software build ID and date code, some devices use a
* custom attribute for this (e.g. Develco SMSZB-120)
*/
this.eventBus.emitReconfigure({device});
this.eventBus.emitDevicesChanged();
const response = utils.getResponse<'bridge/response/device/ota_update/update'>(message, {
id: ID,
from: firmwareFrom ? {software_build_id: firmwareFrom.softwareBuildID, date_code: firmwareFrom.dateCode} : undefined,
to: firmwareTo ? {software_build_id: firmwareTo.softwareBuildID, date_code: firmwareTo.dateCode} : undefined,
});
await this.mqtt.publish(`bridge/response/device/ota_update/update`, stringify(response));
} catch (e) {
logger.debug(`Update of '${device.name}' failed (${e})`);
error = `Update of '${device.name}' failed (${(e as Error).message})`;
@@ -250,10 +270,10 @@ export default class OTAUpdate extends Extension {
this.inProgress.delete(device.ieeeAddr);
}
const response = utils.getResponse(message, responseData, error);
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
if (error) {
const response = utils.getResponse(message, {}, error);
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
logger.error(error);
if (errorStack) {
+10 -2
View File
@@ -1,5 +1,7 @@
import type {IClientOptions, IClientPublishOptions, MqttClient} from 'mqtt';
import type {Zigbee2MQTTAPI} from './types/api';
import fs from 'fs';
import bind from 'bind-decorator';
@@ -119,7 +121,10 @@ export default class MQTT {
async disconnect(): Promise<void> {
clearTimeout(this.connectionTimer);
clearTimeout(this.republishRetainedTimer);
await this.publish('bridge/state', JSON.stringify({state: 'offline'}), {retain: true, qos: 0});
const stateData: Zigbee2MQTTAPI['bridge/state'] = {state: 'offline'};
await this.publish('bridge/state', JSON.stringify(stateData), {retain: true, qos: 0});
this.eventBus.removeListeners(this);
logger.info('Disconnecting from MQTT server');
await this.client?.endAsync();
@@ -135,7 +140,10 @@ export default class MQTT {
@bind private async onConnect(): Promise<void> {
logger.info('Connected to MQTT server');
await this.publish('bridge/state', JSON.stringify({state: 'online'}), {retain: true, qos: 0});
const stateData: Zigbee2MQTTAPI['bridge/state'] = {state: 'online'};
await this.publish('bridge/state', JSON.stringify(stateData), {retain: true, qos: 0});
await this.subscribe(`${settings.get().mqtt.base_topic}/#`);
}
+685
View File
@@ -0,0 +1,685 @@
import type * as zhc from 'zigbee-herdsman-converters';
import type {ClusterDefinition, ClusterName, CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import type {LogLevel, schemaJson} from '../util/settings';
export interface Zigbee2MQTTScene {
id: number;
name: string;
}
interface Zigbee2MQTTDeviceEndpoint {
bindings: Zigbee2MQTTDeviceEndpointBinding[];
configured_reportings: Zigbee2MQTTDeviceEndpointConfiguredReporting[];
clusters: {input: string[]; output: string[]};
scenes: Zigbee2MQTTScene[];
}
interface Zigbee2MQTTDeviceEndpointBinding {
cluster: string;
target: Zigbee2MQTTDeviceEndpointBindingTarget;
}
interface Zigbee2MQTTDeviceEndpointBindingTarget {
type: string;
endpoint?: number;
ieee_address?: string;
id?: number;
}
interface Zigbee2MQTTDeviceEndpointConfiguredReporting {
cluster: string;
attribute: string | number;
minimum_report_interval: number;
maximum_report_interval: number;
reportable_change: number;
}
interface Zigbee2MQTTDeviceDefinition {
model: string;
vendor: string;
description: string;
exposes: zhc.Expose[];
supports_ota: boolean;
options: zhc.Option[];
icon: string;
}
export interface Zigbee2MQTTDevice {
ieee_address: zh.Device['ieeeAddr'];
type: zh.Device['type'];
network_address: zh.Device['networkAddress'];
supported: boolean;
friendly_name: string;
disabled: boolean;
description: string | undefined;
definition: Zigbee2MQTTDeviceDefinition | undefined;
power_source: zh.Device['powerSource'];
software_build_id: zh.Device['softwareBuildID'];
date_code: zh.Device['dateCode'];
model_id: zh.Device['modelID'];
interviewing: zh.Device['interviewing'];
interview_completed: zh.Device['interviewCompleted'];
manufacturer: zh.Device['manufacturerName'];
endpoints: Record<number, Zigbee2MQTTDeviceEndpoint>;
}
export interface Zigbee2MQTTGroupMember {
ieee_address: zh.Device['ieeeAddr'];
endpoint: number;
}
export interface Zigbee2MQTTGroup {
id: number;
friendly_name: 'default_bind_group' | string;
description: string | undefined;
scenes: Zigbee2MQTTScene[];
members: Zigbee2MQTTGroupMember[];
}
export interface Zigbee2MQTTNetworkMap {
nodes: {
ieeeAddr: string;
friendlyName: string;
type: string;
networkAddress: number;
manufacturerName: string | undefined;
modelID: string | undefined;
failed: string[];
lastSeen: number | undefined;
definition?: {model: string; vendor: string; supports: string; description: string};
}[];
links: {
source: {ieeeAddr: string; networkAddress: number};
target: {ieeeAddr: string; networkAddress: number};
linkquality: number;
depth: number;
routes: {
destinationAddress: number;
status: string;
nextHop: number;
}[];
sourceIeeeAddr: string;
targetIeeeAddr: string;
sourceNwkAddr: number;
lqi: number;
relationship: number;
}[];
}
/**
* Zigbee2MQTT state/request/response API endpoints
*/
export interface Zigbee2MQTTAPI {
'bridge/logging': {
message: string;
level: LogLevel;
namespace: string;
};
'bridge/state': {
state: 'online' | 'offline';
};
'bridge/definition': {
clusters: Readonly<Record<ClusterName, Readonly<ClusterDefinition>>>;
custom_clusters: Record<string, CustomClusters>;
};
'bridge/event':
| {
type: 'device_leave' | 'device_joined' | 'device_announce';
data: {
friendly_name: string;
ieee_address: string;
};
}
| {
type: 'device_interview';
data:
| {
friendly_name: string;
ieee_address: string;
status: 'started' | 'failed';
}
| {
friendly_name: string;
ieee_address: string;
status: 'successful';
supported: boolean;
definition: Zigbee2MQTTDeviceDefinition | undefined;
};
};
'bridge/info': {
version: string;
commit: string | undefined;
zigbee_herdsman_converters: {version: string};
zigbee_herdsman: {version: string};
coordinator: {
ieee_address: string;
type: string;
meta: {
[s: string]: number | string;
};
};
network: {
pan_id: number;
extended_pan_id: number;
channel: number;
};
log_level: 'debug' | 'info' | 'warning' | 'error';
permit_join_timeout: number;
restart_required: boolean;
config: Settings;
config_schema: typeof schemaJson;
};
'bridge/devices': Zigbee2MQTTDevice[];
'bridge/groups': Zigbee2MQTTGroup[];
'bridge/request/permit_join':
| {
/** [0-254], 0 meaning disable */
time: number;
device?: string;
}
| `${number}`;
'bridge/response/permit_join': {
/** [0-254], 0 meaning disable */
time: number;
device?: string;
};
'bridge/request/health_check': '';
'bridge/response/health_check': {
/** XXX: currently always returns true */
healthy: boolean;
};
'bridge/request/coordinator_check': '';
'bridge/response/coordinator_check': {
missing_routers: {
ieee_address: string;
friendly_name: string;
}[];
};
'bridge/request/restart': '';
'bridge/response/restart': Record<string, never>;
'bridge/request/networkmap':
| {
type: 'raw' | 'graphviz' | 'plantuml';
routes: boolean;
}
| 'raw'
| 'graphviz'
| 'plantuml';
'bridge/response/networkmap':
| {
type: 'raw';
routes: boolean;
value: Zigbee2MQTTNetworkMap;
}
| {
type: 'graphviz' | 'plantuml';
routes: boolean;
value: string;
};
'bridge/request/extension/save': {
name: string;
code: string;
};
'bridge/response/extension/save': Record<string, never>;
'bridge/request/extension/remove': {
name: string;
};
'bridge/response/extension/remove': Record<string, never>;
'bridge/request/converter/save': {
name: string;
code: string;
};
'bridge/response/converter/save': Record<string, never>;
'bridge/request/converter/remove': {
name: string;
};
'bridge/response/converter/remove': Record<string, never>;
'bridge/request/backup': '';
'bridge/response/backup': {
/** base64 encoded ZIP archive */
zip: string;
};
'bridge/request/install_code/add': {
value: string;
};
'bridge/response/install_code/add': {
value: string;
};
/**
* Applied on-the-fly:
* - newSettings.homeassistant
* - newSettings.advanced?.log_level
* - newSettings.advanced?.log_namespaced_levels
* - newSettings.advanced?.log_debug_namespace_ignore
*/
'bridge/request/options': {
options: Record<string, unknown>;
};
'bridge/response/options': {
restart_required: boolean;
};
'bridge/request/device/bind': {
from: string;
from_endpoint: string | number | 'default';
to: string;
to_endpoint?: string | number;
clusters?: string[];
skip_disable_reporting?: boolean;
};
'bridge/response/device/bind': {
from: string;
from_endpoint: string | number;
to: string;
to_endpoint: string | number | undefined;
clusters: string[];
failed: string[];
};
'bridge/request/device/unbind': {
from: string;
from_endpoint: string | number | 'default';
to: string;
to_endpoint?: string | number;
clusters?: string[];
skip_disable_reporting?: boolean;
};
'bridge/response/device/unbind': {
from: string;
from_endpoint: string | number;
to: string;
to_endpoint: string | number | undefined;
clusters: string[];
failed: string[];
};
'bridge/request/device/configure':
| {
id: string | number;
}
| string;
'bridge/response/device/configure': {
id: string | number;
};
'bridge/request/device/remove': {
id: string;
block?: boolean;
force?: boolean;
};
'bridge/response/device/remove': {
id: string;
block: boolean;
force: boolean;
};
'bridge/request/device/ota_update/check': {
id: string;
};
'bridge/request/device/ota_update/check/downgrade': {
id: string;
};
'bridge/response/device/ota_update/check': {
id: string;
update_available: boolean;
};
'bridge/request/device/ota_update/update': {
id: string;
};
'bridge/request/device/ota_update/update/downgrade': {
id: string;
};
'bridge/response/device/ota_update/update': {
id: string;
from:
| {
software_build_id: string;
date_code: string;
}
| undefined;
to:
| {
software_build_id: string;
date_code: string;
}
| undefined;
};
'bridge/request/device/interview': {
id: string | number;
};
'bridge/response/device/interview': {
id: string | number;
};
'bridge/request/device/generate_external_definition': {
id: string | number;
};
'bridge/response/device/generate_external_definition': {
id: string | number;
source: string;
};
'bridge/request/device/options': {
id: string;
options: Record<string, unknown>;
};
'bridge/response/device/options': {
id: string;
from: Record<string, unknown>;
to: Record<string, unknown>;
restart_required: boolean;
};
'bridge/request/device/rename':
| {
last: true;
from?: string;
to: string;
homeassistant_rename?: boolean;
}
| {
last: false | undefined;
from: string;
to: string;
homeassistant_rename?: boolean;
};
'bridge/response/device/rename': {
from: string;
to: string;
homeassistant_rename: boolean;
};
'bridge/request/device/configure_reporting': {
id: string;
endpoint: string | number;
cluster: string | number;
attribute: string | number | {ID: number; type: number};
minimum_report_interval: number;
maximum_report_interval: number;
reportable_change: number;
option: Record<string, unknown>;
};
'bridge/response/device/configure_reporting': {
id: string;
endpoint: string | number;
cluster: string | number;
attribute: string | number | {ID: number; type: number};
minimum_report_interval: number;
maximum_report_interval: number;
reportable_change: number;
};
'bridge/request/group/remove': {
id: string;
force?: boolean;
};
'bridge/response/group/remove': {
id: string;
force: boolean;
};
'bridge/request/group/add': {
friendly_name: string;
id: string;
};
'bridge/response/group/add': {
friendly_name: string;
id: number;
};
'bridge/request/group/rename': {
from: string;
to: string;
homeassistant_rename?: boolean;
};
'bridge/response/group/rename': {
from: string;
to: string;
homeassistant_rename: boolean;
};
'bridge/request/group/options': {
id: string;
options: Record<string, unknown>;
};
'bridge/response/group/options': {
id: string;
from: Record<string, unknown>;
to: Record<string, unknown>;
restart_required: boolean;
};
'bridge/request/group/members/add': {
device: string;
group: string;
endpoint: string | number | 'default';
skip_disable_reporting?: boolean;
};
'bridge/response/group/members/add': {
device: string;
group: string;
endpoint: string | number | 'default';
};
'bridge/request/group/members/remove': {
device: string;
group: string;
endpoint: string | number | 'default';
skip_disable_reporting?: boolean;
};
'bridge/response/group/members/remove': {
device: string;
group: string;
endpoint: string | number | 'default';
};
'bridge/request/group/members/remove_all': {
device: string;
endpoint: string | number | 'default';
skip_disable_reporting?: boolean;
};
'bridge/response/group/members/remove_all': {
device: string;
endpoint: string | number | 'default';
};
'bridge/request/touchlink/factory_reset':
| {
ieee_address: string;
channel: number;
}
| '';
'bridge/response/touchlink/factory_reset':
| {
ieee_address: string;
channel: number;
}
| Record<string, never>;
'bridge/request/touchlink/scan': '';
'bridge/response/touchlink/scan': {
found: {
ieee_address: string;
channel: number;
}[];
};
'bridge/request/touchlink/identify': {
ieee_address: string;
channel: number;
};
'bridge/response/touchlink/identify': {
ieee_address: string;
channel: number;
};
/**
* entity state response
*/
'{friendlyName}': {
[key: string]: unknown;
};
'{friendlyName}/availability': {
state: 'online' | 'offline';
};
/** entity set request */
'{friendlyName}/set': {
[key: string]: unknown;
};
/** entity get request */
'{friendlyName}/get': {
[key: string]: unknown;
};
}
export type Zigbee2MQTTRequestEndpoints =
| 'bridge/request/permit_join'
| 'bridge/request/health_check'
| 'bridge/request/coordinator_check'
| 'bridge/request/restart'
| 'bridge/request/networkmap'
| 'bridge/request/extension/save'
| 'bridge/request/extension/remove'
| 'bridge/request/converter/save'
| 'bridge/request/converter/remove'
| 'bridge/request/backup'
| 'bridge/request/install_code/add'
| 'bridge/request/options'
| 'bridge/request/device/bind'
| 'bridge/request/device/unbind'
| 'bridge/request/device/configure'
| 'bridge/request/device/remove'
| 'bridge/request/device/ota_update/check'
| 'bridge/request/device/ota_update/check/downgrade'
| 'bridge/request/device/ota_update/update'
| 'bridge/request/device/ota_update/update/downgrade'
| 'bridge/request/device/interview'
| 'bridge/request/device/generate_external_definition'
| 'bridge/request/device/options'
| 'bridge/request/device/rename'
| 'bridge/request/device/configure_reporting'
| 'bridge/request/group/remove'
| 'bridge/request/group/add'
| 'bridge/request/group/rename'
| 'bridge/request/group/options'
| 'bridge/request/group/members/add'
| 'bridge/request/group/members/remove'
| 'bridge/request/group/members/remove_all'
| 'bridge/request/touchlink/factory_reset'
| 'bridge/request/touchlink/scan'
| 'bridge/request/touchlink/identify';
export type Zigbee2MQTTResponseEndpoints =
| 'bridge/response/permit_join'
| 'bridge/response/health_check'
| 'bridge/response/coordinator_check'
| 'bridge/response/restart'
| 'bridge/response/networkmap'
| 'bridge/response/extension/save'
| 'bridge/response/extension/remove'
| 'bridge/response/converter/save'
| 'bridge/response/converter/remove'
| 'bridge/response/backup'
| 'bridge/response/install_code/add'
| 'bridge/response/options'
| 'bridge/response/device/bind'
| 'bridge/response/device/unbind'
| 'bridge/response/device/configure'
| 'bridge/response/device/remove'
| 'bridge/response/device/ota_update/check'
| 'bridge/response/device/ota_update/check'
| 'bridge/response/device/ota_update/update'
| 'bridge/response/device/ota_update/update'
| 'bridge/response/device/interview'
| 'bridge/response/device/generate_external_definition'
| 'bridge/response/device/options'
| 'bridge/response/device/rename'
| 'bridge/response/device/configure_reporting'
| 'bridge/response/group/remove'
| 'bridge/response/group/add'
| 'bridge/response/group/rename'
| 'bridge/response/group/options'
| 'bridge/response/group/members/add'
| 'bridge/response/group/members/remove'
| 'bridge/response/group/members/remove_all'
| 'bridge/response/touchlink/factory_reset'
| 'bridge/response/touchlink/scan'
| 'bridge/response/touchlink/identify';
export type Zigbee2MQTTRequest<T extends Zigbee2MQTTRequestEndpoints> = {
transaction?: string;
} & Zigbee2MQTTAPI[T];
export type Zigbee2MQTTResponseOK<T extends Zigbee2MQTTResponseEndpoints> = {
status: 'ok';
data: Zigbee2MQTTAPI[T];
transaction?: string;
};
export type Zigbee2MQTTResponseError = {
status: 'error';
data: Record<string, never>;
error: string;
transaction?: string;
};
export type Zigbee2MQTTResponse<T extends Zigbee2MQTTResponseEndpoints> = Zigbee2MQTTResponseOK<T> | Zigbee2MQTTResponseError;
+1 -11
View File
@@ -10,7 +10,6 @@ import type {
LQI as ZHLQI,
NetworkParameters as ZHNetworkParameters,
RoutingTable as ZHRoutingTable,
RoutingTableEntry as ZHRoutingTableEntry,
} from 'zigbee-herdsman/dist/adapter/tstype';
import type * as ZHEvents from 'zigbee-herdsman/dist/controller/events';
import type {Device as ZHDevice, Endpoint as ZHEndpoint, Group as ZHGroup} from 'zigbee-herdsman/dist/controller/model';
@@ -31,13 +30,6 @@ declare global {
type Extension = TypeExtension;
// Types
interface MQTTResponse {
data: KeyValue;
status: 'error' | 'ok';
error?: string;
transaction?: string;
}
type Scene = {id: number; name: string};
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached' | 'publishThrottle';
type PublishEntityState = (entity: Device | Group, payload: KeyValue, stateChangeReason?: StateChangeReason) => Promise<void>;
type RecursivePartial<T> = {[P in keyof T]?: RecursivePartial<T[P]>};
@@ -53,12 +45,10 @@ declare global {
type Group = ZHGroup;
type LQI = ZHLQI;
type RoutingTable = ZHRoutingTable;
type RoutingTableEntry = ZHRoutingTableEntry;
type CoordinatorVersion = ZHCoordinatorVersion;
type NetworkParameters = ZHNetworkParameters;
type Cluster = ZHCluster;
interface Bind {
cluster: zh.Cluster;
cluster: ZHCluster;
target: zh.Endpoint | zh.Group;
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ export {schemaJson};
export const CURRENT_VERSION = 2;
/** NOTE: by order of priority, lower index is lower level (more important) */
export const LOG_LEVELS: readonly string[] = ['error', 'warning', 'info', 'debug'] as const;
export type LogLevel = (typeof LOG_LEVELS)[number];
export type LogLevel = 'error' | 'warning' | 'info' | 'debug';
const CONFIG_FILE_PATH = data.joinPath('configuration.yaml');
const NULLABLE_SETTINGS = ['homeassistant'];
+30 -35
View File
@@ -1,3 +1,4 @@
import type {Zigbee2MQTTAPI, Zigbee2MQTTResponse, Zigbee2MQTTResponseEndpoints, Zigbee2MQTTScene} from 'lib/types/api';
import type * as zhc from 'zigbee-herdsman-converters';
import assert from 'assert';
@@ -120,19 +121,35 @@ function getObjectProperty<T>(object: KeyValue, key: string, defaultValue: NoInf
return object && object[key] !== undefined ? object[key] : defaultValue;
}
function getResponse(request: KeyValue | string, data: KeyValue, error?: string): MQTTResponse {
// On `error`, always return an empty `data` payload.
const response: MQTTResponse = {data: error ? {} : data, status: error ? 'error' : 'ok'};
function getResponse<T extends Zigbee2MQTTResponseEndpoints>(
request: KeyValue | string,
data: Zigbee2MQTTAPI[T],
error?: string,
): Zigbee2MQTTResponse<T> {
if (error !== undefined) {
const response: Zigbee2MQTTResponse<T> = {
data: {}, // always return an empty `data` payload on error
status: 'error',
error: error,
};
if (error) {
response.error = error;
if (typeof request === 'object' && request.transaction !== undefined) {
response.transaction = request.transaction;
}
return response;
} else {
const response: Zigbee2MQTTResponse<T> = {
data, // valid from error check
status: 'ok',
};
if (typeof request === 'object' && request.transaction !== undefined) {
response.transaction = request.transaction;
}
return response;
}
if (typeof request === 'object' && request['transaction'] !== undefined) {
response.transaction = request.transaction;
}
return response;
}
function parseJSON(value: string, fallback: string): KeyValue | string {
@@ -166,26 +183,6 @@ function toNetworkAddressHex(value: number): string {
return `0x${'0'.repeat(4 - hex.length)}${hex}`;
}
function toSnakeCaseObject(value: KeyValue): KeyValue {
value = {...value};
for (const key of Object.keys(value)) {
const keySnakeCase = toSnakeCaseString(key);
assert(typeof keySnakeCase === 'string');
if (key !== keySnakeCase) {
value[keySnakeCase] = value[key];
delete value[key];
}
}
return value;
}
function toSnakeCaseString(value: string): string {
return value
.replace(/\.?([A-Z])/g, (x, y) => '_' + y.toLowerCase())
.replace(/^_/, '')
.replace('_i_d', '_id');
}
function charRange(start: string, stop: string): number[] {
const result = [];
for (let idx = start.charCodeAt(0), end = stop.charCodeAt(0); idx <= end; ++idx) {
@@ -345,8 +342,8 @@ export function isLightExpose(expose: zhc.Expose): expose is zhc.Light {
return expose.type === 'light';
}
function getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
const scenes: {[id: number]: Scene} = {};
function getScenes(entity: zh.Endpoint | zh.Group): Zigbee2MQTTScene[] {
const scenes: {[id: number]: Zigbee2MQTTScene} = {};
const endpoints = isZHEndpoint(entity) ? [entity] : entity.members;
const groupID = isZHEndpoint(entity) ? 0 : entity.groupID;
@@ -384,8 +381,6 @@ export default {
parseJSON,
removeNullPropertiesFromObject,
toNetworkAddressHex,
toSnakeCaseString,
toSnakeCaseObject,
isZHEndpoint,
isZHGroup,
hours,
+10
View File
@@ -190,6 +190,16 @@ describe('Extension: Configure', () => {
);
});
it('Handles invalid payload for configure via MQTT', async () => {
await mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/configure', stringify({idx: '0x0017882104a44559'}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/configure',
stringify({data: {}, status: 'error', error: 'Invalid payload'}),
{retain: false, qos: 0},
);
});
it('Should not configure when interview not completed', async () => {
const device = devices.remote;
delete device.meta.configured;
@@ -318,4 +318,28 @@ describe('Extension: ExternalConverters', () => {
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
});
it('handles invalid payloads', async () => {
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: 'test.js', transaction: 1 /* code */}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/save',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}),
{retain: false, qos: 0},
);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({namex: 'test.js', transaction: 2}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/converter/remove',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}),
{retain: false, qos: 0},
);
});
});
@@ -166,4 +166,28 @@ describe('Extension: ExternalExtensions', () => {
);
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
});
it('handles invalid payloads', async () => {
await controller.start();
await flushPromises();
mocksClear.forEach((m) => m.mockClear());
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'test.js', transaction: 1 /* code */}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/save',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 1}),
{retain: false, qos: 0},
);
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({namex: 'test.js', transaction: 2}));
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/extension/remove',
stringify({data: {}, status: 'error', error: `Invalid payload`, transaction: 2}),
{retain: false, qos: 0},
);
});
});
+5 -2
View File
@@ -102,8 +102,11 @@ describe('Extension: OTAUpdate', () => {
expect(mockLogger.info).toHaveBeenCalledWith(`Update of 'bulb' at 0.00%`);
expect(mockLogger.info).toHaveBeenCalledWith(`Update of 'bulb' at 10.00%, ≈ 60 minutes remaining`);
expect(mockLogger.info).toHaveBeenCalledWith(`Finished update of 'bulb'`);
expect(mockLogger.info).toHaveBeenCalledWith(
// note this is a lambda for `info`, so go down to `log` call to get actual message
expect(mockLogger.log).toHaveBeenCalledWith(
'info',
`Device 'bulb' was updated from '{"dateCode":"${fromDateCode}","softwareBuildID":${fromSwBuildId}}' to '{"dateCode":"${toDateCode}","softwareBuildID":${toSwBuildId}}'`,
'z2m',
);
expect(devices.bulb.save).toHaveBeenCalledTimes(1);
expect(devices.bulb.endpoints[0].read).toHaveBeenCalledWith('genBasic', ['dateCode', 'swBuildId'], {sendPolicy: 'immediate'});
@@ -281,7 +284,7 @@ describe('Extension: OTAUpdate', () => {
await flushPromises();
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/ota_update/update',
stringify({data: {id: 'bulb', from: null, to: null}, status: 'ok'}),
stringify({data: {id: 'bulb', from: undefined, to: undefined}, status: 'ok'}),
{retain: false, qos: 0},
);
});