From 220aaa1786473e278136268a3e2e8b23da33c94c Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sat, 30 Nov 2024 17:26:34 +0100 Subject: [PATCH 1/5] fix: Permit join optimization --- lib/extension/bridge.ts | 2 +- lib/extension/homeassistant.ts | 14 -------------- lib/zigbee.ts | 4 ++-- test/extensions/bridge.test.ts | 2 +- test/extensions/homeassistant.test.ts | 20 -------------------- 5 files changed, 4 insertions(+), 38 deletions(-) diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index fc467cab82..d667e5837f 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -660,7 +660,7 @@ export default class Bridge extends Extension { }, network: utils.toSnakeCaseObject(await this.zigbee.getNetworkParameters()), log_level: logger.getLevel(), - permit_join_timeout: this.zigbee.getPermitJoinTimeout(), + permit_join: this.zigbee.getPermitJoin(), restart_required: this.restartRequired, config, config_schema: settings.schemaJson, diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index 685a9dc38b..0fd5764d55 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -2017,20 +2017,6 @@ export default class HomeAssistant extends Extension { json_attributes_template: '{{ value_json.data.value | tojson }}', }, }, - { - type: 'sensor', - object_id: 'permit_join_timeout', - mockProperties: [], - discovery_payload: { - name: 'Permit join timeout', - device_class: 'duration', - unit_of_measurement: 's', - entity_category: 'diagnostic', - state_topic: true, - state_topic_postfix: 'info', - value_template: '{{ iif(value_json.permit_join_timeout is defined, value_json.permit_join_timeout, None) }}', - }, - }, // Switches. { diff --git a/lib/zigbee.ts b/lib/zigbee.ts index 18a173ac3a..5e13466dd9 100644 --- a/lib/zigbee.ts +++ b/lib/zigbee.ts @@ -223,8 +223,8 @@ export default class Zigbee { logger.info('Stopped zigbee-herdsman'); } - getPermitJoinTimeout(): number { - return this.herdsman.getPermitJoinTimeout(); + getPermitJoin(): boolean { + return this.herdsman.getPermitJoin(); } async permitJoin(time: number, device?: Device): Promise { diff --git a/test/extensions/bridge.test.ts b/test/extensions/bridge.test.ts index 426a29d836..55f4bb11a9 100644 --- a/test/extensions/bridge.test.ts +++ b/test/extensions/bridge.test.ts @@ -221,7 +221,7 @@ describe('Extension: Bridge', () => { coordinator: {ieee_address: '0x00124b00120144ae', meta: {revision: 20190425, version: 1}, type: 'z-Stack'}, log_level: 'info', network: {channel: 15, extended_pan_id: 0x001122, pan_id: 5674}, - permit_join_timeout: 0, + permit_join: false, restart_required: false, version: version.version, zigbee_herdsman: zhVersion, diff --git a/test/extensions/homeassistant.test.ts b/test/extensions/homeassistant.test.ts index 96a1d82cf4..05561a2d79 100644 --- a/test/extensions/homeassistant.test.ts +++ b/test/extensions/homeassistant.test.ts @@ -2369,26 +2369,6 @@ describe('Extension: HomeAssistant', () => { {retain: true, qos: 1}, ); - payload = { - name: 'Permit join timeout', - object_id: 'zigbee2mqtt_bridge_permit_join_timeout', - entity_category: 'diagnostic', - device_class: 'duration', - unit_of_measurement: 's', - unique_id: 'bridge_0x00124b00120144ae_permit_join_timeout_zigbee2mqtt', - state_topic: 'zigbee2mqtt/bridge/info', - value_template: '{{ iif(value_json.permit_join_timeout is defined, value_json.permit_join_timeout, None) }}', - origin: origin, - device: devicePayload, - availability: [{topic: 'zigbee2mqtt/bridge/state', value_template: '{{ value_json.state }}'}], - availability_mode: 'all', - }; - expect(mockMQTT.publishAsync).toHaveBeenCalledWith( - 'homeassistant/sensor/1221051039810110150109113116116_0x00124b00120144ae/permit_join_timeout/config', - stringify(payload), - {retain: true, qos: 1}, - ); - // Switches. payload = { name: 'Permit join', From c2012996726de107f7334de7efcfe056694bc38d Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sat, 30 Nov 2024 21:30:53 +0100 Subject: [PATCH 2/5] Update --- lib/extension/bridge.ts | 1 + lib/zigbee.ts | 4 ++++ test/extensions/bridge.test.ts | 1 + 3 files changed, 6 insertions(+) diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index d667e5837f..05bbfb9239 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -661,6 +661,7 @@ export default class Bridge extends Extension { network: utils.toSnakeCaseObject(await this.zigbee.getNetworkParameters()), log_level: logger.getLevel(), permit_join: this.zigbee.getPermitJoin(), + permit_join_end: this.zigbee.getPermitJoinEnd(), restart_required: this.restartRequired, config, config_schema: settings.schemaJson, diff --git a/lib/zigbee.ts b/lib/zigbee.ts index 5e13466dd9..4d4e09c372 100644 --- a/lib/zigbee.ts +++ b/lib/zigbee.ts @@ -227,6 +227,10 @@ export default class Zigbee { return this.herdsman.getPermitJoin(); } + getPermitJoinEnd(): number | undefined { + return this.herdsman.getPermitJoinEnd(); + } + async permitJoin(time: number, device?: Device): Promise { if (time > 0) { logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`); diff --git a/test/extensions/bridge.test.ts b/test/extensions/bridge.test.ts index 55f4bb11a9..58ceece850 100644 --- a/test/extensions/bridge.test.ts +++ b/test/extensions/bridge.test.ts @@ -222,6 +222,7 @@ describe('Extension: Bridge', () => { log_level: 'info', network: {channel: 15, extended_pan_id: 0x001122, pan_id: 5674}, permit_join: false, + permit_join_end: undefined, restart_required: false, version: version.version, zigbee_herdsman: zhVersion, From 9d4ceb49ccc3d16fd281cdb802e47c32ebe274b8 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Sun, 8 Dec 2024 15:08:21 +0100 Subject: [PATCH 3/5] Update zigbee-herdsman to 3.0.2 (#25127) Co-authored-by: IIIEII Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com> Co-authored-by: Artem Draft --- 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 | 53 +- lib/extension/homeassistant.ts | 155 +++-- 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 | 63 +- package.json | 10 +- pnpm-lock.yaml | 444 +++++++------ test/extensions/configure.test.ts | 10 + test/extensions/externalConverters.test.ts | 24 + test/extensions/externalExtensions.test.ts | 24 + test/extensions/frontend.test.ts | 2 + test/extensions/groups.test.ts | 29 +- test/extensions/homeassistant.test.ts | 33 +- test/extensions/otaUpdate.test.ts | 7 +- 24 files changed, 1438 insertions(+), 623 deletions(-) create mode 100644 lib/types/api.ts diff --git a/lib/controller.ts b/lib/controller.ts index b2cd3f2b03..46e90b59bc 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 a32771b788..f43576c2cc 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 6e324f13d8..62ebfefd40 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 05bbfb9239..6e4dd213dd 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: this.zigbee.getPermitJoin(), permit_join_end: this.zigbee.getPermitJoinEnd(), @@ -671,27 +703,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: [], @@ -745,8 +763,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 = []; @@ -768,12 +785,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: {}, }; @@ -785,7 +797,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; } @@ -801,7 +813,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 cda7e74023..00469f648e 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 599adc8a06..d50f0f3065 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 7d8ba29fcf..b062c19de4 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} = {}; @@ -100,7 +95,7 @@ export default class Groups extends Extension { if ( group.zh.hasMember(endpoint) && !equals(this.lastOptimisticState[group.ID], payload) && - this.shouldPublishPayloadForGroup(group, payload, endpointName) + this.shouldPublishPayloadForGroup(group, payload) ) { this.lastOptimisticState[group.ID] = payload; @@ -142,7 +137,7 @@ export default class Groups extends Extension { await this.publishEntityState(device, memberPayload, reason); for (const zigbeeGroup of groups) { - if (zigbeeGroup.zh.hasMember(member) && this.shouldPublishPayloadForGroup(zigbeeGroup, memberPayload, endpointName)) { + if (zigbeeGroup.zh.hasMember(member) && this.shouldPublishPayloadForGroup(zigbeeGroup, payload)) { groupsToPublish.add(zigbeeGroup); } } @@ -157,12 +152,11 @@ export default class Groups extends Extension { } } - private shouldPublishPayloadForGroup(group: Group, payload: KeyValue, endpointName: string | undefined): boolean { - const stateKey = endpointName ? `state_${endpointName}` : 'state'; + private shouldPublishPayloadForGroup(group: Group, payload: KeyValue): boolean { return ( group.options.off_state === 'last_member_state' || !payload || - (payload[stateKey] !== 'OFF' && payload[stateKey] !== 'CLOSE') || + (payload.state !== 'OFF' && payload.state !== 'CLOSE') || this.areAllMembersOffOrClosed(group) ); } @@ -195,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']; @@ -274,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`); @@ -288,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})`; @@ -296,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/homeassistant.ts b/lib/extension/homeassistant.ts index 0fd5764d55..c399c6b516 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -973,6 +973,43 @@ export default class HomeAssistant extends Extension { } case 'numeric': { assertNumericExpose(firstExpose); + const allowsSet = firstExpose.access & ACCESS_SET; + + /** + * If numeric attribute has SET access then expose as SELECT entity. + */ + if (allowsSet) { + const discoveryEntry: DiscoveryEntry = { + type: 'number', + object_id: endpoint ? `${firstExpose.name}_${endpoint}` : `${firstExpose.name}`, + mockProperties: [{property: firstExpose.property, value: null}], + discovery_payload: { + name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, + value_template: `{{ value_json.${firstExpose.property} }}`, + command_topic: true, + command_topic_prefix: endpoint, + command_topic_postfix: firstExpose.property, + ...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}), + ...(firstExpose.value_step && {step: firstExpose.value_step}), + ...NUMERIC_DISCOVERY_LOOKUP[firstExpose.name], + }, + }; + + if (NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.device_class === 'temperature') { + discoveryEntry.discovery_payload.device_class = NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.device_class; + } else { + delete discoveryEntry.discovery_payload.device_class; + } + + // istanbul ignore else + if (firstExpose.value_min != null) discoveryEntry.discovery_payload.min = firstExpose.value_min; + // istanbul ignore else + if (firstExpose.value_max != null) discoveryEntry.discovery_payload.max = firstExpose.value_max; + + discoveryEntries.push(discoveryEntry); + break; + } + const extraAttrs = {}; // If a variable includes Wh, mark it as energy @@ -980,8 +1017,6 @@ export default class HomeAssistant extends Extension { Object.assign(extraAttrs, {device_class: 'energy', state_class: 'total_increasing'}); } - const allowsSet = firstExpose.access & ACCESS_SET; - let key = firstExpose.name; // Home Assistant uses a different voc device_class for µg/m³ versus ppb or ppm. @@ -1016,42 +1051,6 @@ export default class HomeAssistant extends Extension { } discoveryEntries.push(discoveryEntry); - - /** - * If numeric attribute has SET access then expose as SELECT entity too. - * Note: currently both sensor and number are discovered, this is to avoid - * breaking changes for sensors already existing in HA (legacy). - */ - if (allowsSet) { - const discoveryEntry: DiscoveryEntry = { - type: 'number', - object_id: endpoint ? `${firstExpose.name}_${endpoint}` : `${firstExpose.name}`, - mockProperties: [{property: firstExpose.property, value: null}], - discovery_payload: { - name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, - value_template: `{{ value_json.${firstExpose.property} }}`, - command_topic: true, - command_topic_prefix: endpoint, - command_topic_postfix: firstExpose.property, - ...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}), - ...(firstExpose.value_step && {step: firstExpose.value_step}), - ...NUMERIC_DISCOVERY_LOOKUP[firstExpose.name], - }, - }; - - if (NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.device_class === 'temperature') { - discoveryEntry.discovery_payload.device_class = NUMERIC_DISCOVERY_LOOKUP[firstExpose.name]?.device_class; - } else { - delete discoveryEntry.discovery_payload.device_class; - } - - // istanbul ignore else - if (firstExpose.value_min != null) discoveryEntry.discovery_payload.min = firstExpose.value_min; - // istanbul ignore else - if (firstExpose.value_max != null) discoveryEntry.discovery_payload.max = firstExpose.value_max; - - discoveryEntries.push(discoveryEntry); - } break; } case 'enum': { @@ -1085,30 +1084,36 @@ export default class HomeAssistant extends Extension { } const valueTemplate = firstExpose.access & ACCESS_STATE ? `{{ value_json.${firstExpose.property} }}` : undefined; - if (firstExpose.access & ACCESS_STATE) { + + /** + * If enum has only one item and has SET access then expose as BUTTON entity. + */ + if (firstExpose.access & ACCESS_SET && firstExpose.values.length === 1) { discoveryEntries.push({ - type: 'sensor', + type: 'button', object_id: firstExpose.property, mockProperties: [{property: firstExpose.property, value: null}], discovery_payload: { - name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, - value_template: valueTemplate, - enabled_by_default: !(firstExpose.access & ACCESS_SET), + name: endpoint ? /* istanbul ignore next */ `${firstExpose.label} ${endpoint}` : firstExpose.label, + state_topic: false, + command_topic_prefix: endpoint, + command_topic: true, + command_topic_postfix: firstExpose.property, + payload_press: firstExpose.values[0].toString(), ...ENUM_DISCOVERY_LOOKUP[firstExpose.name], }, }); + break; } /** - * If enum attribute has SET access then expose as SELECT entity too. - * Note: currently both sensor and select are discovered, this is to avoid - * breaking changes for sensors already existing in HA (legacy). + * If enum attribute has SET access then expose as SELECT entity. */ if (firstExpose.access & ACCESS_SET) { discoveryEntries.push({ type: 'select', object_id: firstExpose.property, - mockProperties: [], // Already mocked above in case access STATE is supported + mockProperties: [{property: firstExpose.property, value: null}], discovery_payload: { name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, value_template: valueTemplate, @@ -1117,29 +1122,24 @@ export default class HomeAssistant extends Extension { command_topic: true, command_topic_postfix: firstExpose.property, options: firstExpose.values.map((v) => v.toString()), - enabled_by_default: firstExpose.values.length !== 1, // hide if button is exposed ...ENUM_DISCOVERY_LOOKUP[firstExpose.name], }, }); + break; } /** - * If enum has only item and only supports SET then expose as button entity. - * Note: select entity is hidden by default to avoid breaking changes - * for selects already existing in HA (legacy). + * Otherwise expose as SENSOR entity. */ - if (firstExpose.access & ACCESS_SET && firstExpose.values.length === 1) { + /* istanbul ignore else */ + if (firstExpose.access & ACCESS_STATE) { discoveryEntries.push({ - type: 'button', + type: 'sensor', object_id: firstExpose.property, - mockProperties: [], + mockProperties: [{property: firstExpose.property, value: null}], discovery_payload: { - name: endpoint ? /* istanbul ignore next */ `${firstExpose.label} ${endpoint}` : firstExpose.label, - state_topic: false, - command_topic_prefix: endpoint, - command_topic: true, - command_topic_postfix: firstExpose.property, - payload_press: firstExpose.values[0].toString(), + name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, + value_template: valueTemplate, ...ENUM_DISCOVERY_LOOKUP[firstExpose.name], }, }); @@ -1149,37 +1149,34 @@ export default class HomeAssistant extends Extension { case 'text': case 'composite': case 'list': { - // legacy: remove text sensor const firstExposeTyped = firstExpose as zhc.Text | zhc.Composite | zhc.List; - const settableText = firstExposeTyped.type === 'text' && firstExposeTyped.access & ACCESS_SET; - if (firstExposeTyped.access & ACCESS_STATE) { - const discoveryEntry: DiscoveryEntry = { - type: 'sensor', + if (firstExposeTyped.type === 'text' && firstExposeTyped.access & ACCESS_SET) { + discoveryEntries.push({ + type: 'text', object_id: firstExposeTyped.property, mockProperties: [{property: firstExposeTyped.property, value: null}], discovery_payload: { name: endpoint ? `${firstExposeTyped.label} ${endpoint}` : firstExposeTyped.label, - // Truncate text if it's too long - // https://github.com/Koenkk/zigbee2mqtt/issues/23199 - value_template: `{{ value_json.${firstExposeTyped.property} | default('',True) | string | truncate(254, True, '', 0) }}`, - enabled_by_default: !settableText, + state_topic: firstExposeTyped.access & ACCESS_STATE, + value_template: `{{ value_json.${firstExposeTyped.property} }}`, + command_topic_prefix: endpoint, + command_topic: true, + command_topic_postfix: firstExposeTyped.property, ...LIST_DISCOVERY_LOOKUP[firstExposeTyped.name], }, - }; - discoveryEntries.push(discoveryEntry); + }); + break; } - if (settableText) { + if (firstExposeTyped.access & ACCESS_STATE) { discoveryEntries.push({ - type: 'text', + type: 'sensor', object_id: firstExposeTyped.property, - mockProperties: firstExposeTyped.access & ACCESS_STATE ? [{property: firstExposeTyped.property, value: null}] : [], + mockProperties: [{property: firstExposeTyped.property, value: null}], discovery_payload: { name: endpoint ? `${firstExposeTyped.label} ${endpoint}` : firstExposeTyped.label, - state_topic: firstExposeTyped.access & ACCESS_STATE, - value_template: `{{ value_json.${firstExposeTyped.property} }}`, - command_topic_prefix: endpoint, - command_topic: true, - command_topic_postfix: firstExposeTyped.property, + // Truncate text if it's too long + // https://github.com/Koenkk/zigbee2mqtt/issues/23199 + value_template: `{{ value_json.${firstExposeTyped.property} | default('',True) | string | truncate(254, True, '', 0) }}`, ...LIST_DISCOVERY_LOOKUP[firstExposeTyped.name], }, }); diff --git a/lib/extension/networkMap.ts b/lib/extension/networkMap.ts index 426f20d456..f30d1781d0 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 c2b7727fee..60f374f338 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 a7ceedf8ed..715198992c 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 0000000000..59e147a01a --- /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 b030dc57b7..9db2ea0bda 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 7f2485a58c..aaf4b09e93 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 32a369f0f8..c1f5194a3c 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 (typeof request === 'object' && request.transaction !== undefined) { + response.transaction = request.transaction; + } - if (error) { - response.error = error; - } + return response; + } else { + const response: Zigbee2MQTTResponse = { + data, // valid from error check + status: 'ok', + }; - if (typeof request === 'object' && request['transaction'] !== undefined) { - response.transaction = request.transaction; - } + if (typeof request === 'object' && request.transaction !== undefined) { + response.transaction = request.transaction; + } - return response; + 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/package.json b/package.json index 5ab2fa4a3e..edbb51c3a5 100644 --- a/package.json +++ b/package.json @@ -60,16 +60,16 @@ "winston-syslog": "^2.7.1", "winston-transport": "^4.9.0", "ws": "^8.18.0", - "zigbee-herdsman": "3.0.1", + "zigbee-herdsman": "3.0.2", "zigbee-herdsman-converters": "21.2.1", - "zigbee2mqtt-frontend": "0.8.0" + "zigbee2mqtt-frontend": "0.8.1" }, "devDependencies": { "@babel/core": "^7.26.0", "@babel/plugin-proposal-decorators": "^7.25.9", "@babel/preset-env": "^7.26.0", "@babel/preset-typescript": "^7.26.0", - "@eslint/core": "^0.9.0", + "@eslint/core": "^0.9.1", "@eslint/js": "^9.16.0", "@ianvs/prettier-plugin-sort-imports": "^4.4.0", "@types/eslint__js": "^8.42.3", @@ -87,10 +87,10 @@ "eslint": "^9.16.0", "eslint-config-prettier": "^9.1.0", "jest": "^29.7.0", - "prettier": "^3.4.1", + "prettier": "^3.4.2", "tmp": "^0.2.3", "typescript": "^5.7.2", - "typescript-eslint": "^8.16.0" + "typescript-eslint": "^8.17.0" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09d9dcf57e..92713f7a64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - zigbee-herdsman: 3.0.1 + zigbee-herdsman: 3.0.2 importers: @@ -81,14 +81,14 @@ importers: specifier: ^8.18.0 version: 8.18.0 zigbee-herdsman: - specifier: 3.0.1 - version: 3.0.1 + specifier: 3.0.2 + version: 3.0.2 zigbee-herdsman-converters: specifier: 21.2.1 version: 21.2.1 zigbee2mqtt-frontend: - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.8.1 + version: 0.8.1 optionalDependencies: sd-notify: specifier: ^2.8.0 @@ -107,14 +107,14 @@ importers: specifier: ^7.26.0 version: 7.26.0(@babel/core@7.26.0) '@eslint/core': - specifier: ^0.9.0 - version: 0.9.0 + specifier: ^0.9.1 + version: 0.9.1 '@eslint/js': specifier: ^9.16.0 version: 9.16.0 '@ianvs/prettier-plugin-sort-imports': specifier: ^4.4.0 - version: 4.4.0(prettier@3.4.1) + version: 4.4.0(prettier@3.4.2) '@types/eslint__js': specifier: ^8.42.3 version: 8.42.3 @@ -161,8 +161,8 @@ importers: specifier: ^29.7.0 version: 29.7.0(@types/node@22.10.1) prettier: - specifier: ^3.4.1 - version: 3.4.1 + specifier: ^3.4.2 + version: 3.4.2 tmp: specifier: ^0.2.3 version: 0.2.3 @@ -170,8 +170,8 @@ importers: specifier: ^5.7.2 version: 5.7.2 typescript-eslint: - specifier: ^8.16.0 - version: 8.16.0(eslint@9.16.0)(typescript@5.7.2) + specifier: ^8.17.0 + version: 8.17.0(eslint@9.16.0)(typescript@5.7.2) packages: @@ -183,26 +183,22 @@ packages: resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.26.2': - resolution: {integrity: sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg==} + '@babel/compat-data@7.26.3': + resolution: {integrity: sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==} engines: {node: '>=6.9.0'} '@babel/core@7.26.0': resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} engines: {node: '>=6.9.0'} - '@babel/generator@7.26.2': - resolution: {integrity: sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==} + '@babel/generator@7.26.3': + resolution: {integrity: sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==} engines: {node: '>=6.9.0'} '@babel/helper-annotate-as-pure@7.25.9': resolution: {integrity: sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==} engines: {node: '>=6.9.0'} - '@babel/helper-builder-binary-assignment-operator-visitor@7.25.9': - resolution: {integrity: sha512-C47lC7LIDCnz0h4vai/tpNOI95tCd5ZT3iBt/DBH5lXKHZsyNQv18yf1wIIg2ntiQNgmAvA+DgZ82iW8Qdym8g==} - engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.25.9': resolution: {integrity: sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==} engines: {node: '>=6.9.0'} @@ -213,8 +209,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-create-regexp-features-plugin@7.25.9': - resolution: {integrity: sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==} + '@babel/helper-create-regexp-features-plugin@7.26.3': + resolution: {integrity: sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -258,10 +254,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-simple-access@7.25.9': - resolution: {integrity: sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-skip-transparent-expression-wrappers@7.25.9': resolution: {integrity: sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==} engines: {node: '>=6.9.0'} @@ -286,8 +278,8 @@ packages: resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.26.2': - resolution: {integrity: sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==} + '@babel/parser@7.26.3': + resolution: {integrity: sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==} engines: {node: '>=6.0.0'} hasBin: true @@ -526,8 +518,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-exponentiation-operator@7.25.9': - resolution: {integrity: sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==} + '@babel/plugin-transform-exponentiation-operator@7.26.3': + resolution: {integrity: sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -580,8 +572,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-modules-commonjs@7.25.9': - resolution: {integrity: sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==} + '@babel/plugin-transform-modules-commonjs@7.26.3': + resolution: {integrity: sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -718,8 +710,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-typescript@7.25.9': - resolution: {integrity: sha512-7PbZQZP50tzv2KGGnhh82GSyMB01yKY9scIjf1a+GfZCtInOWqUH5+1EBU4t9fyR5Oykkkc9vFTs4OHrhHXljQ==} + '@babel/plugin-transform-typescript@7.26.3': + resolution: {integrity: sha512-6+5hpdr6mETwSKjmJUdYw0EIkATiQhnELWlE3kJFBwSg/BGIVwVaVbX+gOXBCdc7Ln1RXZxyWGecIXhUfnl7oA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -773,12 +765,12 @@ packages: resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.25.9': - resolution: {integrity: sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==} + '@babel/traverse@7.26.4': + resolution: {integrity: sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==} engines: {node: '>=6.9.0'} - '@babel/types@7.26.0': - resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==} + '@babel/types@7.26.3': + resolution: {integrity: sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@0.2.3': @@ -801,12 +793,12 @@ packages: resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.19.0': - resolution: {integrity: sha512-zdHg2FPIFNKPdcHWtiNT+jEFCHYVplAXRDlQDyqy0zGx/q2parwh7brGJSiTxRk/TSMkbM//zt/f5CHgyTyaSQ==} + '@eslint/config-array@0.19.1': + resolution: {integrity: sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@0.9.0': - resolution: {integrity: sha512-7ATR9F0e4W85D/0w7cU0SNj7qkAexMG+bAHEZOjo9akvGuhHE2m7umzWzfnpa0XAg5Kxc1BWmtPMV67jJ+9VUg==} + '@eslint/core@0.9.1': + resolution: {integrity: sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/eslintrc@3.2.0': @@ -817,12 +809,12 @@ packages: resolution: {integrity: sha512-tw2HxzQkrbeuvyj1tG2Yqq+0H9wGoI2IMk4EOsQeX+vmd75FtJAzf+gTA69WF+baUKRYQ3x2kbLE08js5OsTVg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.4': - resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==} + '@eslint/object-schema@2.1.5': + resolution: {integrity: sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.2.3': - resolution: {integrity: sha512-2b/g5hRmpbb1o4GnTZax9N9m0FXzz9OV42ZzI4rDDMDuHUqigAiQCEWChBWCY4ztAGVRjoWT19v0yMmc5/L5kA==} + '@eslint/plugin-kit@0.2.4': + resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@humanfs/core@0.19.1': @@ -1085,8 +1077,8 @@ packages: '@types/yargs@17.0.33': resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - '@typescript-eslint/eslint-plugin@8.16.0': - resolution: {integrity: sha512-5YTHKV8MYlyMI6BaEG7crQ9BhSc8RxzshOReKwZwRWN0+XvvTOm+L/UYLCYxFpfwYuAAqhxiq4yae0CMFwbL7Q==} + '@typescript-eslint/eslint-plugin@8.17.0': + resolution: {integrity: sha512-HU1KAdW3Tt8zQkdvNoIijfWDMvdSweFYm4hWh+KwhPstv+sCmWb89hCIP8msFm9N1R/ooh9honpSuvqKWlYy3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 @@ -1096,8 +1088,8 @@ packages: typescript: optional: true - '@typescript-eslint/parser@8.16.0': - resolution: {integrity: sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==} + '@typescript-eslint/parser@8.17.0': + resolution: {integrity: sha512-Drp39TXuUlD49F7ilHHCG7TTg8IkA+hxCuULdmzWYICxGXvDXmDmWEjJYZQYgf6l/TFfYNE167m7isnc3xlIEg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -1106,12 +1098,12 @@ packages: typescript: optional: true - '@typescript-eslint/scope-manager@8.16.0': - resolution: {integrity: sha512-mwsZWubQvBki2t5565uxF0EYvG+FwdFb8bMtDuGQLdCCnGPrDEDvm1gtfynuKlnpzeBRqdFCkMf9jg1fnAK8sg==} + '@typescript-eslint/scope-manager@8.17.0': + resolution: {integrity: sha512-/ewp4XjvnxaREtqsZjF4Mfn078RD/9GmiEAtTeLQ7yFdKnqwTOgRMSvFz4et9U5RiJQ15WTGXPLj89zGusvxBg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/type-utils@8.16.0': - resolution: {integrity: sha512-IqZHGG+g1XCWX9NyqnI/0CX5LL8/18awQqmkZSl2ynn8F76j579dByc0jhfVSnSnhf7zv76mKBQv9HQFKvDCgg==} + '@typescript-eslint/type-utils@8.17.0': + resolution: {integrity: sha512-q38llWJYPd63rRnJ6wY/ZQqIzPrBCkPdpIsaCfkR3Q4t3p6sb422zougfad4TFW9+ElIFLVDzWGiGAfbb/v2qw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -1120,12 +1112,12 @@ packages: typescript: optional: true - '@typescript-eslint/types@8.16.0': - resolution: {integrity: sha512-NzrHj6thBAOSE4d9bsuRNMvk+BvaQvmY4dDglgkgGC0EW/tB3Kelnp3tAKH87GEwzoxgeQn9fNGRyFJM/xd+GQ==} + '@typescript-eslint/types@8.17.0': + resolution: {integrity: sha512-gY2TVzeve3z6crqh2Ic7Cr+CAv6pfb0Egee7J5UAVWCpVvDI/F71wNfolIim4FE6hT15EbpZFVUj9j5i38jYXA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.16.0': - resolution: {integrity: sha512-E2+9IzzXMc1iaBy9zmo+UYvluE3TW7bCGWSF41hVWUE01o8nzr1rvOQYSxelxr6StUvRcTMe633eY8mXASMaNw==} + '@typescript-eslint/typescript-estree@8.17.0': + resolution: {integrity: sha512-JqkOopc1nRKZpX+opvKqnM3XUlM7LpFMD0lYxTqOTKQfCWAmxw45e3qlOCsEqEB2yuacujivudOFpCnqkBDNMw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '*' @@ -1133,8 +1125,8 @@ packages: typescript: optional: true - '@typescript-eslint/utils@8.16.0': - resolution: {integrity: sha512-C1zRy/mOL8Pj157GiX4kaw7iyRLKfJXBR3L82hk5kS/GyHcOFmy4YUq/zfZti72I9wnuQtA/+xzft4wCC8PJdA==} + '@typescript-eslint/utils@8.17.0': + resolution: {integrity: sha512-bQC8BnEkxqG8HBGKwG9wXlZqg37RKSMY7v/X8VEWD8JG2JuTHuNK0VFvMPMUKQcbk6B+tf05k+4AShAEtCtJ/w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -1143,8 +1135,8 @@ packages: typescript: optional: true - '@typescript-eslint/visitor-keys@8.16.0': - resolution: {integrity: sha512-pq19gbaMOmFE3CbL0ZB8J8BFCo2ckfHBfaIsaOZgBIF4EoISJIdLX5xRhd0FGB0LlHReNRuzoJoMGpTjq8F2CQ==} + '@typescript-eslint/visitor-keys@8.17.0': + resolution: {integrity: sha512-1Hm7THLpO6ww5QU6H/Qp+AusUUl+z/CAm3cNZZ0jQvon9yicgO7Rwd+/WWRpMKLYV6p2UvdbR27c86rzCPpreg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} abort-controller@3.0.0: @@ -1302,8 +1294,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001684: - resolution: {integrity: sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==} + caniuse-lite@1.0.30001687: + resolution: {integrity: sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1402,8 +1394,8 @@ packages: supports-color: optional: true - debug@4.3.7: - resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -1452,8 +1444,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.67: - resolution: {integrity: sha512-nz88NNBsD7kQSAGGJyp8hS6xSPtWwqNogA0mjtc2nUYeEf3nURK9qpV18TuBdDmEDgVWotS8Wkzf+V52dSQ/LQ==} + electron-to-chromium@1.5.71: + resolution: {integrity: sha512-dB68l59BI75W1BUGVTAEJy45CEVuEGy9qPVVQ8pnHyHMn36PLPPoE1mjLH+lo9rKulO3HC2OhbACI/8tCqJBcA==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -2264,8 +2256,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.4.1: - resolution: {integrity: sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==} + prettier@3.4.2: + resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==} engines: {node: '>=14'} hasBin: true @@ -2359,8 +2351,8 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} - resolve.exports@2.0.2: - resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} resolve@1.22.8: @@ -2586,8 +2578,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.16.0: - resolution: {integrity: sha512-wDkVmlY6O2do4V+lZd0GtRfbtXbeD0q9WygwXXSJnC1xorE8eqyC2L1tJimqpSeFrOzRlYtWnUp/uzgHQOgfBQ==} + typescript-eslint@8.17.0: + resolution: {integrity: sha512-409VXvFd/f1br1DCbuKNFqQpXICoTB+V51afcwG1pn1a3Cp92MqAUges3YjwEdQ0cMUoCIodjVDAYzyD8h3SYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -2728,11 +2720,11 @@ packages: zigbee-herdsman-converters@21.2.1: resolution: {integrity: sha512-U8a5cCvT5CjvWfyiiZMCu8VTh3iBqac/sotfkMx+ruhoZyxBoH5a4uRAdRPSnNkMTfLuS1hcviAs7IqZz8R8vA==} - zigbee-herdsman@3.0.1: - resolution: {integrity: sha512-FIhcaJObMW8MNET1vzS8RgXwxPdps98kPpe7gYRkdUH/laMATiPPR5bscI4ckM3yWsQXYLYutQXIlchAbwEJJw==} + zigbee-herdsman@3.0.2: + resolution: {integrity: sha512-na134O6uk96Jzj/8oWA8PYYFUu3BCNIkU1zAs4oQKQWEBccCF5EWM78GHYiPfhhxbCGvm67MfYj2KlN5sTGILg==} - zigbee2mqtt-frontend@0.8.0: - resolution: {integrity: sha512-l23vFtiUDKKZZg3LO7jsdz3vA3KSyk2zphcT0SDMQ6tguym7jFfi+z0a6R/zUs0Rrp5JPAzanlsTmYCJVElljA==} + zigbee2mqtt-frontend@0.8.1: + resolution: {integrity: sha512-WrpfK8SnYwMbVjjfIQgJqN+WQXge6Co06s890OQmrZOGYHfUfJkLkrGTZeUymVQnuPyFF/hNGpdEtkMHwryTwg==} engines: {node: '>=18'} snapshots: @@ -2748,50 +2740,43 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.26.2': {} + '@babel/compat-data@7.26.3': {} '@babel/core@7.26.0': dependencies: '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.26.2 - '@babel/generator': 7.26.2 + '@babel/generator': 7.26.3 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) '@babel/helpers': 7.26.0 - '@babel/parser': 7.26.2 + '@babel/parser': 7.26.3 '@babel/template': 7.25.9 - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 + '@babel/traverse': 7.26.4 + '@babel/types': 7.26.3 convert-source-map: 2.0.0 - debug: 4.3.7 + debug: 4.4.0 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/generator@7.26.2': + '@babel/generator@7.26.3': dependencies: - '@babel/parser': 7.26.2 - '@babel/types': 7.26.0 + '@babel/parser': 7.26.3 + '@babel/types': 7.26.3 '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.0.2 '@babel/helper-annotate-as-pure@7.25.9': dependencies: - '@babel/types': 7.26.0 - - '@babel/helper-builder-binary-assignment-operator-visitor@7.25.9': - dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 - transitivePeerDependencies: - - supports-color + '@babel/types': 7.26.3 '@babel/helper-compilation-targets@7.25.9': dependencies: - '@babel/compat-data': 7.26.2 + '@babel/compat-data': 7.26.3 '@babel/helper-validator-option': 7.25.9 browserslist: 4.24.2 lru-cache: 5.1.1 @@ -2805,12 +2790,12 @@ snapshots: '@babel/helper-optimise-call-expression': 7.25.9 '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.26.4 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.25.9(@babel/core@7.26.0)': + '@babel/helper-create-regexp-features-plugin@7.26.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-annotate-as-pure': 7.25.9 @@ -2822,7 +2807,7 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 - debug: 4.3.7 + debug: 4.4.0 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -2830,15 +2815,15 @@ snapshots: '@babel/helper-member-expression-to-functions@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 + '@babel/traverse': 7.26.4 + '@babel/types': 7.26.3 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 + '@babel/traverse': 7.26.4 + '@babel/types': 7.26.3 transitivePeerDependencies: - supports-color @@ -2847,13 +2832,13 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-module-imports': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.26.4 transitivePeerDependencies: - supports-color '@babel/helper-optimise-call-expression@7.25.9': dependencies: - '@babel/types': 7.26.0 + '@babel/types': 7.26.3 '@babel/helper-plugin-utils@7.25.9': {} @@ -2862,7 +2847,7 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-annotate-as-pure': 7.25.9 '@babel/helper-wrap-function': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.26.4 transitivePeerDependencies: - supports-color @@ -2871,21 +2856,14 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-member-expression-to-functions': 7.25.9 '@babel/helper-optimise-call-expression': 7.25.9 - '@babel/traverse': 7.25.9 - transitivePeerDependencies: - - supports-color - - '@babel/helper-simple-access@7.25.9': - dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 + '@babel/traverse': 7.26.4 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 + '@babel/traverse': 7.26.4 + '@babel/types': 7.26.3 transitivePeerDependencies: - supports-color @@ -2898,25 +2876,25 @@ snapshots: '@babel/helper-wrap-function@7.25.9': dependencies: '@babel/template': 7.25.9 - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 + '@babel/traverse': 7.26.4 + '@babel/types': 7.26.3 transitivePeerDependencies: - supports-color '@babel/helpers@7.26.0': dependencies: '@babel/template': 7.25.9 - '@babel/types': 7.26.0 + '@babel/types': 7.26.3 - '@babel/parser@7.26.2': + '@babel/parser@7.26.3': dependencies: - '@babel/types': 7.26.0 + '@babel/types': 7.26.3 '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.26.4 transitivePeerDependencies: - supports-color @@ -2943,7 +2921,7 @@ snapshots: dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.26.4 transitivePeerDependencies: - supports-color @@ -3058,7 +3036,7 @@ snapshots: '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-create-regexp-features-plugin': 7.26.3(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.25.9 '@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.26.0)': @@ -3071,7 +3049,7 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.25.9 '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.26.0) - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.26.4 transitivePeerDependencies: - supports-color @@ -3117,7 +3095,7 @@ snapshots: '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.26.4 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3136,7 +3114,7 @@ snapshots: '@babel/plugin-transform-dotall-regex@7.25.9(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-create-regexp-features-plugin': 7.26.3(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.25.9 '@babel/plugin-transform-duplicate-keys@7.25.9(@babel/core@7.26.0)': @@ -3147,7 +3125,7 @@ snapshots: '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.25.9(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-create-regexp-features-plugin': 7.26.3(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.25.9 '@babel/plugin-transform-dynamic-import@7.25.9(@babel/core@7.26.0)': @@ -3155,13 +3133,10 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-exponentiation-operator@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-exponentiation-operator@7.26.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-builder-binary-assignment-operator-visitor': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 - transitivePeerDependencies: - - supports-color '@babel/plugin-transform-export-namespace-from@7.25.9(@babel/core@7.26.0)': dependencies: @@ -3181,7 +3156,7 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.26.4 transitivePeerDependencies: - supports-color @@ -3213,12 +3188,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-modules-commonjs@7.26.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.25.9 - '@babel/helper-simple-access': 7.25.9 transitivePeerDependencies: - supports-color @@ -3228,7 +3202,7 @@ snapshots: '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.26.4 transitivePeerDependencies: - supports-color @@ -3243,7 +3217,7 @@ snapshots: '@babel/plugin-transform-named-capturing-groups-regex@7.25.9(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-create-regexp-features-plugin': 7.26.3(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.25.9 '@babel/plugin-transform-new-target@7.25.9(@babel/core@7.26.0)': @@ -3325,7 +3299,7 @@ snapshots: '@babel/plugin-transform-regexp-modifiers@7.26.0(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-create-regexp-features-plugin': 7.26.3(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.25.9 '@babel/plugin-transform-reserved-words@7.25.9(@babel/core@7.26.0)': @@ -3361,7 +3335,7 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-typescript@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-typescript@7.26.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-annotate-as-pure': 7.25.9 @@ -3380,24 +3354,24 @@ snapshots: '@babel/plugin-transform-unicode-property-regex@7.25.9(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-create-regexp-features-plugin': 7.26.3(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.25.9 '@babel/plugin-transform-unicode-regex@7.25.9(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-create-regexp-features-plugin': 7.26.3(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.25.9 '@babel/plugin-transform-unicode-sets-regex@7.25.9(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-create-regexp-features-plugin': 7.26.3(@babel/core@7.26.0) '@babel/helper-plugin-utils': 7.25.9 '@babel/preset-env@7.26.0(@babel/core@7.26.0)': dependencies: - '@babel/compat-data': 7.26.2 + '@babel/compat-data': 7.26.3 '@babel/core': 7.26.0 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 @@ -3425,7 +3399,7 @@ snapshots: '@babel/plugin-transform-duplicate-keys': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-dynamic-import': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-exponentiation-operator': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-exponentiation-operator': 7.26.3(@babel/core@7.26.0) '@babel/plugin-transform-export-namespace-from': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-for-of': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-function-name': 7.25.9(@babel/core@7.26.0) @@ -3434,7 +3408,7 @@ snapshots: '@babel/plugin-transform-logical-assignment-operators': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-member-expression-literals': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-modules-amd': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-modules-commonjs': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-modules-commonjs': 7.26.3(@babel/core@7.26.0) '@babel/plugin-transform-modules-systemjs': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-modules-umd': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-named-capturing-groups-regex': 7.25.9(@babel/core@7.26.0) @@ -3474,7 +3448,7 @@ snapshots: dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.25.9 - '@babel/types': 7.26.0 + '@babel/types': 7.26.3 esutils: 2.0.3 '@babel/preset-typescript@7.26.0(@babel/core@7.26.0)': @@ -3483,8 +3457,8 @@ snapshots: '@babel/helper-plugin-utils': 7.25.9 '@babel/helper-validator-option': 7.25.9 '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-modules-commonjs': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-typescript': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-modules-commonjs': 7.26.3(@babel/core@7.26.0) + '@babel/plugin-transform-typescript': 7.26.3(@babel/core@7.26.0) transitivePeerDependencies: - supports-color @@ -3495,22 +3469,22 @@ snapshots: '@babel/template@7.25.9': dependencies: '@babel/code-frame': 7.26.2 - '@babel/parser': 7.26.2 - '@babel/types': 7.26.0 + '@babel/parser': 7.26.3 + '@babel/types': 7.26.3 - '@babel/traverse@7.25.9': + '@babel/traverse@7.26.4': dependencies: '@babel/code-frame': 7.26.2 - '@babel/generator': 7.26.2 - '@babel/parser': 7.26.2 + '@babel/generator': 7.26.3 + '@babel/parser': 7.26.3 '@babel/template': 7.25.9 - '@babel/types': 7.26.0 - debug: 4.3.7 + '@babel/types': 7.26.3 + debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color - '@babel/types@7.26.0': + '@babel/types@7.26.3': dependencies: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 @@ -3532,20 +3506,22 @@ snapshots: '@eslint-community/regexpp@4.12.1': {} - '@eslint/config-array@0.19.0': + '@eslint/config-array@0.19.1': dependencies: - '@eslint/object-schema': 2.1.4 - debug: 4.3.7 + '@eslint/object-schema': 2.1.5 + debug: 4.4.0 minimatch: 3.1.2 transitivePeerDependencies: - supports-color - '@eslint/core@0.9.0': {} + '@eslint/core@0.9.1': + dependencies: + '@types/json-schema': 7.0.15 '@eslint/eslintrc@3.2.0': dependencies: ajv: 6.12.6 - debug: 4.3.7 + debug: 4.4.0 espree: 10.3.0 globals: 14.0.0 ignore: 5.3.2 @@ -3558,9 +3534,9 @@ snapshots: '@eslint/js@9.16.0': {} - '@eslint/object-schema@2.1.4': {} + '@eslint/object-schema@2.1.5': {} - '@eslint/plugin-kit@0.2.3': + '@eslint/plugin-kit@0.2.4': dependencies: levn: 0.4.1 @@ -3577,13 +3553,13 @@ snapshots: '@humanwhocodes/retry@0.4.1': {} - '@ianvs/prettier-plugin-sort-imports@4.4.0(prettier@3.4.1)': + '@ianvs/prettier-plugin-sort-imports@4.4.0(prettier@3.4.2)': dependencies: - '@babel/generator': 7.26.2 - '@babel/parser': 7.26.2 - '@babel/traverse': 7.25.9 - '@babel/types': 7.26.0 - prettier: 3.4.1 + '@babel/generator': 7.26.3 + '@babel/parser': 7.26.3 + '@babel/traverse': 7.26.4 + '@babel/types': 7.26.3 + prettier: 3.4.2 semver: 7.6.3 transitivePeerDependencies: - supports-color @@ -3839,24 +3815,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.26.2 - '@babel/types': 7.26.0 + '@babel/parser': 7.26.3 + '@babel/types': 7.26.3 '@types/babel__generator': 7.6.8 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.6 '@types/babel__generator@7.6.8': dependencies: - '@babel/types': 7.26.0 + '@babel/types': 7.26.3 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.26.2 - '@babel/types': 7.26.0 + '@babel/parser': 7.26.3 + '@babel/types': 7.26.3 '@types/babel__traverse@7.20.6': dependencies: - '@babel/types': 7.26.0 + '@babel/types': 7.26.3 '@types/eslint@9.6.1': dependencies: @@ -3940,14 +3916,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.16.0(@typescript-eslint/parser@8.16.0(eslint@9.16.0)(typescript@5.7.2))(eslint@9.16.0)(typescript@5.7.2)': + '@typescript-eslint/eslint-plugin@8.17.0(@typescript-eslint/parser@8.17.0(eslint@9.16.0)(typescript@5.7.2))(eslint@9.16.0)(typescript@5.7.2)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.16.0(eslint@9.16.0)(typescript@5.7.2) - '@typescript-eslint/scope-manager': 8.16.0 - '@typescript-eslint/type-utils': 8.16.0(eslint@9.16.0)(typescript@5.7.2) - '@typescript-eslint/utils': 8.16.0(eslint@9.16.0)(typescript@5.7.2) - '@typescript-eslint/visitor-keys': 8.16.0 + '@typescript-eslint/parser': 8.17.0(eslint@9.16.0)(typescript@5.7.2) + '@typescript-eslint/scope-manager': 8.17.0 + '@typescript-eslint/type-utils': 8.17.0(eslint@9.16.0)(typescript@5.7.2) + '@typescript-eslint/utils': 8.17.0(eslint@9.16.0)(typescript@5.7.2) + '@typescript-eslint/visitor-keys': 8.17.0 eslint: 9.16.0 graphemer: 1.4.0 ignore: 5.3.2 @@ -3958,29 +3934,29 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.16.0(eslint@9.16.0)(typescript@5.7.2)': + '@typescript-eslint/parser@8.17.0(eslint@9.16.0)(typescript@5.7.2)': dependencies: - '@typescript-eslint/scope-manager': 8.16.0 - '@typescript-eslint/types': 8.16.0 - '@typescript-eslint/typescript-estree': 8.16.0(typescript@5.7.2) - '@typescript-eslint/visitor-keys': 8.16.0 - debug: 4.3.7 + '@typescript-eslint/scope-manager': 8.17.0 + '@typescript-eslint/types': 8.17.0 + '@typescript-eslint/typescript-estree': 8.17.0(typescript@5.7.2) + '@typescript-eslint/visitor-keys': 8.17.0 + debug: 4.4.0 eslint: 9.16.0 optionalDependencies: typescript: 5.7.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.16.0': + '@typescript-eslint/scope-manager@8.17.0': dependencies: - '@typescript-eslint/types': 8.16.0 - '@typescript-eslint/visitor-keys': 8.16.0 + '@typescript-eslint/types': 8.17.0 + '@typescript-eslint/visitor-keys': 8.17.0 - '@typescript-eslint/type-utils@8.16.0(eslint@9.16.0)(typescript@5.7.2)': + '@typescript-eslint/type-utils@8.17.0(eslint@9.16.0)(typescript@5.7.2)': dependencies: - '@typescript-eslint/typescript-estree': 8.16.0(typescript@5.7.2) - '@typescript-eslint/utils': 8.16.0(eslint@9.16.0)(typescript@5.7.2) - debug: 4.3.7 + '@typescript-eslint/typescript-estree': 8.17.0(typescript@5.7.2) + '@typescript-eslint/utils': 8.17.0(eslint@9.16.0)(typescript@5.7.2) + debug: 4.4.0 eslint: 9.16.0 ts-api-utils: 1.4.3(typescript@5.7.2) optionalDependencies: @@ -3988,13 +3964,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.16.0': {} + '@typescript-eslint/types@8.17.0': {} - '@typescript-eslint/typescript-estree@8.16.0(typescript@5.7.2)': + '@typescript-eslint/typescript-estree@8.17.0(typescript@5.7.2)': dependencies: - '@typescript-eslint/types': 8.16.0 - '@typescript-eslint/visitor-keys': 8.16.0 - debug: 4.3.7 + '@typescript-eslint/types': 8.17.0 + '@typescript-eslint/visitor-keys': 8.17.0 + debug: 4.4.0 fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 @@ -4005,21 +3981,21 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.16.0(eslint@9.16.0)(typescript@5.7.2)': + '@typescript-eslint/utils@8.17.0(eslint@9.16.0)(typescript@5.7.2)': dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@9.16.0) - '@typescript-eslint/scope-manager': 8.16.0 - '@typescript-eslint/types': 8.16.0 - '@typescript-eslint/typescript-estree': 8.16.0(typescript@5.7.2) + '@typescript-eslint/scope-manager': 8.17.0 + '@typescript-eslint/types': 8.17.0 + '@typescript-eslint/typescript-estree': 8.17.0(typescript@5.7.2) eslint: 9.16.0 optionalDependencies: typescript: 5.7.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.16.0': + '@typescript-eslint/visitor-keys@8.17.0': dependencies: - '@typescript-eslint/types': 8.16.0 + '@typescript-eslint/types': 8.17.0 eslint-visitor-keys: 4.2.0 abort-controller@3.0.0: @@ -4101,13 +4077,13 @@ snapshots: babel-plugin-jest-hoist@29.6.3: dependencies: '@babel/template': 7.25.9 - '@babel/types': 7.26.0 + '@babel/types': 7.26.3 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.6 babel-plugin-polyfill-corejs2@0.4.12(@babel/core@7.26.0): dependencies: - '@babel/compat-data': 7.26.2 + '@babel/compat-data': 7.26.3 '@babel/core': 7.26.0 '@babel/helper-define-polyfill-provider': 0.6.3(@babel/core@7.26.0) semver: 6.3.1 @@ -4192,8 +4168,8 @@ snapshots: browserslist@4.24.2: dependencies: - caniuse-lite: 1.0.30001684 - electron-to-chromium: 1.5.67 + caniuse-lite: 1.0.30001687 + electron-to-chromium: 1.5.71 node-releases: 2.0.18 update-browserslist-db: 1.1.1(browserslist@4.24.2) @@ -4216,7 +4192,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001684: {} + caniuse-lite@1.0.30001687: {} chalk@4.1.2: dependencies: @@ -4316,7 +4292,7 @@ snapshots: dependencies: ms: 2.1.2 - debug@4.3.7: + debug@4.4.0: dependencies: ms: 2.1.3 @@ -4342,7 +4318,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.67: {} + electron-to-chromium@1.5.71: {} emittery@0.13.1: {} @@ -4385,11 +4361,11 @@ snapshots: dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@9.16.0) '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.19.0 - '@eslint/core': 0.9.0 + '@eslint/config-array': 0.19.1 + '@eslint/core': 0.9.1 '@eslint/eslintrc': 3.2.0 '@eslint/js': 9.16.0 - '@eslint/plugin-kit': 0.2.3 + '@eslint/plugin-kit': 0.2.4 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.1 @@ -4398,7 +4374,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.3.7 + debug: 4.4.0 escape-string-regexp: 4.0.0 eslint-scope: 8.2.0 eslint-visitor-keys: 4.2.0 @@ -4690,7 +4666,7 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: '@babel/core': 7.26.0 - '@babel/parser': 7.26.2 + '@babel/parser': 7.26.3 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 @@ -4700,7 +4676,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.26.0 - '@babel/parser': 7.26.2 + '@babel/parser': 7.26.3 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 7.6.3 @@ -4715,7 +4691,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.3.7 + debug: 4.4.0 istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -4909,7 +4885,7 @@ snapshots: jest-util: 29.7.0 jest-validate: 29.7.0 resolve: 1.22.8 - resolve.exports: 2.0.2 + resolve.exports: 2.0.3 slash: 3.0.0 jest-runner@29.7.0: @@ -4968,10 +4944,10 @@ snapshots: jest-snapshot@29.7.0: dependencies: '@babel/core': 7.26.0 - '@babel/generator': 7.26.2 + '@babel/generator': 7.26.3 '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0) - '@babel/types': 7.26.0 + '@babel/types': 7.26.3 '@jest/expect-utils': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 @@ -5168,7 +5144,7 @@ snapshots: mqtt-packet@9.0.1: dependencies: bl: 6.0.16 - debug: 4.3.7 + debug: 4.4.0 process-nextick-args: 2.0.1 transitivePeerDependencies: - supports-color @@ -5179,7 +5155,7 @@ snapshots: '@types/ws': 8.5.13 commist: 3.2.0 concat-stream: 2.0.0 - debug: 4.3.7 + debug: 4.4.0 help-me: 5.0.0 lru-cache: 10.4.3 minimist: 1.2.8 @@ -5228,7 +5204,7 @@ snapshots: number-allocator@1.0.14: dependencies: - debug: 4.3.7 + debug: 4.4.0 js-sdsl: 4.3.0 transitivePeerDependencies: - supports-color @@ -5320,7 +5296,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.4.1: {} + prettier@3.4.2: {} pretty-format@29.7.0: dependencies: @@ -5412,7 +5388,7 @@ snapshots: resolve-from@5.0.0: {} - resolve.exports@2.0.2: {} + resolve.exports@2.0.3: {} resolve@1.22.8: dependencies: @@ -5614,11 +5590,11 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.16.0(eslint@9.16.0)(typescript@5.7.2): + typescript-eslint@8.17.0(eslint@9.16.0)(typescript@5.7.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.16.0(@typescript-eslint/parser@8.16.0(eslint@9.16.0)(typescript@5.7.2))(eslint@9.16.0)(typescript@5.7.2) - '@typescript-eslint/parser': 8.16.0(eslint@9.16.0)(typescript@5.7.2) - '@typescript-eslint/utils': 8.16.0(eslint@9.16.0)(typescript@5.7.2) + '@typescript-eslint/eslint-plugin': 8.17.0(@typescript-eslint/parser@8.17.0(eslint@9.16.0)(typescript@5.7.2))(eslint@9.16.0)(typescript@5.7.2) + '@typescript-eslint/parser': 8.17.0(eslint@9.16.0)(typescript@5.7.2) + '@typescript-eslint/utils': 8.17.0(eslint@9.16.0)(typescript@5.7.2) eslint: 9.16.0 optionalDependencies: typescript: 5.7.2 @@ -5768,11 +5744,11 @@ snapshots: buffer-crc32: 1.0.0 iconv-lite: 0.6.3 semver: 7.6.3 - zigbee-herdsman: 3.0.1 + zigbee-herdsman: 3.0.2 transitivePeerDependencies: - supports-color - zigbee-herdsman@3.0.1: + zigbee-herdsman@3.0.2: dependencies: '@serialport/bindings-cpp': 12.0.1 '@serialport/parser-delimiter': 12.0.0 @@ -5785,4 +5761,4 @@ snapshots: transitivePeerDependencies: - supports-color - zigbee2mqtt-frontend@0.8.0: {} + zigbee2mqtt-frontend@0.8.1: {} diff --git a/test/extensions/configure.test.ts b/test/extensions/configure.test.ts index d29c10dd62..44c4c2e722 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 08e16f61db..8c6126051f 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 747b6d29ce..4e1244ee4a 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/frontend.test.ts b/test/extensions/frontend.test.ts index fc7c634d59..4d51876fdb 100644 --- a/test/extensions/frontend.test.ts +++ b/test/extensions/frontend.test.ts @@ -239,6 +239,7 @@ describe('Extension: Frontend', () => { 'zigbee2mqtt/bulb_color', stringify({ state: 'ON', + effect: null, power_on_behavior: null, linkquality: null, update: {state: null, installed_version: -1, latest_version: -1}, @@ -261,6 +262,7 @@ describe('Extension: Frontend', () => { topic: 'bulb_color', payload: { state: 'ON', + effect: null, power_on_behavior: null, linkquality: null, update: {state: null, installed_version: -1, latest_version: -1}, diff --git a/test/extensions/groups.test.ts b/test/extensions/groups.test.ts index c5eb3347ac..7db66bcd05 100644 --- a/test/extensions/groups.test.ts +++ b/test/extensions/groups.test.ts @@ -266,11 +266,11 @@ describe('Extension: Groups', () => { expect(mockMQTT.publishAsync).toHaveBeenNthCalledWith(2, 'zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0}); }); - it('Should not publish state change off if any lights within are still on when changed via device with non default-ep', async () => { + it('Should not publish state change off if any lights within with non default-ep are still on when changed via device', async () => { const device_1 = devices.bulb_color; const device_2 = devices.QBKG03LM; const endpoint_1 = device_1.getEndpoint(1)!; - const endpoint_2 = device_2.getEndpoint(3)!; + const endpoint_2 = device_2.getEndpoint(2)!; const group = groups.group_1; group.members.push(endpoint_1); group.members.push(endpoint_2); @@ -285,6 +285,31 @@ describe('Extension: Groups', () => { expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb_color', stringify({state: 'OFF'}), {retain: false, qos: 0}); }); + it('Should not publish state change off if any lights within are still on when changed via device with non default-ep', async () => { + const device_1 = devices.bulb_color; + const device_2 = devices.QBKG03LM; + const endpoint_1 = device_1.getEndpoint(1)!; + const endpoint_2 = device_2.getEndpoint(2)!; + const endpoint_3 = device_2.getEndpoint(3)!; + endpoint_3.removeFromGroup(groups.ha_discovery_group); + const group = groups.group_1; + group.members.push(endpoint_1); + group.members.push(endpoint_2); + group.members.push(endpoint_3); + + await mockMQTTEvents.message('zigbee2mqtt/group_1/set', stringify({state: 'ON'})); + await flushPromises(); + mockMQTT.publishAsync.mockClear(); + + await mockMQTTEvents.message('zigbee2mqtt/wall_switch_double/set', stringify({state_left: 'OFF'})); + await flushPromises(); + expect(mockMQTT.publishAsync).toHaveBeenCalledTimes(1); + expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/wall_switch_double', stringify({state_left: 'OFF', state_right: 'ON'}), { + retain: false, + qos: 0, + }); + }); + it('Should publish state change off if all lights within turn off with non default-ep', async () => { const device_1 = devices.bulb_color; const device_2 = devices.QBKG03LM; diff --git a/test/extensions/homeassistant.test.ts b/test/extensions/homeassistant.test.ts index 05561a2d79..816a1fd338 100644 --- a/test/extensions/homeassistant.test.ts +++ b/test/extensions/homeassistant.test.ts @@ -1090,6 +1090,7 @@ describe('Extension: HomeAssistant', () => { stringify({ color: {hue: 0, saturation: 100, h: 0, s: 100}, color_mode: 'hs', + effect: null, linkquality: null, state: null, power_on_behavior: null, @@ -1112,6 +1113,7 @@ describe('Extension: HomeAssistant', () => { stringify({ color: {x: 0.4576, y: 0.41}, color_mode: 'xy', + effect: null, linkquality: null, state: null, power_on_behavior: null, @@ -1133,6 +1135,7 @@ describe('Extension: HomeAssistant', () => { 'zigbee2mqtt/bulb_color', stringify({ linkquality: null, + effect: null, state: 'ON', power_on_behavior: null, update: {state: null, installed_version: -1, latest_version: -1}, @@ -1242,6 +1245,8 @@ describe('Extension: HomeAssistant', () => { color_options: null, brightness: 50, color_temp: 370, + effect: null, + identify: null, linkquality: 99, power_on_behavior: null, update: {state: null, installed_version: -1, latest_version: -1}, @@ -1280,6 +1285,8 @@ describe('Extension: HomeAssistant', () => { color_options: null, brightness: 50, color_temp: 370, + effect: null, + identify: null, linkquality: 99, power_on_behavior: null, update: {state: null, installed_version: -1, latest_version: -1}, @@ -1735,18 +1742,31 @@ describe('Extension: HomeAssistant', () => { await flushPromises(); expect(mockMQTT.publishAsync).toHaveBeenCalledWith( 'zigbee2mqtt/U202DST600ZB', - stringify({state_l2: 'ON', brightness_l2: 20, linkquality: null, state_l1: null, power_on_behavior_l1: null, power_on_behavior_l2: null}), + stringify({ + state_l2: 'ON', + brightness_l2: 20, + linkquality: null, + state_l1: null, + effect_l1: null, + effect_l2: null, + power_on_behavior_l1: null, + power_on_behavior_l2: null, + }), {qos: 0, retain: false}, ); expect(mockMQTT.publishAsync).toHaveBeenCalledWith( 'zigbee2mqtt/U202DST600ZB/l2', - stringify({state: 'ON', brightness: 20, power_on_behavior: null}), + stringify({state: 'ON', brightness: 20, effect: null, power_on_behavior: null}), {qos: 0, retain: false}, ); - expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/U202DST600ZB/l1', stringify({state: null, power_on_behavior: null}), { - qos: 0, - retain: false, - }); + expect(mockMQTT.publishAsync).toHaveBeenCalledWith( + 'zigbee2mqtt/U202DST600ZB/l1', + stringify({state: null, effect: null, power_on_behavior: null}), + { + qos: 0, + retain: false, + }, + ); }); it('Shouldnt crash in onPublishEntityState on group publish', async () => { @@ -2432,7 +2452,6 @@ describe('Extension: HomeAssistant', () => { state_topic: 'zigbee2mqtt/0x18fc26000000cafe', unique_id: '0x18fc26000000cafe_device_mode_zigbee2mqtt', value_template: '{{ value_json.device_mode }}', - enabled_by_default: true, }; expect(mockMQTT.publishAsync).toHaveBeenCalledWith('homeassistant/select/0x18fc26000000cafe/device_mode/config', stringify(payload), { retain: true, diff --git a/test/extensions/otaUpdate.test.ts b/test/extensions/otaUpdate.test.ts index c4ea7eb97a..906bb4d99e 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}, ); }); From f455080438da23d34cc85223c93580b54cb8ddaf Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Sun, 8 Dec 2024 15:13:25 +0100 Subject: [PATCH 4/5] update api --- lib/types/api.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/types/api.ts b/lib/types/api.ts index 59e147a01a..b429dd7160 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -169,7 +169,8 @@ export interface Zigbee2MQTTAPI { channel: number; }; log_level: 'debug' | 'info' | 'warning' | 'error'; - permit_join_timeout: number; + permit_join: boolean; + permit_join_end: number | undefined; restart_required: boolean; config: Settings; config_schema: typeof schemaJson; From e7515836bc26828e777eab54ba32d5b37d0327d0 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Sun, 8 Dec 2024 15:39:54 +0100 Subject: [PATCH 5/5] u --- test/mocks/zigbeeHerdsman.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/mocks/zigbeeHerdsman.ts b/test/mocks/zigbeeHerdsman.ts index a9640d660f..9728c8bc56 100644 --- a/test/mocks/zigbeeHerdsman.ts +++ b/test/mocks/zigbeeHerdsman.ts @@ -1092,7 +1092,8 @@ export const mockController = { touchlinkFactoryResetFirst: jest.fn(), addInstallCode: jest.fn(), permitJoin: jest.fn(), - getPermitJoinTimeout: jest.fn((): number => 0), + getPermitJoin: jest.fn((): boolean => false), + getPermitJoinEnd: jest.fn((): number | undefined => undefined), isStopping: jest.fn((): boolean => false), backup: jest.fn(), coordinatorCheck: jest.fn(),