mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-06 19:00:13 +00:00
feat: Typing for MQTT API (#25098)
This commit is contained in:
+3
-1
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}/#`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
Vendored
+1
-11
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user