From 1a9c79bb2f9b5e123b8cd017e82498ab534596fd Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sat, 7 Dec 2024 21:21:30 +0100 Subject: [PATCH] feat: Typing for MQTT API (#25098) --- lib/controller.ts | 4 +- lib/extension/availability.ts | 6 +- lib/extension/bind.ts | 32 +- lib/extension/bridge.ts | 262 ++++---- lib/extension/configure.ts | 32 +- lib/extension/externalJS.ts | 41 +- lib/extension/groups.ts | 44 +- lib/extension/networkMap.ts | 75 +-- lib/extension/otaUpdate.ts | 44 +- lib/mqtt.ts | 12 +- lib/types/api.ts | 685 +++++++++++++++++++++ lib/types/types.d.ts | 12 +- lib/util/settings.ts | 2 +- lib/util/utils.ts | 65 +- test/extensions/configure.test.ts | 10 + test/extensions/externalConverters.test.ts | 24 + test/extensions/externalExtensions.test.ts | 24 + test/extensions/otaUpdate.test.ts | 7 +- 18 files changed, 1089 insertions(+), 292 deletions(-) create mode 100644 lib/types/api.ts diff --git a/lib/controller.ts b/lib/controller.ts index b2cd3f2b0..46e90b59b 100644 --- a/lib/controller.ts +++ b/lib/controller.ts @@ -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 { - let message = {...payload}; + let message: Zigbee2MQTTAPI['{friendlyName}'] = {...payload}; // Update state cache with new state. const newState = this.state.set(entity, payload, stateChangeReason); diff --git a/lib/extension/availability.ts b/lib/extension/availability.ts index a32771b78..f43576c2c 100644 --- a/lib/extension/availability.ts +++ b/lib/extension/availability.ts @@ -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()) { diff --git a/lib/extension/bind.ts b/lib/extension/bind.ts index 6e324f13d..62ebfefd4 100755 --- a/lib/extension/bind.ts +++ b/lib/extension/bind.ts @@ -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 { - const response = stringify(utils.getResponse(request, data, error)); - await this.mqtt.publish(`bridge/response/device/${type}`, response); + private async publishResponse( + type: ParsedMQTTMessage['type'], + request: KeyValue, + data: Zigbee2MQTTAPI[T], + error?: string, + ): Promise { + const response = utils.getResponse(request, data, error); + await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response)); if (error) { logger.error(error); diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index fc467cab8..b513ac914 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -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}; + private logTransport!: winston.transport; + private requestLookup: {[key: string]: (message: KeyValue | string) => Promise>} = { + '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 { - 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 => - 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 { - 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 { + @bind async deviceOptions(message: KeyValue | string): Promise> { return await this.changeEntityOptions('device', message); } - @bind async groupOptions(message: KeyValue | string): Promise { + @bind async groupOptions(message: KeyValue | string): Promise> { return await this.changeEntityOptions('group', message); } - @bind async bridgeOptions(message: KeyValue | string): Promise { + @bind async bridgeOptions(message: KeyValue | string): Promise> { 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 { + @bind async deviceRemove(message: string | KeyValue): Promise> { return await this.removeEntity('device', message); } - @bind async groupRemove(message: string | KeyValue): Promise { + @bind async groupRemove(message: string | KeyValue): Promise> { return await this.removeEntity('group', message); } - @bind async healthCheck(message: string | KeyValue): Promise { + @bind async healthCheck(message: string | KeyValue): Promise> { return utils.getResponse(message, {healthy: true}); } - @bind async coordinatorCheck(message: string | KeyValue): Promise { + @bind async coordinatorCheck(message: string | KeyValue): Promise> { 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 { + @bind async groupAdd(message: string | KeyValue): Promise> { 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 { + @bind async deviceRename(message: string | KeyValue): Promise> { return await this.renameEntity('device', message); } - @bind async groupRename(message: string | KeyValue): Promise { + @bind async groupRename(message: string | KeyValue): Promise> { return await this.renameEntity('group', message); } - @bind async restart(message: string | KeyValue): Promise { + @bind async restart(message: string | KeyValue): Promise> { // 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 { + @bind async backup(message: string | KeyValue): Promise> { 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 { + @bind async installCodeAdd(message: KeyValue | string): Promise> { 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 { + @bind async permitJoin(message: KeyValue | string): Promise> { 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 { + @bind async touchlinkIdentify(message: KeyValue | string): Promise> { 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 { + @bind async touchlinkFactoryReset(message: KeyValue | string): Promise> { 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 { + @bind async touchlinkScan(message: KeyValue | string): Promise> { 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 { + async changeEntityOptions( + entityType: T, + message: KeyValue | string, + ): Promise> { 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 { + @bind async deviceConfigureReporting(message: string | KeyValue): Promise> { if ( typeof message !== 'object' || message.id === undefined || @@ -479,7 +496,7 @@ export default class Bridge extends Extension { }); } - @bind async deviceInterview(message: string | KeyValue): Promise { + @bind async deviceInterview(message: string | KeyValue): Promise> { 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 { + @bind async deviceGenerateExternalDefinition( + message: string | KeyValue, + ): Promise> { 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 { + async renameEntity( + entityType: T, + message: string | KeyValue, + ): Promise> { 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 { + async removeEntity( + entityType: T, + message: string | KeyValue, + ): Promise> { 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 { - 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 { - // 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 { - interface ClusterDefinitionPayload { - clusters: Readonly>>; - 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, diff --git a/lib/extension/configure.ts b/lib/extension/configure.ts index cda7e7402..00469f648 100644 --- a/lib/extension/configure.ts +++ b/lib/extension/configure.ts @@ -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 { 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)); } } diff --git a/lib/extension/externalJS.ts b/lib/extension/externalJS.ts index 599adc8a0..d50f0f306 100644 --- a/lib/extension/externalJS.ts +++ b/lib/extension/externalJS.ts @@ -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 extends Extension { - private requestLookup: {[s: string]: (message: KeyValue) => Promise} = { - save: this.save, - remove: this.remove, - }; +const SUPPORTED_OPERATIONS = ['save', 'remove']; +export default abstract class ExternalJSExtension extends Extension { protected mqttTopic: string; protected requestRegex: RegExp; protected basePath: string; @@ -75,11 +74,21 @@ export default abstract class ExternalJSExtension extends Extension { @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise { 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 extends Extension { protected abstract loadJS(name: string, module: M): Promise; - @bind private async remove(message: KeyValue): Promise { + @bind private async remove( + message: Zigbee2MQTTAPI['bridge/request/converter/remove'] | Zigbee2MQTTAPI['bridge/request/extension/remove'], + ): Promise> { + 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 extends Extension { } } - @bind private async save(message: KeyValue): Promise { + @bind private async save( + message: Zigbee2MQTTAPI['bridge/request/converter/save'] | Zigbee2MQTTAPI['bridge/request/extension/save'], + ): Promise> { + if (!message.name || !message.code) { + return utils.getResponse(message, {}, `Invalid payload`); + } + const {name, code} = message; try { diff --git a/lib/extension/groups.ts b/lib/extension/groups.ts index 5af557ee4..b062c19de 100644 --- a/lib/extension/groups.ts +++ b/lib/extension/groups.ts @@ -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 { - const response = stringify(utils.getResponse(request, data, error)); - await this.mqtt.publish(`bridge/response/group/members/${type}`, response); + private async publishResponse( + type: ParsedMQTTMessage['type'], + request: KeyValue, + data: Zigbee2MQTTAPI[T], + error?: string, + ): Promise { + const response = utils.getResponse(request, data, error); + await this.mqtt.publish(`bridge/response/group/members/${type}`, stringify(response)); if (error) { logger.error(error); diff --git a/lib/extension/networkMap.ts b/lib/extension/networkMap.ts index 426f20d45..f30d1781d 100644 --- a/lib/extension/networkMap.ts +++ b/lib/extension/networkMap.ts @@ -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 { this.eventBus.onMQTTMessage(this, this.onMQTTMessage); @@ -51,28 +22,46 @@ export default class NetworkMap extends Extension { @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise { 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 { + async networkScan(includeRoutes: boolean): Promise { logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`); const lqis: Map = new Map(); const routingTables: Map = 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, diff --git a/lib/extension/otaUpdate.ts b/lib/extension/otaUpdate.ts index c2b7727fe..60f374f33 100644 --- a/lib/extension/otaUpdate.ts +++ b/lib/extension/otaUpdate.ts @@ -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) { diff --git a/lib/mqtt.ts b/lib/mqtt.ts index a7ceedf8e..715198992 100644 --- a/lib/mqtt.ts +++ b/lib/mqtt.ts @@ -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 { 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 { 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}/#`); } diff --git a/lib/types/api.ts b/lib/types/api.ts new file mode 100644 index 000000000..59e147a01 --- /dev/null +++ b/lib/types/api.ts @@ -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; +} + +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>>; + custom_clusters: Record; + }; + + '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; + + '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; + + 'bridge/request/extension/remove': { + name: string; + }; + + 'bridge/response/extension/remove': Record; + + 'bridge/request/converter/save': { + name: string; + code: string; + }; + + 'bridge/response/converter/save': Record; + + 'bridge/request/converter/remove': { + name: string; + }; + + 'bridge/response/converter/remove': Record; + + '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; + }; + + '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; + }; + + 'bridge/response/device/options': { + id: string; + from: Record; + to: Record; + 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; + }; + + '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; + }; + + 'bridge/response/group/options': { + id: string; + from: Record; + to: Record; + 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; + + '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 = { + transaction?: string; +} & Zigbee2MQTTAPI[T]; + +export type Zigbee2MQTTResponseOK = { + status: 'ok'; + data: Zigbee2MQTTAPI[T]; + transaction?: string; +}; + +export type Zigbee2MQTTResponseError = { + status: 'error'; + data: Record; + error: string; + transaction?: string; +}; + +export type Zigbee2MQTTResponse = Zigbee2MQTTResponseOK | Zigbee2MQTTResponseError; diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts index b030dc57b..9db2ea0bd 100644 --- a/lib/types/types.d.ts +++ b/lib/types/types.d.ts @@ -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; type RecursivePartial = {[P in keyof T]?: RecursivePartial}; @@ -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; } } diff --git a/lib/util/settings.ts b/lib/util/settings.ts index 7f2485a58..aaf4b09e9 100644 --- a/lib/util/settings.ts +++ b/lib/util/settings.ts @@ -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']; diff --git a/lib/util/utils.ts b/lib/util/utils.ts index 32a369f0f..c1f5194a3 100644 --- a/lib/util/utils.ts +++ b/lib/util/utils.ts @@ -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(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( + request: KeyValue | string, + data: Zigbee2MQTTAPI[T], + error?: string, +): Zigbee2MQTTResponse { + if (error !== undefined) { + const response: Zigbee2MQTTResponse = { + 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 = { + 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, diff --git a/test/extensions/configure.test.ts b/test/extensions/configure.test.ts index d29c10dd6..44c4c2e72 100644 --- a/test/extensions/configure.test.ts +++ b/test/extensions/configure.test.ts @@ -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; diff --git a/test/extensions/externalConverters.test.ts b/test/extensions/externalConverters.test.ts index 08e16f61d..8c6126051 100644 --- a/test/extensions/externalConverters.test.ts +++ b/test/extensions/externalConverters.test.ts @@ -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}, + ); + }); }); diff --git a/test/extensions/externalExtensions.test.ts b/test/extensions/externalExtensions.test.ts index 747b6d29c..4e1244ee4 100644 --- a/test/extensions/externalExtensions.test.ts +++ b/test/extensions/externalExtensions.test.ts @@ -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}, + ); + }); }); diff --git a/test/extensions/otaUpdate.test.ts b/test/extensions/otaUpdate.test.ts index c4ea7eb97..906bb4d99 100644 --- a/test/extensions/otaUpdate.test.ts +++ b/test/extensions/otaUpdate.test.ts @@ -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}, ); });