From 48d77e4b5ef88e985a363fecf45b8c23038b2a11 Mon Sep 17 00:00:00 2001 From: Nerivec <62446222+Nerivec@users.noreply.github.com> Date: Sat, 7 Sep 2024 13:40:50 +0200 Subject: [PATCH] fix: Enforce TS `strict` type checking (#23601) * Enforce TS `strict` type checking. * updates * updates * updates * Updates * Updates * pretty * u * u * u * Updates * updates * Updates * Updates * `ReadonlyArray` * scenesChanged * objectID * Improve coverage * u * u * process feedback --------- Co-authored-by: Koen Kanters --- lib/controller.ts | 42 +- lib/eventBus.ts | 50 +- lib/extension/availability.ts | 29 +- lib/extension/bind.ts | 74 +- lib/extension/bridge.ts | 165 +- lib/extension/configure.ts | 12 +- lib/extension/externalConverters.ts | 2 +- lib/extension/externalExtension.ts | 14 +- lib/extension/frontend.ts | 58 +- lib/extension/groups.ts | 86 +- lib/extension/homeassistant.ts | 1885 +++++++++-------- lib/extension/legacy/bridgeLegacy.ts | 69 +- lib/extension/legacy/deviceGroupMembership.ts | 7 +- lib/extension/legacy/report.ts | 21 +- lib/extension/legacy/softReset.ts | 12 +- lib/extension/networkMap.ts | 37 +- lib/extension/otaUpdate.ts | 54 +- lib/extension/publish.ts | 112 +- lib/extension/receive.ts | 22 +- lib/model/device.ts | 58 +- lib/model/group.ts | 23 +- lib/mqtt.ts | 8 +- lib/state.ts | 10 +- lib/types/types.d.ts | 35 +- lib/util/data.ts | 20 +- lib/util/logger.ts | 7 + lib/util/settings.ts | 191 +- lib/util/utils.ts | 60 +- lib/util/yaml.ts | 25 +- lib/zigbee.ts | 56 +- scripts/zStackEraseAllNvMem.js | 4 +- test/availability.test.js | 14 +- test/bridge.test.js | 28 +- test/controller.test.js | 4 +- test/data.test.js | 4 +- test/homeassistant.test.js | 47 +- test/legacy/bridgeLegacy.test.js | 12 +- test/networkMap.test.js | 8 +- test/publish.test.js | 9 + test/settings.test.js | 2 +- test/stub/zigbeeHerdsman.js | 2 +- tsconfig.json | 1 + 42 files changed, 1899 insertions(+), 1480 deletions(-) diff --git a/lib/controller.ts b/lib/controller.ts index 4d203b0bc..cfa293f7c 100644 --- a/lib/controller.ts +++ b/lib/controller.ts @@ -119,13 +119,28 @@ export class Controller { new ExtensionReport(...this.extensionArgs), new ExtensionExternalExtension(...this.extensionArgs), new ExtensionAvailability(...this.extensionArgs), - settings.get().frontend && new ExtensionFrontend(...this.extensionArgs), - settings.get().advanced.legacy_api && new ExtensionBridgeLegacy(...this.extensionArgs), - settings.get().external_converters.length && new ExtensionExternalConverters(...this.extensionArgs), - settings.get().homeassistant && new ExtensionHomeAssistant(...this.extensionArgs), - /* istanbul ignore next */ - settings.get().advanced.soft_reset_timeout !== 0 && new ExtensionSoftReset(...this.extensionArgs), - ].filter((n) => n); + ]; + + if (settings.get().frontend) { + this.extensions.push(new ExtensionFrontend(...this.extensionArgs)); + } + + if (settings.get().advanced.legacy_api) { + this.extensions.push(new ExtensionBridgeLegacy(...this.extensionArgs)); + } + + if (settings.get().external_converters.length) { + this.extensions.push(new ExtensionExternalConverters(...this.extensionArgs)); + } + + if (settings.get().homeassistant) { + this.extensions.push(new ExtensionHomeAssistant(...this.extensionArgs)); + } + + /* istanbul ignore next */ + if (settings.get().advanced.soft_reset_timeout !== 0) { + this.extensions.push(new ExtensionSoftReset(...this.extensionArgs)); + } } async start(): Promise { @@ -143,7 +158,7 @@ export class Controller { logger.error('Failed to start zigbee'); logger.error('Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html for possible solutions'); logger.error('Exiting...'); - logger.error(error.stack); + logger.error((error as Error).stack!); return this.exit(1); } @@ -160,8 +175,9 @@ export class Controller { let deviceCount = 0; for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) { + // `definition` validated by `isSupported` const model = device.isSupported - ? `${device.definition.model} - ${device.definition.vendor} ${device.definition.description}` + ? `${device.definition!.model} - ${device.definition!.vendor} ${device.definition!.description}` : 'Not supported'; logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`); @@ -180,14 +196,14 @@ export class Controller { await this.zigbee.permitJoin(settings.get().permit_join); } catch (error) { - logger.error(`Failed to set permit join to ${settings.get().permit_join} (${error.message})`); + logger.error(`Failed to set permit join to ${settings.get().permit_join} (${(error as Error).message})`); } // MQTT try { await this.mqtt.connect(); } catch (error) { - logger.error(`MQTT failed to connect, exiting... (${error.message})`); + logger.error(`MQTT failed to connect, exiting... (${(error as Error).message})`); await this.zigbee.stop(); return this.exit(1); } @@ -252,7 +268,7 @@ export class Controller { await this.zigbee.stop(); logger.info('Stopped Zigbee2MQTT'); } catch (error) { - logger.error(`Failed to stop Zigbee2MQTT (${error.message})`); + logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).message})`); code = 1; } @@ -377,7 +393,7 @@ export class Controller { await extension[method]?.(); } catch (error) { /* istanbul ignore next */ - logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${error.stack})`); + logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${(error as Error).stack})`); } } } diff --git a/lib/eventBus.ts b/lib/eventBus.ts index 5a360d217..27d7ede8d 100644 --- a/lib/eventBus.ts +++ b/lib/eventBus.ts @@ -5,9 +5,39 @@ import logger from './util/logger'; // eslint-disable-next-line type ListenerKey = object; +interface EventBusMap { + adapterDisconnected: []; + permitJoinChanged: [data: eventdata.PermitJoinChanged]; + publishAvailability: []; + deviceRenamed: [data: eventdata.EntityRenamed]; + deviceRemoved: [data: eventdata.EntityRemoved]; + lastSeenChanged: [data: eventdata.LastSeenChanged]; + deviceNetworkAddressChanged: [data: eventdata.DeviceNetworkAddressChanged]; + deviceAnnounce: [data: eventdata.DeviceAnnounce]; + deviceInterview: [data: eventdata.DeviceInterview]; + deviceJoined: [data: eventdata.DeviceJoined]; + entityOptionsChanged: [data: eventdata.EntityOptionsChanged]; + exposesChanged: [data: eventdata.ExposesChanged]; + deviceLeave: [data: eventdata.DeviceLeave]; + deviceMessage: [data: eventdata.DeviceMessage]; + mqttMessage: [data: eventdata.MQTTMessage]; + mqttMessagePublished: [data: eventdata.MQTTMessagePublished]; + publishEntityState: [data: eventdata.PublishEntityState]; + groupMembersChanged: [data: eventdata.GroupMembersChanged]; + devicesChanged: []; + scenesChanged: [data: eventdata.ScenesChanged]; + reconfigure: [data: eventdata.Reconfigure]; + stateChange: [data: eventdata.StateChange]; +} +type EventBusListener = K extends keyof EventBusMap + ? EventBusMap[K] extends unknown[] + ? (...args: EventBusMap[K]) => Promise | void + : never + : never; + export default class EventBus { - private callbacksByExtension: {[s: string]: {event: string; callback: (...args: unknown[]) => void}[]} = {}; - private emitter = new events.EventEmitter(); + private callbacksByExtension: {[s: string]: {event: keyof EventBusMap; callback: EventBusListener}[]} = {}; + private emitter = new events.EventEmitter(); constructor() { this.emitter.setMaxListeners(100); @@ -167,18 +197,22 @@ export default class EventBus { this.on('stateChange', callback, key); } - private on(event: string, callback: (...args: unknown[]) => Promise | void, key: ListenerKey): void { - if (!this.callbacksByExtension[key.constructor.name]) this.callbacksByExtension[key.constructor.name] = []; - const wrappedCallback = async (...args: unknown[]): Promise => { + private on(event: K, callback: EventBusListener, key: ListenerKey): void { + if (!this.callbacksByExtension[key.constructor.name]) { + this.callbacksByExtension[key.constructor.name] = []; + } + + const wrappedCallback = async (...args: never[]): Promise => { try { await callback(...args); } catch (error) { - logger.error(`EventBus error '${key.constructor.name}/${event}': ${error.message}`); - logger.debug(error.stack); + logger.error(`EventBus error '${key.constructor.name}/${event}': ${(error as Error).message}`); + logger.debug((error as Error).stack!); } }; + this.callbacksByExtension[key.constructor.name].push({event, callback: wrappedCallback}); - this.emitter.on(event, wrappedCallback); + this.emitter.on(event, wrappedCallback as EventBusListener); } public removeListeners(key: ListenerKey): void { diff --git a/lib/extension/availability.ts b/lib/extension/availability.ts index cd7d7ac61..04a1b2682 100644 --- a/lib/extension/availability.ts +++ b/lib/extension/availability.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import bind from 'bind-decorator'; import debounce from 'debounce'; import * as zhc from 'zigbee-herdsman-converters'; @@ -41,9 +42,12 @@ export default class Availability extends Extension { } private isAvailable(entity: Device | Group): boolean { - return entity.isDevice() - ? Date.now() - entity.zh.lastSeen < this.getTimeout(entity) - : entity.membersDevices().length === 0 || entity.membersDevices().some((d) => this.availabilityCache[d.ieeeAddr]); + if (entity.isDevice()) { + return Date.now() - (entity.zh.lastSeen ?? /* istanbul ignore next */ 0) < this.getTimeout(entity); + } else { + const membersDevices = entity.membersDevices(); + return membersDevices.length === 0 || membersDevices.some((d) => this.availabilityCache[d.ieeeAddr]); + } } private resetTimer(device: Device): void { @@ -92,7 +96,7 @@ export default class Availability extends Extension { logger.debug(`Successfully pinged '${device.name}' (attempt ${i}/${attempts})`); break; } catch (error) { - logger.warning(`Failed to ping '${device.name}' (attempt ${i}/${attempts}, ${error.message})`); + logger.warning(`Failed to ping '${device.name}' (attempt ${i}/${attempts}, ${(error as Error).message})`); // Try again in 3 seconds. if (i !== attempts) { @@ -125,7 +129,7 @@ export default class Availability extends Extension { this.eventBus.onEntityRenamed(this, async (data) => { if (utils.isAvailabilityEnabledForEntity(data.entity, settings.get())) { - await this.mqtt.publish(`${data.from}/availability`, null, {retain: true, qos: 1}); + await this.mqtt.publish(`${data.from}/availability`, '', {retain: true, qos: 1}); await this.publishAvailability(data.entity, false, true); } }); @@ -162,7 +166,8 @@ export default class Availability extends Extension { private async publishAvailability(entity: Device | Group, logLastSeen: boolean, forcePublish = false, skipGroups = false): Promise { if (logLastSeen && entity.isDevice()) { - const ago = Date.now() - entity.zh.lastSeen; + const ago = Date.now() - (entity.zh.lastSeen ?? /* istanbul ignore next */ 0); + if (this.isActiveDevice(entity)) { logger.debug(`Active device '${entity.name}' was last seen '${(ago / utils.minutes(1)).toFixed(2)}' minutes ago.`); } else { @@ -230,22 +235,24 @@ export default class Availability extends Extension { continue; } - const converter = device.definition.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k))); + const converter = device.definition!.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k))); const options: KeyValue = device.options; const state = this.state.get(device); const meta: zhc.Tz.Meta = { message: this.state.get(device), - mapped: device.definition, - endpoint_name: null, + mapped: device.definition!, + endpoint_name: undefined, options, state, device: device.zh, }; try { - await converter?.convertGet?.(device.endpoint(), item.keys[0], meta); + const endpoint = device.endpoint(); + assert(endpoint); + await converter?.convertGet?.(endpoint, item.keys[0], meta); } catch (error) { - logger.error(`Failed to read state of '${device.name}' after reconnect (${error.message})`); + logger.error(`Failed to read state of '${device.name}' after reconnect (${(error as Error).message})`); } await utils.sleep(500); diff --git a/lib/extension/bind.ts b/lib/extension/bind.ts index d8ac3dadc..5ae3f4b16 100755 --- a/lib/extension/bind.ts +++ b/lib/extension/bind.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import bind from 'bind-decorator'; import debounce from 'debounce'; import stringify from 'json-stable-stringify-without-jsonify'; @@ -87,7 +88,7 @@ const REPORT_CLUSTERS: Readonly< type PollOnMessage = { cluster: Readonly>>; read: Readonly<{cluster: string; attributes: string[]; attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise}>; - manufacturerIDs: readonly number[]; + manufacturerIDs: readonly Zcl.ManufacturerCode[]; manufacturerNames: readonly string[]; }[]; @@ -196,10 +197,17 @@ interface ParsedMQTTMessage { type: 'bind' | 'unbind'; sourceKey: string; targetKey: string; - clusters: string[]; + clusters?: string[]; skipDisableReporting: boolean; } +interface DataMessage { + from: ParsedMQTTMessage['sourceKey']; + to: ParsedMQTTMessage['targetKey']; + clusters: ParsedMQTTMessage['clusters']; + skip_disable_reporting?: ParsedMQTTMessage['skipDisableReporting']; +} + export default class Bind extends Extension { private pollDebouncers: {[s: string]: () => void} = {}; @@ -209,11 +217,11 @@ export default class Bind extends Extension { this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged); } - private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage { - let type: ParsedMQTTMessage['type'] = null; - let sourceKey: ParsedMQTTMessage['sourceKey'] = null; - let targetKey: ParsedMQTTMessage['targetKey'] = null; - let clusters: ParsedMQTTMessage['clusters'] = null; + private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage | undefined { + let type: ParsedMQTTMessage['type'] | undefined; + let sourceKey: ParsedMQTTMessage['sourceKey'] | undefined; + let targetKey: ParsedMQTTMessage['targetKey'] | undefined; + let clusters: ParsedMQTTMessage['clusters'] | undefined; let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false; if (LEGACY_API && data.topic.match(LEGACY_TOPIC_REGEX)) { @@ -223,26 +231,29 @@ export default class Bind extends Extension { targetKey = data.message; } else if (data.topic.match(TOPIC_REGEX)) { type = data.topic.endsWith('unbind') ? 'unbind' : 'bind'; - const message = JSON.parse(data.message); + const message: DataMessage = JSON.parse(data.message); sourceKey = message.from; targetKey = message.to; clusters = message.clusters; - skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false; + skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false; + } else { + return undefined; } return {type, sourceKey, targetKey, clusters, skipDisableReporting}; } @bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise { - const {type, sourceKey, targetKey, clusters, skipDisableReporting} = this.parseMQTTMessage(data); + const parsed = this.parseMQTTMessage(data); - if (!type) { - return null; + if (!parsed || !parsed.type) { + return; } + const {type, sourceKey, targetKey, clusters, skipDisableReporting} = parsed; const message = utils.parseJSON(data.message, data.message); - let error = null; + let error: string | undefined; const parsedSource = this.zigbee.resolveEntityAndEndpoint(sourceKey); const parsedTarget = this.zigbee.resolveEntityAndEndpoint(targetKey); const source = parsedSource.entity; @@ -262,9 +273,11 @@ export default class Bind extends Extension { const failedClusters = []; const attemptedClusters = []; - const bindSource: zh.Endpoint = parsedSource.endpoint; - const bindTarget: number | zh.Group | zh.Endpoint = - target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID); + const bindSource = parsedSource.endpoint; + const bindTarget = target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID); + + assert(bindSource != undefined && bindTarget != undefined); + // Find which clusters are supported by both the source and target. // Groups are assumed to support all clusters. const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES; @@ -274,7 +287,7 @@ export default class Bind extends Extension { const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' || (target as Device).zh.type === 'Coordinator'; - if (!anyClusterValid && utils.isEndpoint(bindTarget)) { + if (!anyClusterValid && utils.isZHEndpoint(bindTarget)) { matchingClusters = (bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) || (bindSource.supportsInputCluster(cluster) && bindTarget.supportsOutputCluster(cluster)); @@ -383,7 +396,7 @@ export default class Bind extends Extension { } getSetupReportingEndpoints(bind: zh.Bind, coordinatorEp: zh.Endpoint): zh.Endpoint[] { - const endpoints = utils.isEndpoint(bind.target) ? [bind.target] : bind.target.members; + const endpoints = utils.isZHEndpoint(bind.target) ? [bind.target] : bind.target.members; return endpoints.filter((e) => { if (!e.supportsInputCluster(bind.cluster.name)) { @@ -409,14 +422,14 @@ export default class Bind extends Extension { /* istanbul ignore else */ if (bind.cluster.name in REPORT_CLUSTERS) { for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) { - const entity = `${this.zigbee.resolveEntity(endpoint.getDevice()).name}/${endpoint.ID}`; + const entity = `${this.zigbee.resolveEntity(endpoint.getDevice())!.name}/${endpoint.ID}`; try { await endpoint.bind(bind.cluster.name, coordinatorEndpoint); const items = []; - for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]) { + for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]!) { /* istanbul ignore else */ if (!c.condition || (await c.condition(endpoint))) { const i = {...c}; @@ -429,7 +442,7 @@ export default class Bind extends Extension { await endpoint.configureReporting(bind.cluster.name, items); logger.info(`Successfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`); } catch (error) { - logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}' (${error.message})`); + logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}' (${(error as Error).message})`); } } } @@ -440,7 +453,7 @@ export default class Bind extends Extension { async disableUnnecessaryReportings(target: zh.Group | zh.Endpoint): Promise { const coordinator = this.zigbee.firstCoordinatorEndpoint(); - const endpoints = utils.isEndpoint(target) ? [target] : target.members; + const endpoints = utils.isZHEndpoint(target) ? [target] : target.members; const allBinds: zh.Bind[] = []; for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) { @@ -458,7 +471,7 @@ export default class Bind extends Extension { const boundClusters: string[] = []; for (const bind of allBinds) { - if (utils.isEndpoint(bind.target) ? bind.target === endpoint : bind.target.members.includes(endpoint)) { + if (utils.isZHEndpoint(bind.target) ? bind.target === endpoint : bind.target.members.includes(endpoint)) { requiredClusters.push(bind.cluster.name); } } @@ -476,7 +489,7 @@ export default class Bind extends Extension { const items = []; - for (const item of REPORT_CLUSTERS[cluster as ClusterName]) { + for (const item of REPORT_CLUSTERS[cluster as ClusterName]!) { /* istanbul ignore else */ if (!item.condition || (await item.condition(endpoint))) { const i = {...item}; @@ -489,7 +502,7 @@ export default class Bind extends Extension { await endpoint.configureReporting(cluster, items); logger.info(`Successfully disabled reporting for '${entity}' cluster '${cluster}'`); } catch (error) { - logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${error.message})`); + logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${(error as Error).message})`); } } @@ -516,7 +529,7 @@ export default class Bind extends Extension { // Add bound devices for (const endpoint of data.device.zh.endpoints) { for (const bind of endpoint.binds) { - if (utils.isEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') { + if (utils.isZHEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') { toPoll.add(bind.target); } } @@ -532,10 +545,11 @@ export default class Bind extends Extension { } for (const endpoint of toPoll) { + const device = endpoint.getDevice(); for (const poll of polls) { + // XXX: manufacturerID/manufacturerName can be undefined and won't match `includes`, but TS enforces same-type if ( - (!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID) && - !poll.manufacturerNames.includes(endpoint.getDevice().manufacturerName)) || + (!poll.manufacturerIDs.includes(device.manufacturerID!) && !poll.manufacturerNames.includes(device.manufacturerName!)) || !endpoint.supportsInputCluster(poll.read.cluster) ) { continue; @@ -548,7 +562,7 @@ export default class Bind extends Extension { readAttrs = [...poll.read.attributes, ...attrsForEndpoint]; } - const key = `${endpoint.getDevice().ieeeAddr}_${endpoint.ID}_${POLL_ON_MESSAGE.indexOf(poll)}`; + const key = `${device.ieeeAddr}_${endpoint.ID}_${POLL_ON_MESSAGE.indexOf(poll)}`; if (!this.pollDebouncers[key]) { this.pollDebouncers[key] = debounce(async () => { @@ -556,7 +570,7 @@ export default class Bind extends Extension { await endpoint.read(poll.read.cluster, readAttrs); } catch (error) { logger.error( - `Failed to poll ${readAttrs} from ${this.zigbee.resolveEntity(endpoint.getDevice()).name} (${error.message})`, + `Failed to poll ${readAttrs} from ${this.zigbee.resolveEntity(device)!.name} (${(error as Error).message})`, ); } }, 1000); diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 4c33cffdd..98cdd5fdb 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -27,18 +27,24 @@ type DefinitionPayload = { exposes: zhc.Expose[]; supports_ota: boolean; icon: string; - options: zhc.Expose[]; + options: zhc.Option[]; }; export default class Bridge extends Extension { - private zigbee2mqttVersion: {commitHash: string; version: string}; + // @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 restartRequired = false; - private lastJoinedDeviceIeeeAddr: string; - private lastBridgeLoggingPayload: string; + 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}; override async start(): Promise { @@ -111,10 +117,22 @@ export default class Bridge extends Extension { this.zigbeeHerdsmanConvertersVersion = await utils.getDependencyVersion('zigbee-herdsman-converters'); this.coordinatorVersion = await this.zigbee.getCoordinatorVersion(); - this.eventBus.onEntityRenamed(this, () => this.publishInfo()); - this.eventBus.onGroupMembersChanged(this, () => this.publishGroups()); - this.eventBus.onDevicesChanged(this, () => this.publishDevices() && this.publishInfo() && this.publishDefinitions()); - this.eventBus.onPermitJoinChanged(this, () => !this.zigbee.isStopping() && this.publishInfo()); + this.eventBus.onEntityRenamed(this, async () => { + await this.publishInfo(); + }); + this.eventBus.onGroupMembersChanged(this, async () => { + await this.publishGroups(); + }); + this.eventBus.onDevicesChanged(this, async () => { + await this.publishDevices(); + await this.publishInfo(); + await this.publishDefinitions(); + }); + this.eventBus.onPermitJoinChanged(this, async () => { + if (!this.zigbee.isStopping()) { + await this.publishInfo(); + } + }); this.eventBus.onScenesChanged(this, async () => { await this.publishDevices(); await this.publishGroups(); @@ -133,14 +151,18 @@ export default class Bridge extends Extension { await this.publishDefinitions(); await publishEvent('device_leave', {ieee_address: data.ieeeAddr, friendly_name: data.name}); }); - this.eventBus.onDeviceNetworkAddressChanged(this, () => this.publishDevices()); + 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}; + if (data.status === 'successful') { payload.supported = data.device.isSupported; payload.definition = this.getDefinitionPayload(data.device); } + await publishEvent('device_interview', payload); }); this.eventBus.onDeviceAnnounce(this, async (data) => { @@ -163,7 +185,13 @@ export default class Bridge extends Extension { @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise { const match = data.topic.match(requestRegex); - const key = match?.[1]?.toLowerCase(); + + if (!match) { + return; + } + + const key = match[1].toLowerCase(); + if (key in this.requestLookup) { const message = utils.parseJSON(data.message, data.message); @@ -171,9 +199,9 @@ export default class Bridge extends Extension { const response = await this.requestLookup[key](message); await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response)); } catch (error) { - logger.error(`Request '${data.topic}' failed with error: '${error.message}'`); - logger.debug(error.stack); - const response = utils.getResponse(message, {}, error.message); + logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`); + logger.debug((error as Error).stack!); + const response = utils.getResponse(message, {}, (error as Error).message); await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response)); } } @@ -223,7 +251,7 @@ export default class Bridge extends Extension { logger.info('Successfully changed options'); await this.publishInfo(); - return utils.getResponse(message, {restart_required: this.restartRequired}, null); + return utils.getResponse(message, {restart_required: this.restartRequired}); } @bind async deviceRemove(message: string | KeyValue): Promise { @@ -235,7 +263,7 @@ export default class Bridge extends Extension { } @bind async healthCheck(message: string | KeyValue): Promise { - return utils.getResponse(message, {healthy: true}, null); + return utils.getResponse(message, {healthy: true}); } @bind async coordinatorCheck(message: string | KeyValue): Promise { @@ -243,7 +271,7 @@ export default class Bridge extends Extension { const missingRouters = result.missingRouters.map((d) => { return {ieee_address: d.ieeeAddr, friendly_name: d.name}; }); - return utils.getResponse(message, {missing_routers: missingRouters}, null); + return utils.getResponse(message, {missing_routers: missingRouters}); } @bind async groupAdd(message: string | KeyValue): Promise { @@ -256,7 +284,7 @@ export default class Bridge extends Extension { const group = settings.addGroup(friendlyName, ID); this.zigbee.createGroup(group.ID); await this.publishGroups(); - return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID}, null); + return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID}); } @bind async deviceRename(message: string | KeyValue): Promise { @@ -271,7 +299,7 @@ export default class Bridge extends Extension { // Wait 500 ms before restarting so response can be send. setTimeout(this.restartCallback, 500); logger.info('Restarting Zigbee2MQTT'); - return utils.getResponse(message, {}, null); + return utils.getResponse(message, {}); } @bind async backup(message: string | KeyValue): Promise { @@ -284,7 +312,7 @@ export default class Bridge extends Extension { const zip = new JSZip(); files.forEach((f) => zip.file(f[1], fs.readFileSync(f[0]))); const base64Zip = await zip.generateAsync({type: 'base64'}); - return utils.getResponse(message, {zip: base64Zip}, null); + return utils.getResponse(message, {zip: base64Zip}); } @bind async installCodeAdd(message: KeyValue | string): Promise { @@ -295,7 +323,7 @@ export default class Bridge extends Extension { const value = typeof message === 'object' ? message.value : message; await this.zigbee.addInstallCode(value); logger.info('Successfully added new install code'); - return utils.getResponse(message, {value}, null); + return utils.getResponse(message, {value}); } @bind async permitJoin(message: KeyValue | string): Promise { @@ -304,13 +332,16 @@ export default class Bridge extends Extension { } let value: boolean | string; - let time: number; - let device: Device = null; + let time: number | undefined; + let device: Device | undefined; + if (typeof message === 'object') { value = message.value; time = message.time; + if (message.device) { const resolved = this.zigbee.resolveEntity(message.device); + if (resolved instanceof Device) { device = resolved; } else { @@ -326,10 +357,20 @@ export default class Bridge extends Extension { } await this.zigbee.permitJoin(value, device, time); + const response: {value: boolean; device?: string; time?: number} = {value}; - if (device && typeof message === 'object') response.device = message.device; - if (time && typeof message === 'object') response.time = message.time; - return utils.getResponse(message, response, null); + + if (typeof message === 'object') { + if (device) { + response.device = message.device; + } + + if (time != undefined) { + response.time = message.time; + } + } + + return utils.getResponse(message, response); } // Deprecated @@ -342,7 +383,7 @@ export default class Bridge extends Extension { settings.set(['advanced', 'last_seen'], value); await this.publishInfo(); - return utils.getResponse(message, {value}, null); + return utils.getResponse(message, {value}); } // Deprecated @@ -353,10 +394,10 @@ export default class Bridge extends Extension { throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`); } - await this.enableDisableExtension(value, 'HomeAssistant'); settings.set(['homeassistant'], value); + await this.enableDisableExtension(value, 'HomeAssistant'); await this.publishInfo(); - return utils.getResponse(message, {value}, null); + return utils.getResponse(message, {value}); } // Deprecated @@ -369,7 +410,7 @@ export default class Bridge extends Extension { settings.set(['advanced', 'elapsed'], value); await this.publishInfo(); - return utils.getResponse(message, {value}, null); + return utils.getResponse(message, {value}); } // Deprecated @@ -381,7 +422,7 @@ export default class Bridge extends Extension { logger.setLevel(value); await this.publishInfo(); - return utils.getResponse(message, {value}, null); + return utils.getResponse(message, {value}); } @bind async touchlinkIdentify(message: KeyValue | string): Promise { @@ -391,7 +432,7 @@ export default class Bridge extends Extension { logger.info(`Start Touchlink identify of '${message.ieee_address}' on channel ${message.channel}`); await this.zigbee.touchlinkIdentify(message.ieee_address, message.channel); - return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel}, null); + return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel}); } @bind async touchlinkFactoryReset(message: KeyValue | string): Promise { @@ -409,7 +450,7 @@ export default class Bridge extends Extension { if (result) { logger.info('Successfully factory reset device through Touchlink'); - return utils.getResponse(message, payload, null); + return utils.getResponse(message, payload); } else { logger.error('Failed to factory reset device through Touchlink'); throw new Error('Failed to factory reset device through Touchlink'); @@ -423,7 +464,7 @@ export default class Bridge extends Extension { return {ieee_address: r.ieeeAddr, channel: r.channel}; }); logger.info('Finished Touchlink scan'); - return utils.getResponse(message, {found}, null); + return utils.getResponse(message, {found}); } /** @@ -467,7 +508,7 @@ export default class Bridge extends Extension { logger.info(`Changed config for ${entityType} ${ID}`); this.eventBus.emitEntityOptionsChanged({from: oldOptions, to: newOptions, entity}); - return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired}, null); + return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired}); } @bind async deviceConfigureReporting(message: string | KeyValue): Promise { @@ -484,10 +525,12 @@ export default class Bridge extends Extension { } const device = this.zigbee.resolveEntityAndEndpoint(message.id); - if (!device.entity) throw new Error(`Device '${message.id}' does not exist`); + if (!device.entity) { + throw new Error(`Device '${message.id}' does not exist`); + } const endpoint = device.endpoint; - if (device.endpointID && !endpoint) { + if (!endpoint) { throw new Error(`Device '${device.ID}' does not have endpoint '${device.endpointID}'`); } @@ -511,18 +554,14 @@ export default class Bridge extends Extension { logger.info(`Configured reporting for '${message.id}', '${message.cluster}.${message.attribute}'`); - return utils.getResponse( - message, - { - id: message.id, - cluster: message.cluster, - maximum_report_interval: message.maximum_report_interval, - minimum_report_interval: message.minimum_report_interval, - reportable_change: message.reportable_change, - attribute: message.attribute, - }, - null, - ); + return utils.getResponse(message, { + id: message.id, + cluster: message.cluster, + maximum_report_interval: message.maximum_report_interval, + minimum_report_interval: message.minimum_report_interval, + reportable_change: message.reportable_change, + attribute: message.attribute, + }); } @bind async deviceInterview(message: string | KeyValue): Promise { @@ -545,7 +584,7 @@ export default class Bridge extends Extension { this.eventBus.emitDevicesChanged(); this.eventBus.emitExposesChanged({device}); - return utils.getResponse(message, {id: message.id}, null); + return utils.getResponse(message, {id: message.id}); } @bind async deviceGenerateExternalDefinition(message: string | KeyValue): Promise { @@ -554,15 +593,19 @@ export default class Bridge extends Extension { } const device = this.zigbee.resolveEntityAndEndpoint(message.id).entity as Device; - if (!device) throw new Error(`Device '${message.id}' does not exist`); + + if (!device) { + throw new Error(`Device '${message.id}' does not exist`); + } const source = await zhc.generateExternalDefinitionSource(device.zh); - return utils.getResponse(message, {id: message.id, source}, null); + return utils.getResponse(message, {id: message.id, source}); } async renameEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise { const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true; + if (typeof message !== 'object' || (!message.hasOwnProperty('from') && !deviceAndHasLast) || !message.hasOwnProperty('to')) { throw new Error(`Invalid payload`); } @@ -594,7 +637,7 @@ export default class Bridge extends Extension { // Republish entity state await this.publishEntityState(entity, {}); - return utils.getResponse(message, {from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename}, null); + return utils.getResponse(message, {from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename}); } async removeEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise { @@ -665,10 +708,10 @@ export default class Bridge extends Extension { await this.publishDevices(); // Refresh Cluster definition await this.publishDefinitions(); - return utils.getResponse(message, {id: ID, block, force}, null); + return utils.getResponse(message, {id: ID, block, force}); } else { await this.publishGroups(); - return utils.getResponse(message, {id: ID, force: force}, null); + return utils.getResponse(message, {id: ID, force: force}); } } catch (error) { throw new Error(`Failed to remove ${entityType} '${friendlyName}'${blockForceLog} (${error})`); @@ -685,11 +728,14 @@ export default class Bridge extends Extension { async publishInfo(): Promise { const config = objectAssignDeep({}, settings.get()); + // @ts-expect-error hidden from publish delete config.advanced.network_key; delete config.mqtt.password; + if (config.frontend) { delete config.frontend.auth_token; } + const payload = { version: this.zigbee2mqttVersion.version, commit: this.zigbee2mqttVersion.commitHash, @@ -743,7 +789,7 @@ export default class Bridge extends Extension { }; for (const bind of endpoint.binds) { - const target = utils.isEndpoint(bind.target) + const target = utils.isZHEndpoint(bind.target) ? {type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID} : {type: 'group', id: bind.target.groupID}; data.bindings.push({cluster: bind.cluster.name, target}); @@ -826,9 +872,9 @@ 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 | null { + getDefinitionPayload(device: Device): DefinitionPayload | undefined { if (!device.definition) { - return null; + return undefined; } // TODO: better typing to avoid @ts-expect-error @@ -837,7 +883,8 @@ export default class Bridge extends Extension { let icon = device.options.icon ?? definitionIcon; if (icon) { - icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID)); + /* istanbul ignore next */ + icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID ?? '')); icon = icon.replace('${model}', utils.sanitizeImageParameter(device.definition.model)); } @@ -847,7 +894,7 @@ export default class Bridge extends Extension { description: device.definition.description, exposes: device.exposes(), supports_ota: !!device.definition.ota, - options: device.definition.options, + options: device.definition.options ?? [], icon, }; diff --git a/lib/extension/configure.ts b/lib/extension/configure.ts index f5257a3fb..352badc9b 100644 --- a/lib/extension/configure.ts +++ b/lib/extension/configure.ts @@ -44,7 +44,7 @@ export default class Configure extends Extension { } else if (data.topic === this.topic) { const message = utils.parseJSON(data.message, data.message); const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message; - let error = null; + let error: string | undefined; const device = this.zigbee.resolveEntity(ID); if (!device || !(device instanceof Device)) { @@ -55,7 +55,7 @@ export default class Configure extends Extension { try { await this.configure(device, 'mqtt_message', true, true); } catch (e) { - error = `Failed to configure (${e.message})`; + error = `Failed to configure (${(e as Error).message})`; } } @@ -95,8 +95,12 @@ export default class Configure extends Extension { force = false, throwError = false, ): Promise { + if (!device.definition?.configure) { + return; + } + if (!force) { - if (device.options.disabled || !device.definition?.configure || !device.zh.interviewCompleted) { + if (device.options.disabled || !device.zh.interviewCompleted) { return; } @@ -130,7 +134,7 @@ export default class Configure extends Extension { } catch (error) { this.attempts[device.ieeeAddr]++; const attempt = this.attempts[device.ieeeAddr]; - const msg = `Failed to configure '${device.name}', attempt ${attempt} (${error.stack})`; + const msg = `Failed to configure '${device.name}', attempt ${attempt} (${(error as Error).stack})`; logger.error(msg); if (throwError) { diff --git a/lib/extension/externalConverters.ts b/lib/extension/externalConverters.ts index 7112aff61..598d59a37 100644 --- a/lib/extension/externalConverters.ts +++ b/lib/extension/externalConverters.ts @@ -27,7 +27,7 @@ export default class ExternalConverters extends Extension { } logger.info(`Loaded external converter '${file}'`); } catch (error) { - logger.error(`Failed to load external converter file '${file}' (${error.message})`); + logger.error(`Failed to load external converter file '${file}' (${(error as Error).message})`); logger.error( `Probably there is a syntax error in the file or the external converter is not ` + `compatible with the current Zigbee2MQTT version`, diff --git a/lib/extension/externalExtension.ts b/lib/extension/externalExtension.ts index 6fcffbd79..9d406bba3 100644 --- a/lib/extension/externalExtension.ts +++ b/lib/extension/externalExtension.ts @@ -12,11 +12,13 @@ import Extension from './extension'; const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/extension/(save|remove)`); export default class ExternalExtension extends Extension { - private requestLookup: {[s: string]: (message: KeyValue) => Promise}; + private requestLookup: {[s: string]: (message: KeyValue) => Promise} = { + save: this.saveExtension, + remove: this.removeExtension, + }; override async start(): Promise { this.eventBus.onMQTTMessage(this, this.onMQTTMessage); - this.requestLookup = {save: this.saveExtension, remove: this.removeExtension}; await this.loadUserDefinedExtensions(); await this.publishExtensions(); } @@ -52,7 +54,7 @@ export default class ExternalExtension extends Extension { fs.unlinkSync(extensionFilePath); await this.publishExtensions(); logger.info(`Extension ${name} removed`); - return utils.getResponse(message, {}, null); + return utils.getResponse(message, {}); } else { return utils.getResponse(message, {}, `Extension ${name} doesn't exists`); } @@ -71,7 +73,7 @@ export default class ExternalExtension extends Extension { fs.writeFileSync(extensionFilePath, code); await this.publishExtensions(); logger.info(`Extension ${name} loaded`); - return utils.getResponse(message, {}, null); + return utils.getResponse(message, {}); } @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise { @@ -82,8 +84,8 @@ export default class ExternalExtension extends Extension { const response = await this.requestLookup[match[1].toLowerCase()](message); await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response)); } catch (error) { - logger.error(`Request '${data.topic}' failed with error: '${error.message}'`); - const response = utils.getResponse(message, {}, error.message); + logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`); + const response = utils.getResponse(message, {}, `${(error as Error).message}`); await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response)); } } diff --git a/lib/extension/frontend.ts b/lib/extension/frontend.ts index e46d35b70..985ec1689 100644 --- a/lib/extension/frontend.ts +++ b/lib/extension/frontend.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import bind from 'bind-decorator'; import gzipStatic, {RequestHandler} from 'connect-gzip-static'; import finalhandler from 'finalhandler'; @@ -19,16 +20,37 @@ import Extension from './extension'; * This extension servers the frontend */ export default class Frontend extends Extension { - private mqttBaseTopic = settings.get().mqtt.base_topic; - private host = settings.get().frontend.host; - private port = settings.get().frontend.port; - private sslCert = settings.get().frontend.ssl_cert; - private sslKey = settings.get().frontend.ssl_key; - private authToken = settings.get().frontend.auth_token; - private server: http.Server; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private fileServer: RequestHandler; - private wss: WebSocket.Server = null; + private mqttBaseTopic: string; + private host: string | undefined; + private port: number; + private sslCert: string | undefined; + private sslKey: string | undefined; + private authToken: string | undefined; + private server: http.Server | undefined; + private fileServer: RequestHandler | undefined; + private wss: WebSocket.Server | undefined; + + constructor( + zigbee: Zigbee, + mqtt: MQTT, + state: State, + publishEntityState: PublishEntityState, + eventBus: EventBus, + enableDisableExtension: (enable: boolean, name: string) => Promise, + restartCallback: () => Promise, + addExtension: (extension: Extension) => Promise, + ) { + super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension); + + const frontendSettings = settings.get().frontend; + assert(frontendSettings, 'Frontend extension created without having frontend settings'); + this.host = frontendSettings.host; + this.port = frontendSettings.port; + this.sslCert = frontendSettings.ssl_cert; + this.sslKey = frontendSettings.ssl_key; + this.authToken = frontendSettings.auth_token; + this.mqttBaseTopic = settings.get().mqtt.base_topic; + } private isHttpsConfigured(): boolean { if (this.sslCert && this.sslKey) { @@ -44,8 +66,8 @@ export default class Frontend extends Extension { override async start(): Promise { if (this.isHttpsConfigured()) { const serverOptions = { - key: fs.readFileSync(this.sslKey), - cert: fs.readFileSync(this.sslCert), + key: fs.readFileSync(this.sslKey!), // valid from `isHttpsConfigured` + cert: fs.readFileSync(this.sslCert!), // valid from `isHttpsConfigured` }; this.server = https.createServer(serverOptions, this.onRequest); } else { @@ -90,7 +112,7 @@ export default class Frontend extends Extension { this.wss?.close(); /* istanbul ignore else */ if (this.server) { - return new Promise((cb: () => void) => this.server.close(cb)); + return new Promise((cb: () => void) => this.server!.close(cb)); } } @@ -100,15 +122,15 @@ export default class Frontend extends Extension { } private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void { - const {query} = url.parse(request.url, true); + const {query} = url.parse(request.url!, true); cb(!this.authToken || this.authToken === query.token); } @bind private onUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void { - this.wss.handleUpgrade(request, socket, head, (ws) => { + this.wss!.handleUpgrade(request, socket, head, (ws) => { this.authenticate(request, (isAuthenticated) => { if (isAuthenticated) { - this.wss.emit('connection', ws, request); + this.wss!.emit('connection', ws, request); } else { ws.close(4401, 'Unauthorized'); } @@ -144,7 +166,7 @@ export default class Frontend extends Extension { const lastSeen = settings.get().advanced.last_seen; /* istanbul ignore if */ if (lastSeen !== 'disable') { - payload.last_seen = utils.formatDate(device.zh.lastSeen, lastSeen); + payload.last_seen = utils.formatDate(device.zh.lastSeen ?? 0, lastSeen); } if (device.zh.linkquality !== undefined) { @@ -162,7 +184,7 @@ export default class Frontend extends Extension { const topic = data.topic.substring(this.mqttBaseTopic.length + 1); const payload = utils.parseJSON(data.payload, data.payload); - for (const client of this.wss.clients) { + for (const client of this.wss!.clients) { /* istanbul ignore else */ if (client.readyState === WebSocket.OPEN) { client.send(stringify({topic, payload})); diff --git a/lib/extension/groups.ts b/lib/extension/groups.ts index 21126bfca..04de37758 100644 --- a/lib/extension/groups.ts +++ b/lib/extension/groups.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import bind from 'bind-decorator'; import equals from 'fast-deep-equal/es6'; import stringify from 'json-stable-stringify-without-jsonify'; @@ -7,7 +8,7 @@ import Device from '../model/device'; import Group from '../model/group'; import logger from '../util/logger'; import * as settings from '../util/settings'; -import utils from '../util/utils'; +import utils, {isLightExpose} from '../util/utils'; import Extension from './extension'; const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`); @@ -16,27 +17,27 @@ const LEGACY_TOPIC_REGEX_REMOVE_ALL = new RegExp(`^${settings.get().mqtt.base_to const STATE_PROPERTIES: Readonly boolean>> = { state: () => true, - brightness: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'brightness')), - color_temp: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_temp')), - color: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_xy' || f.name === 'color_hs')), + brightness: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'brightness')), + color_temp: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'color_temp')), + color: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'color_xy' || f.name === 'color_hs')), color_mode: (value, exposes) => exposes.some( (e) => - e.type === 'light' && + isLightExpose(e) && (e.features.some((f) => f.name === `color_${value}`) || (value === 'color_temp' && e.features.some((f) => f.name === 'color_temp'))), ), }; interface ParsedMQTTMessage { type: 'remove' | 'add' | 'remove_all'; - resolvedEntityGroup: Group; + resolvedEntityGroup?: Group; resolvedEntityDevice: Device; - error: string; - groupKey: string; - deviceKey: string; + error?: string; + groupKey?: string; + deviceKey?: string; triggeredViaLegacyApi: boolean; skipDisableReporting: boolean; - resolvedEntityEndpoint: zh.Endpoint; + resolvedEntityEndpoint?: zh.Endpoint; } export default class Groups extends Extension { @@ -69,7 +70,7 @@ export default class Groups extends Extension { } } catch (error) { logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`); - logger.debug(error.stack); + logger.debug((error as Error).stack!); } }; @@ -105,7 +106,7 @@ export default class Groups extends Extension { // In zigbee but not in settings for (const endpoint of zigbeeGroup.zh.members) { if (!settingsEndpoints.includes(endpoint)) { - const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name; + const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr)!.friendly_name; await addRemoveFromGroup('remove', deviceName, settingGroup.friendly_name, endpoint, zigbeeGroup); } @@ -114,7 +115,7 @@ export default class Groups extends Extension { for (const zigbeeGroup of this.zigbee.groupsIterator((zg) => !settingsGroups.some((sg) => sg.ID === zg.groupID))) { for (const endpoint of zigbeeGroup.zh.members) { - const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name; + const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr)!.friendly_name; await addRemoveFromGroup('remove', deviceName, zigbeeGroup.ID, endpoint, zigbeeGroup); } @@ -129,7 +130,7 @@ export default class Groups extends Extension { } const payload: KeyValue = {}; - let endpointName: string = null; + let endpointName: string | undefined; const endpointNames: string[] = data.entity instanceof Device ? data.entity.getEndpointNames() : []; for (let prop of Object.keys(data.update)) { @@ -159,15 +160,20 @@ export default class Groups extends Extension { } if (entity instanceof Device) { - for (const group of groups) { - if ( - group.zh.hasMember(entity.endpoint(endpointName)) && - !equals(this.lastOptimisticState[group.ID], payload) && - this.shouldPublishPayloadForGroup(group, payload) - ) { - this.lastOptimisticState[group.ID] = payload; + const endpoint = entity.endpoint(endpointName); - await this.publishEntityState(group, payload, reason); + /* istanbul ignore else */ + if (endpoint) { + for (const group of groups) { + if ( + group.zh.hasMember(endpoint) && + !equals(this.lastOptimisticState[group.ID], payload) && + this.shouldPublishPayloadForGroup(group, payload) + ) { + this.lastOptimisticState[group.ID] = payload; + + await this.publishEntityState(group, payload, reason); + } } } } else { @@ -225,7 +231,7 @@ export default class Groups extends Extension { private areAllMembersOff(group: Group): boolean { for (const member of group.zh.members) { - const device = this.zigbee.resolveEntity(member.getDevice()); + const device = this.zigbee.resolveEntity(member.getDevice())!; if (this.state.exists(device)) { const state = this.state.get(device); @@ -239,14 +245,14 @@ export default class Groups extends Extension { return true; } - private async parseMQTTMessage(data: eventdata.MQTTMessage): Promise { - let type: ParsedMQTTMessage['type'] = null; - let resolvedEntityGroup: ParsedMQTTMessage['resolvedEntityGroup'] = null; - let resolvedEntityDevice: ParsedMQTTMessage['resolvedEntityDevice'] = null; - let resolvedEntityEndpoint: ParsedMQTTMessage['resolvedEntityEndpoint'] = null; - let error: ParsedMQTTMessage['error'] = null; - let groupKey: ParsedMQTTMessage['groupKey'] = null; - let deviceKey: ParsedMQTTMessage['deviceKey'] = null; + private async parseMQTTMessage(data: eventdata.MQTTMessage): Promise { + let type: ParsedMQTTMessage['type'] | undefined; + let resolvedEntityGroup: ParsedMQTTMessage['resolvedEntityGroup'] | undefined; + let resolvedEntityDevice: ParsedMQTTMessage['resolvedEntityDevice'] | undefined; + let resolvedEntityEndpoint: ParsedMQTTMessage['resolvedEntityEndpoint'] | undefined; + let error: ParsedMQTTMessage['error'] | undefined; + let groupKey: ParsedMQTTMessage['groupKey'] | undefined; + let deviceKey: ParsedMQTTMessage['deviceKey'] | undefined; let triggeredViaLegacyApi: ParsedMQTTMessage['triggeredViaLegacyApi'] = false; let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false; @@ -272,7 +278,7 @@ export default class Groups extends Extension { await this.mqtt.publish('bridge/log', stringify({type: `device_group_${type}_failed`, message})); } - return null; + return undefined; } } else { type = 'remove_all'; @@ -286,19 +292,19 @@ export default class Groups extends Extension { /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { - const message = {friendly_name: data.message, group: legacyTopicRegexMatch[1], error: "entity doesn't exists"}; + const message = {friendly_name: data.message, group: legacyTopicRegexMatch![1], error: "entity doesn't exists"}; await this.mqtt.publish('bridge/log', stringify({type: `device_group_${type}_failed`, message})); } - return null; + return undefined; } resolvedEntityEndpoint = parsedEntity.endpoint; if (parsedEntity.endpointID && !resolvedEntityEndpoint) { logger.error(`Device '${parsedEntity.ID}' does not have endpoint '${parsedEntity.endpointID}'`); - return null; + return undefined; } } else if (topicRegexMatch) { type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all'; @@ -329,6 +335,8 @@ export default class Groups extends Extension { error = `Device '${parsed.ID}' does not have endpoint '${parsed.endpointID}'`; } } + } else { + return undefined; } return { @@ -365,6 +373,7 @@ export default class Groups extends Extension { const changedGroups: Group[] = []; if (!error) { + assert(resolvedEntityEndpoint, '`resolvedEntityEndpoint` is missing'); try { const keys = [ `${resolvedEntityDevice.ieeeAddr}/${resolvedEntityEndpoint.ID}`, @@ -383,6 +392,7 @@ export default class Groups extends Extension { } if (type === 'add') { + assert(resolvedEntityGroup, '`resolvedEntityGroup` is missing'); logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`); await resolvedEntityEndpoint.addToGroup(resolvedEntityGroup.zh); settings.addDeviceToGroup(resolvedEntityGroup.ID.toString(), keys); @@ -395,6 +405,7 @@ export default class Groups extends Extension { await this.mqtt.publish('bridge/log', stringify({type: `device_group_add`, message})); } } else if (type === 'remove') { + assert(resolvedEntityGroup, '`resolvedEntityGroup` is missing'); logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`); await resolvedEntityEndpoint.removeFromGroup(resolvedEntityGroup.zh); settings.removeDeviceFromGroup(resolvedEntityGroup.ID.toString(), keys); @@ -428,8 +439,8 @@ export default class Groups extends Extension { } } } catch (e) { - error = `Failed to ${type} from group (${e.message})`; - logger.debug(e.stack); + error = `Failed to ${type} from group (${(e as Error).message})`; + logger.debug((e as Error).stack!); } } @@ -447,6 +458,7 @@ export default class Groups extends Extension { if (error) { logger.error(error); } else { + assert(resolvedEntityEndpoint, '`resolvedEntityEndpoint` is missing'); for (const group of changedGroups) { this.eventBus.emitGroupMembersChanged({group, action: type, endpoint: resolvedEntityEndpoint, skipDisableReporting}); } diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index 14a73238e..b9dc7ea74 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -5,12 +5,12 @@ import * as zhc from 'zigbee-herdsman-converters'; import logger from '../util/logger'; import * as settings from '../util/settings'; -import utils, {isNumericExposeFeature, isBinaryExposeFeature, isEnumExposeFeature} from '../util/utils'; +import utils, {isNumericExpose, isBinaryExpose, isEnumExpose, assertBinaryExpose, assertNumericExpose, assertEnumExpose} from '../util/utils'; import Extension from './extension'; interface MockProperty { property: string; - value: KeyValue | string; + value: KeyValue | string | null; } // eslint-disable-next-line camelcase @@ -21,7 +21,14 @@ interface DiscoveryEntry { discovery_payload: KeyValue; } -const sensorClick: DiscoveryEntry = { +interface Discovered { + mockProperties: Set; + messages: {[s: string]: {payload: string; published: boolean}}; + triggers: Set; + discovered: boolean; +} + +const SENSOR_CLICK: Readonly = { type: 'sensor', object_id: 'click', mockProperties: [{property: 'click', value: null}], @@ -32,19 +39,15 @@ const sensorClick: DiscoveryEntry = { }, }; -interface Discovered { - mockProperties: Set; - messages: {[s: string]: {payload: string; published: boolean}}; - triggers: Set; - discovered: boolean; -} - const ACCESS_STATE = 0b001; const ACCESS_SET = 0b010; -const groupSupportedTypes = ['light', 'switch', 'lock', 'cover']; -const defaultStatusTopic = 'homeassistant/status'; - -const legacyMapping = [ +const GROUP_SUPPORTED_TYPES: ReadonlyArray = ['light', 'switch', 'lock', 'cover']; +const DEFAULT_STATUS_TOPIC = 'homeassistant/status'; +const COVER_OPENING_LOOKUP: ReadonlyArray = ['opening', 'open', 'forward', 'up', 'rising']; +const COVER_CLOSING_LOOKUP: ReadonlyArray = ['closing', 'close', 'backward', 'back', 'reverse', 'down', 'declining']; +const COVER_STOPPED_LOOKUP: ReadonlyArray = ['stopped', 'stop', 'pause', 'paused']; +const SWITCH_DIFFERENT: ReadonlyArray = ['valve_detection', 'window_detection', 'auto_lock', 'away_mode']; +const LEGACY_MAPPING: ReadonlyArray<{models: string[]; discovery: DiscoveryEntry}> = [ { models: [ 'WXKG01LM', @@ -76,7 +79,7 @@ const legacyMapping = [ 'QBKG12LM', 'E1743', ], - discovery: sensorClick, + discovery: SENSOR_CLICK, }, { models: ['ICTC-G-1'], @@ -93,6 +96,266 @@ const legacyMapping = [ }, }, ]; +const BINARY_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = { + activity_led_indicator: {icon: 'mdi:led-on'}, + auto_off: {icon: 'mdi:flash-auto'}, + battery_low: {entity_category: 'diagnostic', device_class: 'battery'}, + button_lock: {entity_category: 'config', icon: 'mdi:lock'}, + calibration: {entity_category: 'config', icon: 'mdi:progress-wrench'}, + capabilities_configurable_curve: {entity_category: 'diagnostic', icon: 'mdi:tune'}, + capabilities_forward_phase_control: {entity_category: 'diagnostic', icon: 'mdi:tune'}, + capabilities_overload_detection: {entity_category: 'diagnostic', icon: 'mdi:tune'}, + capabilities_reactance_discriminator: {entity_category: 'diagnostic', icon: 'mdi:tune'}, + capabilities_reverse_phase_control: {entity_category: 'diagnostic', icon: 'mdi:tune'}, + carbon_monoxide: {device_class: 'carbon_monoxide'}, + card: {entity_category: 'config', icon: 'mdi:clipboard-check'}, + child_lock: {entity_category: 'config', icon: 'mdi:account-lock'}, + color_sync: {entity_category: 'config', icon: 'mdi:sync-circle'}, + consumer_connected: {device_class: 'plug'}, + contact: {device_class: 'door'}, + garage_door_contact: {device_class: 'garage_door', payload_on: false, payload_off: true}, + eco_mode: {entity_category: 'config', icon: 'mdi:leaf'}, + expose_pin: {entity_category: 'config', icon: 'mdi:pin'}, + flip_indicator_light: {entity_category: 'config', icon: 'mdi:arrow-left-right'}, + gas: {device_class: 'gas'}, + indicator_mode: {entity_category: 'config', icon: 'mdi:led-on'}, + invert_cover: {entity_category: 'config', icon: 'mdi:arrow-left-right'}, + led_disabled_night: {entity_category: 'config', icon: 'mdi:led-off'}, + led_indication: {entity_category: 'config', icon: 'mdi:led-on'}, + led_enable: {entity_category: 'config', icon: 'mdi:led-on'}, + legacy: {entity_category: 'config', icon: 'mdi:cog'}, + motor_reversal: {entity_category: 'config', icon: 'mdi:arrow-left-right'}, + moving: {device_class: 'moving'}, + no_position_support: {entity_category: 'config', icon: 'mdi:minus-circle-outline'}, + noise_detected: {device_class: 'sound'}, + occupancy: {device_class: 'occupancy'}, + power_outage_memory: {entity_category: 'config', icon: 'mdi:memory'}, + presence: {device_class: 'presence'}, + setup: {device_class: 'running'}, + smoke: {device_class: 'smoke'}, + sos: {device_class: 'safety'}, + schedule: {icon: 'mdi:calendar'}, + status_capacitive_load: {entity_category: 'diagnostic', icon: 'mdi:tune'}, + status_forward_phase_control: {entity_category: 'diagnostic', icon: 'mdi:tune'}, + status_inductive_load: {entity_category: 'diagnostic', icon: 'mdi:tune'}, + status_overload: {entity_category: 'diagnostic', icon: 'mdi:tune'}, + status_reverse_phase_control: {entity_category: 'diagnostic', icon: 'mdi:tune'}, + tamper: {device_class: 'tamper'}, + temperature_scale: {entity_category: 'config', icon: 'mdi:temperature-celsius'}, + test: {entity_category: 'diagnostic', icon: 'mdi:test-tube'}, + th_heater: {icon: 'mdi:heat-wave'}, + trigger_indicator: {icon: 'mdi:led-on'}, + valve_alarm: {device_class: 'problem'}, + valve_detection: {icon: 'mdi:pipe-valve'}, + valve_state: {device_class: 'opening'}, + vibration: {device_class: 'vibration'}, + water_leak: {device_class: 'moisture'}, + window: {device_class: 'window'}, + window_detection: {icon: 'mdi:window-open-variant'}, + window_open: {device_class: 'window'}, +} as const; +const NUMERIC_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = { + ac_frequency: {device_class: 'frequency', enabled_by_default: false, entity_category: 'diagnostic', state_class: 'measurement'}, + action_duration: {icon: 'mdi:timer', device_class: 'duration'}, + alarm_humidity_max: {device_class: 'humidity', entity_category: 'config', icon: 'mdi:water-plus'}, + alarm_humidity_min: {device_class: 'humidity', entity_category: 'config', icon: 'mdi:water-minus'}, + alarm_temperature_max: {device_class: 'temperature', entity_category: 'config', icon: 'mdi:thermometer-high'}, + alarm_temperature_min: {device_class: 'temperature', entity_category: 'config', icon: 'mdi:thermometer-low'}, + angle: {icon: 'angle-acute'}, + angle_axis: {icon: 'angle-acute'}, + aqi: {device_class: 'aqi', state_class: 'measurement'}, + auto_relock_time: {entity_category: 'config', icon: 'mdi:timer'}, + away_preset_days: {entity_category: 'config', icon: 'mdi:timer'}, + away_preset_temperature: {entity_category: 'config', icon: 'mdi:thermometer'}, + ballast_maximum_level: {entity_category: 'config'}, + ballast_minimum_level: {entity_category: 'config'}, + ballast_physical_maximum_level: {entity_category: 'diagnostic'}, + ballast_physical_minimum_level: {entity_category: 'diagnostic'}, + battery: {device_class: 'battery', state_class: 'measurement'}, + battery2: {device_class: 'battery', entity_category: 'diagnostic', state_class: 'measurement'}, + battery_voltage: {device_class: 'voltage', entity_category: 'diagnostic', state_class: 'measurement', enabled_by_default: true}, + boost_heating_countdown: {device_class: 'duration'}, + boost_heating_countdown_time_set: {entity_category: 'config', icon: 'mdi:timer'}, + boost_time: {entity_category: 'config', icon: 'mdi:timer'}, + calibration: {entity_category: 'config', icon: 'mdi:wrench-clock'}, + calibration_time: {entity_category: 'config', icon: 'mdi:wrench-clock'}, + co2: {device_class: 'carbon_dioxide', state_class: 'measurement'}, + comfort_temperature: {entity_category: 'config', icon: 'mdi:thermometer'}, + cpu_temperature: { + device_class: 'temperature', + entity_category: 'diagnostic', + state_class: 'measurement', + }, + cube_side: {icon: 'mdi:cube'}, + current: { + device_class: 'current', + enabled_by_default: false, + entity_category: 'diagnostic', + state_class: 'measurement', + }, + current_phase_b: { + device_class: 'current', + enabled_by_default: false, + entity_category: 'diagnostic', + state_class: 'measurement', + }, + current_phase_c: { + device_class: 'current', + enabled_by_default: false, + entity_category: 'diagnostic', + state_class: 'measurement', + }, + deadzone_temperature: {entity_category: 'config', icon: 'mdi:thermometer'}, + detection_interval: {icon: 'mdi:timer'}, + device_temperature: { + device_class: 'temperature', + entity_category: 'diagnostic', + state_class: 'measurement', + }, + duration: {entity_category: 'config', icon: 'mdi:timer'}, + eco2: {device_class: 'carbon_dioxide', state_class: 'measurement'}, + eco_temperature: {entity_category: 'config', icon: 'mdi:thermometer'}, + energy: {device_class: 'energy', state_class: 'total_increasing'}, + external_temperature_input: {icon: 'mdi:thermometer'}, + formaldehyd: {state_class: 'measurement'}, + gas_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'}, + hcho: {icon: 'mdi:air-filter', state_class: 'measurement'}, + humidity: {device_class: 'humidity', state_class: 'measurement'}, + humidity_calibration: {entity_category: 'config', icon: 'mdi:wrench-clock'}, + humidity_max: {entity_category: 'config', icon: 'mdi:water-percent'}, + humidity_min: {entity_category: 'config', icon: 'mdi:water-percent'}, + illuminance_calibration: {entity_category: 'config', icon: 'mdi:wrench-clock'}, + illuminance_lux: {device_class: 'illuminance', state_class: 'measurement'}, + illuminance: {device_class: 'illuminance', enabled_by_default: false, state_class: 'measurement'}, + linkquality: { + enabled_by_default: false, + entity_category: 'diagnostic', + icon: 'mdi:signal', + state_class: 'measurement', + }, + local_temperature: {device_class: 'temperature', state_class: 'measurement'}, + max_temperature: {entity_category: 'config', icon: 'mdi:thermometer-high'}, + max_temperature_limit: {entity_category: 'config', icon: 'mdi:thermometer-high'}, + min_temperature_limit: {entity_category: 'config', icon: 'mdi:thermometer-low'}, + min_temperature: {entity_category: 'config', icon: 'mdi:thermometer-low'}, + minimum_on_level: {entity_category: 'config'}, + measurement_poll_interval: {entity_category: 'config', icon: 'mdi:clock-out'}, + noise: {device_class: 'sound_pressure', state_class: 'measurement'}, + noise_detect_level: {icon: 'mdi:volume-equal'}, + noise_timeout: {icon: 'mdi:timer'}, + occupancy_level: {icon: 'mdi:motion-sensor'}, + occupancy_sensitivity: {icon: 'mdi:motion-sensor'}, + occupancy_timeout: {entity_category: 'config', icon: 'mdi:timer'}, + overload_protection: {icon: 'mdi:flash'}, + pm10: {device_class: 'pm10', state_class: 'measurement'}, + pm25: {device_class: 'pm25', state_class: 'measurement'}, + people: {state_class: 'measurement', icon: 'mdi:account-multiple'}, + position: {icon: 'mdi:valve', state_class: 'measurement'}, + power: {device_class: 'power', entity_category: 'diagnostic', state_class: 'measurement'}, + power_factor: {device_class: 'power_factor', enabled_by_default: false, entity_category: 'diagnostic', state_class: 'measurement'}, + power_outage_count: {icon: 'mdi:counter', enabled_by_default: false}, + precision: {entity_category: 'config', icon: 'mdi:decimal-comma-increase'}, + pressure: {device_class: 'atmospheric_pressure', state_class: 'measurement'}, + presence_timeout: {entity_category: 'config', icon: 'mdi:timer'}, + reporting_time: {entity_category: 'config', icon: 'mdi:clock-time-one-outline'}, + requested_brightness_level: { + enabled_by_default: false, + entity_category: 'diagnostic', + icon: 'mdi:brightness-5', + }, + requested_brightness_percent: { + enabled_by_default: false, + entity_category: 'diagnostic', + icon: 'mdi:brightness-5', + }, + smoke_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'}, + soil_moisture: {device_class: 'moisture', state_class: 'measurement'}, + temperature: {device_class: 'temperature', state_class: 'measurement'}, + temperature_calibration: {entity_category: 'config', icon: 'mdi:wrench-clock'}, + temperature_max: {entity_category: 'config', icon: 'mdi:thermometer-plus'}, + temperature_min: {entity_category: 'config', icon: 'mdi:thermometer-minus'}, + temperature_offset: {icon: 'mdi:thermometer-lines'}, + transition: {entity_category: 'config', icon: 'mdi:transition'}, + trigger_count: {icon: 'mdi:counter', enabled_by_default: false}, + voc: {device_class: 'volatile_organic_compounds', state_class: 'measurement'}, + voc_index: {state_class: 'measurement', icon: 'mdi:molecule'}, + voc_parts: {device_class: 'volatile_organic_compounds_parts', state_class: 'measurement'}, + vibration_timeout: {entity_category: 'config', icon: 'mdi:timer'}, + voltage: { + device_class: 'voltage', + enabled_by_default: false, + entity_category: 'diagnostic', + state_class: 'measurement', + }, + voltage_phase_b: { + device_class: 'voltage', + enabled_by_default: false, + entity_category: 'diagnostic', + state_class: 'measurement', + }, + voltage_phase_c: { + device_class: 'voltage', + enabled_by_default: false, + entity_category: 'diagnostic', + state_class: 'measurement', + }, + water_consumed: { + device_class: 'water', + state_class: 'total_increasing', + }, + x_axis: {icon: 'mdi:axis-x-arrow'}, + y_axis: {icon: 'mdi:axis-y-arrow'}, + z_axis: {icon: 'mdi:axis-z-arrow'}, +} as const; +const ENUM_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = { + action: {icon: 'mdi:gesture-double-tap'}, + alarm_humidity: {entity_category: 'config', icon: 'mdi:water-percent-alert'}, + alarm_temperature: {entity_category: 'config', icon: 'mdi:thermometer-alert'}, + backlight_auto_dim: {entity_category: 'config', icon: 'mdi:brightness-auto'}, + backlight_mode: {entity_category: 'config', icon: 'mdi:lightbulb'}, + calibrate: {icon: 'mdi:tune'}, + color_power_on_behavior: {entity_category: 'config', icon: 'mdi:palette'}, + control_mode: {entity_category: 'config', icon: 'mdi:tune'}, + device_mode: {entity_category: 'config', icon: 'mdi:tune'}, + effect: {enabled_by_default: false, icon: 'mdi:palette'}, + force: {entity_category: 'config', icon: 'mdi:valve'}, + keep_time: {entity_category: 'config', icon: 'mdi:av-timer'}, + identify: {device_class: 'identify'}, + keypad_lockout: {entity_category: 'config', icon: 'mdi:lock'}, + load_detection_mode: {entity_category: 'config', icon: 'mdi:tune'}, + load_dimmable: {entity_category: 'config', icon: 'mdi:chart-bell-curve'}, + load_type: {entity_category: 'config', icon: 'mdi:led-on'}, + melody: {entity_category: 'config', icon: 'mdi:music-note'}, + mode_phase_control: {entity_category: 'config', icon: 'mdi:tune'}, + mode: {entity_category: 'config', icon: 'mdi:tune'}, + mode_switch: {icon: 'mdi:tune'}, + motion_sensitivity: {entity_category: 'config', icon: 'mdi:tune'}, + operation_mode: {entity_category: 'config', icon: 'mdi:tune'}, + power_on_behavior: {entity_category: 'config', icon: 'mdi:power-settings'}, + power_outage_memory: {entity_category: 'config', icon: 'mdi:power-settings'}, + power_supply_mode: {entity_category: 'config', icon: 'mdi:power-settings'}, + power_type: {entity_category: 'config', icon: 'mdi:lightning-bolt-circle'}, + restart: {device_class: 'restart'}, + sensitivity: {entity_category: 'config', icon: 'mdi:tune'}, + sensor: {icon: 'mdi:tune'}, + sensors_type: {entity_category: 'config', icon: 'mdi:tune'}, + sound_volume: {entity_category: 'config', icon: 'mdi:volume-high'}, + status: {icon: 'mdi:state-machine'}, + switch_type: {entity_category: 'config', icon: 'mdi:tune'}, + temperature_display_mode: {entity_category: 'config', icon: 'mdi:thermometer'}, + temperature_sensor_select: {entity_category: 'config', icon: 'mdi:home-thermometer'}, + thermostat_unit: {entity_category: 'config', icon: 'mdi:thermometer'}, + update: {device_class: 'update'}, + volume: {entity_category: 'config', icon: 'mdi: volume-high'}, + week: {entity_category: 'config', icon: 'mdi:calendar-clock'}, +} as const; +const LIST_DISCOVERY_LOOKUP: {[s: string]: KeyValue} = { + action: {icon: 'mdi:gesture-double-tap'}, + color_options: {icon: 'mdi:palette'}, + level_config: {entity_category: 'diagnostic'}, + programming_mode: {icon: 'mdi:calendar-clock'}, + schedule_settings: {icon: 'mdi:calendar-clock'}, +} as const; const featurePropertyWithoutEndpoint = (feature: zhc.Feature): string => { if (feature.endpoint) { @@ -162,14 +425,19 @@ class Bridge { */ export default class HomeAssistant extends Extension { private discovered: {[s: string]: Discovered} = {}; - private discoveryTopic = settings.get().homeassistant.discovery_topic; - private discoveryRegex = new RegExp(`${settings.get().homeassistant.discovery_topic}/(.*)/(.*)/(.*)/config`); + private discoveryTopic: string; + private discoveryRegex: RegExp; private discoveryRegexWoTopic = new RegExp(`(.*)/(.*)/(.*)/config`); - private statusTopic = settings.get().homeassistant.status_topic; - private entityAttributes = settings.get().homeassistant.legacy_entity_attributes; + private statusTopic: string; + private entityAttributes: boolean; + private legacyTrigger: boolean; + // @ts-expect-error initialized in `start` private zigbee2MQTTVersion: string; + // @ts-expect-error initialized in `start` private discoveryOrigin: {name: string; sw: string; url: string}; + // @ts-expect-error initialized in `start` private bridge: Bridge; + // @ts-expect-error initialized in `start` private bridgeIdentifier: string; constructor( @@ -187,7 +455,14 @@ export default class HomeAssistant extends Extension { throw new Error('Home Assistant integration is not possible with attribute output!'); } - if (settings.get().homeassistant.discovery_topic === settings.get().mqtt.base_topic) { + const haSettings = settings.get().homeassistant; + assert(haSettings, 'Home Assistant extension used without settings'); + this.discoveryTopic = haSettings.discovery_topic; + this.discoveryRegex = new RegExp(`${haSettings.discovery_topic}/(.*)/(.*)/(.*)/config`); + this.statusTopic = haSettings.status_topic; + this.entityAttributes = haSettings.legacy_entity_attributes; + this.legacyTrigger = haSettings.legacy_triggers; + if (haSettings.discovery_topic === settings.get().mqtt.base_topic) { throw new Error(`'homeassistant.discovery_topic' cannot not be equal to the 'mqtt.base_topic' (got '${settings.get().mqtt.base_topic}')`); } } @@ -215,7 +490,7 @@ export default class HomeAssistant extends Extension { this.eventBus.onExposesChanged(this, async (data) => this.discover(data.device)); this.mqtt.subscribe(this.statusTopic); - this.mqtt.subscribe(defaultStatusTopic); + this.mqtt.subscribe(DEFAULT_STATUS_TOPIC); /** * Prevent unnecessary re-discovery of entities by waiting 5 seconds for retained discovery messages to come in. @@ -266,917 +541,697 @@ export default class HomeAssistant extends Extension { // to use for a bulb (e.g. color_xy/color_temp) assert(entityType === 'group' || exposes.length === 1, 'Multiple exposes for device not allowed'); const firstExpose = exposes[0]; - assert(entityType === 'device' || groupSupportedTypes.includes(firstExpose.type), `Unsupported expose type ${firstExpose.type} for group`); + assert(entityType === 'device' || GROUP_SUPPORTED_TYPES.includes(firstExpose.type), `Unsupported expose type ${firstExpose.type} for group`); const discoveryEntries: DiscoveryEntry[] = []; const endpoint = entityType === 'device' ? exposes[0].endpoint : undefined; const getProperty = (feature: zhc.Feature): string => (entityType === 'group' ? featurePropertyWithoutEndpoint(feature) : feature.property); - /* istanbul ignore else */ - if (firstExpose.type === 'light') { - const hasColorXY = exposes.find((expose) => expose.features.find((e) => e.name === 'color_xy')); - const hasColorHS = exposes.find((expose) => expose.features.find((e) => e.name === 'color_hs')); - const hasBrightness = exposes.find((expose) => expose.features.find((e) => e.name === 'brightness')); - const hasColorTemp = exposes.find((expose) => expose.features.find((e) => e.name === 'color_temp')); - const state = firstExpose.features.find((f) => f.name === 'state'); - // Prefer HS over XY when at least one of the lights in the group prefers HS over XY. - // A light prefers HS over XY when HS is earlier in the feature array than HS. - const preferHS = - exposes - .map((e) => [e.features.findIndex((ee) => ee.name === 'color_xy'), e.features.findIndex((ee) => ee.name === 'color_hs')]) - .filter((d) => d[0] !== -1 && d[1] !== -1 && d[1] < d[0]).length !== 0; + switch (firstExpose.type) { + case 'light': { + const hasColorXY = (exposes as zhc.Light[]).find((expose) => expose.features.find((e) => e.name === 'color_xy')); + const hasColorHS = (exposes as zhc.Light[]).find((expose) => expose.features.find((e) => e.name === 'color_hs')); + const hasBrightness = (exposes as zhc.Light[]).find((expose) => expose.features.find((e) => e.name === 'brightness')); + const hasColorTemp = (exposes as zhc.Light[]).find((expose) => expose.features.find((e) => e.name === 'color_temp')); + const state = (firstExpose as zhc.Light).features.find((f) => f.name === 'state'); + assert(state, `Light expose must have a 'state'`); + // Prefer HS over XY when at least one of the lights in the group prefers HS over XY. + // A light prefers HS over XY when HS is earlier in the feature array than HS. + const preferHS = + (exposes as zhc.Light[]) + .map((e) => [e.features.findIndex((ee) => ee.name === 'color_xy'), e.features.findIndex((ee) => ee.name === 'color_hs')]) + .filter((d) => d[0] !== -1 && d[1] !== -1 && d[1] < d[0]).length !== 0; - const discoveryEntry: DiscoveryEntry = { - type: 'light', - object_id: endpoint ? `light_${endpoint}` : 'light', - mockProperties: [{property: state.property, value: null}], - discovery_payload: { - name: endpoint ? utils.capitalize(endpoint) : null, - brightness: !!hasBrightness, - schema: 'json', - command_topic: true, - brightness_scale: 254, - command_topic_prefix: endpoint, - state_topic_postfix: endpoint, - }, - }; - - const colorModes = [ - hasColorXY && !preferHS ? 'xy' : null, - (!hasColorXY || preferHS) && hasColorHS ? 'hs' : null, - hasColorTemp ? 'color_temp' : null, - ].filter((c) => c); - - if (colorModes.length) { - discoveryEntry.discovery_payload.supported_color_modes = colorModes; - } - - if (hasColorTemp) { - const colorTemps = exposes - .map((expose) => expose.features.find((e) => e.name === 'color_temp')) - .filter((e) => e) - .filter(isNumericExposeFeature); - const max = Math.min(...colorTemps.map((e) => e.value_max)); - const min = Math.max(...colorTemps.map((e) => e.value_min)); - discoveryEntry.discovery_payload.max_mireds = max; - discoveryEntry.discovery_payload.min_mireds = min; - } - - const effects = utils.arrayUnique( - utils.flatten( - allExposes - .filter(isEnumExposeFeature) - .filter((e) => e.name === 'effect') - .map((e) => e.values), - ), - ); - if (effects.length) { - discoveryEntry.discovery_payload.effect = true; - discoveryEntry.discovery_payload.effect_list = effects; - } - - discoveryEntries.push(discoveryEntry); - } else if (firstExpose.type === 'switch') { - const state = firstExpose.features.filter(isBinaryExposeFeature).find((f) => f.name === 'state'); - const property = getProperty(state); - const discoveryEntry: DiscoveryEntry = { - type: 'switch', - object_id: endpoint ? `switch_${endpoint}` : 'switch', - mockProperties: [{property: property, value: null}], - discovery_payload: { - name: endpoint ? utils.capitalize(endpoint) : null, - payload_off: state.value_off, - payload_on: state.value_on, - value_template: `{{ value_json.${property} }}`, - command_topic: true, - command_topic_prefix: endpoint, - }, - }; - - const different = ['valve_detection', 'window_detection', 'auto_lock', 'away_mode']; - if (different.includes(property)) { - discoveryEntry.discovery_payload.name = firstExpose.label; - discoveryEntry.discovery_payload.command_topic_postfix = property; - discoveryEntry.discovery_payload.state_off = state.value_off; - discoveryEntry.discovery_payload.state_on = state.value_on; - discoveryEntry.object_id = property; - - if (property === 'window_detection') { - discoveryEntry.discovery_payload.icon = 'mdi:window-open-variant'; - } - } - - discoveryEntries.push(discoveryEntry); - } else if (firstExpose.type === 'climate') { - const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint']; - const setpoint = firstExpose.features.filter(isNumericExposeFeature).find((f) => setpointProperties.includes(f.name)); - assert(setpoint, 'No setpoint found'); - const temperature = firstExpose.features.find((f) => f.name === 'local_temperature'); - assert(temperature, 'No temperature found'); - - const discoveryEntry: DiscoveryEntry = { - type: 'climate', - object_id: endpoint ? `climate_${endpoint}` : 'climate', - mockProperties: [], - discovery_payload: { - name: endpoint ? utils.capitalize(endpoint) : null, - // Static - state_topic: false, - temperature_unit: 'C', - // Setpoint - temp_step: setpoint.value_step, - min_temp: setpoint.value_min.toString(), - max_temp: setpoint.value_max.toString(), - // Temperature - current_temperature_topic: true, - current_temperature_template: `{{ value_json.${temperature.property} }}`, - command_topic_prefix: endpoint, - }, - }; - - const mode = firstExpose.features.filter(isEnumExposeFeature).find((f) => f.name === 'system_mode'); - if (mode) { - if (mode.values.includes('sleep')) { - // 'sleep' is not supported by Home Assistant, but is valid according to ZCL - // TRV that support sleep (e.g. Viessmann) will have it removed from here, - // this allows other expose consumers to still use it, e.g. the frontend. - mode.values.splice(mode.values.indexOf('sleep'), 1); - } - discoveryEntry.discovery_payload.mode_state_topic = true; - discoveryEntry.discovery_payload.mode_state_template = `{{ value_json.${mode.property} }}`; - discoveryEntry.discovery_payload.modes = mode.values; - discoveryEntry.discovery_payload.mode_command_topic = true; - } - - const state = firstExpose.features.find((f) => f.name === 'running_state'); - if (state) { - discoveryEntry.mockProperties.push({property: state.property, value: null}); - discoveryEntry.discovery_payload.action_topic = true; - discoveryEntry.discovery_payload.action_template = - `{% set values = ` + - `{None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'}` + - ` %}{{ values[value_json.${state.property}] }}`; - } - - const coolingSetpoint = firstExpose.features.find((f) => f.name === 'occupied_cooling_setpoint'); - if (coolingSetpoint) { - discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name; - discoveryEntry.discovery_payload.temperature_low_state_template = `{{ value_json.${setpoint.property} }}`; - discoveryEntry.discovery_payload.temperature_low_state_topic = true; - discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name; - discoveryEntry.discovery_payload.temperature_high_state_template = `{{ value_json.${coolingSetpoint.property} }}`; - discoveryEntry.discovery_payload.temperature_high_state_topic = true; - } else { - discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name; - discoveryEntry.discovery_payload.temperature_state_template = `{{ value_json.${setpoint.property} }}`; - discoveryEntry.discovery_payload.temperature_state_topic = true; - } - - const fanMode = firstExpose.features.filter(isEnumExposeFeature).find((f) => f.name === 'fan_mode'); - if (fanMode) { - discoveryEntry.discovery_payload.fan_modes = fanMode.values; - discoveryEntry.discovery_payload.fan_mode_command_topic = true; - discoveryEntry.discovery_payload.fan_mode_state_template = `{{ value_json.${fanMode.property} }}`; - discoveryEntry.discovery_payload.fan_mode_state_topic = true; - } - - const swingMode = firstExpose.features.filter(isEnumExposeFeature).find((f) => f.name === 'swing_mode'); - if (swingMode) { - discoveryEntry.discovery_payload.swing_modes = swingMode.values; - discoveryEntry.discovery_payload.swing_mode_command_topic = true; - discoveryEntry.discovery_payload.swing_mode_state_template = `{{ value_json.${swingMode.property} }}`; - discoveryEntry.discovery_payload.swing_mode_state_topic = true; - } - - const preset = firstExpose.features.filter(isEnumExposeFeature).find((f) => f.name === 'preset'); - if (preset) { - discoveryEntry.discovery_payload.preset_modes = preset.values; - discoveryEntry.discovery_payload.preset_mode_command_topic = 'preset'; - discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${preset.property} }}`; - discoveryEntry.discovery_payload.preset_mode_state_topic = true; - } - - const tempCalibration = firstExpose.features.filter(isNumericExposeFeature).find((f) => f.name === 'local_temperature_calibration'); - if (tempCalibration) { const discoveryEntry: DiscoveryEntry = { - type: 'number', - object_id: endpoint ? `${tempCalibration.name}_${endpoint}` : `${tempCalibration.name}`, - mockProperties: [{property: tempCalibration.property, value: null}], + type: 'light', + object_id: endpoint ? `light_${endpoint}` : 'light', + mockProperties: [{property: state.property, value: null}], discovery_payload: { - name: endpoint ? `${tempCalibration.label} ${endpoint}` : tempCalibration.label, - value_template: `{{ value_json.${tempCalibration.property} }}`, + name: endpoint ? utils.capitalize(endpoint) : null, + brightness: !!hasBrightness, + schema: 'json', command_topic: true, + brightness_scale: 254, command_topic_prefix: endpoint, - command_topic_postfix: tempCalibration.property, - device_class: 'temperature', - entity_category: 'config', - icon: 'mdi:math-compass', - ...(tempCalibration.unit && {unit_of_measurement: tempCalibration.unit}), + state_topic_postfix: endpoint, }, }; - if (tempCalibration.value_min != null) discoveryEntry.discovery_payload.min = tempCalibration.value_min; - if (tempCalibration.value_max != null) discoveryEntry.discovery_payload.max = tempCalibration.value_max; - if (tempCalibration.value_step != null) { - discoveryEntry.discovery_payload.step = tempCalibration.value_step; + const colorModes = [ + hasColorXY && !preferHS ? 'xy' : null, + (!hasColorXY || preferHS) && hasColorHS ? 'hs' : null, + hasColorTemp ? 'color_temp' : null, + ].filter((c) => c); + + if (colorModes.length) { + discoveryEntry.discovery_payload.supported_color_modes = colorModes; } - discoveryEntries.push(discoveryEntry); - } - const piHeatingDemand = firstExpose.features.filter(isNumericExposeFeature).find((f) => f.name === 'pi_heating_demand'); - if (piHeatingDemand) { - const discoveryEntry: DiscoveryEntry = { - type: 'sensor', - object_id: endpoint ? `${piHeatingDemand.name}_${endpoint}` : `${piHeatingDemand.name}`, - mockProperties: [{property: piHeatingDemand.property, value: null}], - discovery_payload: { - name: endpoint ? `${piHeatingDemand.label} ${endpoint}` : piHeatingDemand.label, - value_template: `{{ value_json.${piHeatingDemand.property} }}`, - ...(piHeatingDemand.unit && {unit_of_measurement: piHeatingDemand.unit}), - entity_category: 'diagnostic', - icon: 'mdi:radiator', - }, - }; - - discoveryEntries.push(discoveryEntry); - } - - discoveryEntries.push(discoveryEntry); - } else if (firstExpose.type === 'lock') { - assert(!endpoint, `Endpoint not supported for lock type`); - const state = firstExpose.features.filter(isBinaryExposeFeature).find((f) => f.name === 'state'); - assert(state, 'No state found'); - const discoveryEntry: DiscoveryEntry = { - type: 'lock', - object_id: 'lock', - mockProperties: [{property: state.property, value: null}], - discovery_payload: { - name: null, - command_topic: true, - value_template: `{{ value_json.${state.property} }}`, - }, - }; - - if (state.property === 'keypad_lockout') { - // deprecated: keypad_lockout is messy, but changing is breaking - discoveryEntry.discovery_payload.name = firstExpose.label; - discoveryEntry.discovery_payload.payload_lock = state.value_on; - discoveryEntry.discovery_payload.payload_unlock = state.value_off; - discoveryEntry.discovery_payload.state_topic = true; - discoveryEntry.object_id = 'keypad_lock'; - } else if (state.property === 'child_lock') { - // deprecated: child_lock is messy, but changing is breaking - discoveryEntry.discovery_payload.name = firstExpose.label; - discoveryEntry.discovery_payload.payload_lock = state.value_on; - discoveryEntry.discovery_payload.payload_unlock = state.value_off; - discoveryEntry.discovery_payload.state_locked = 'LOCK'; - discoveryEntry.discovery_payload.state_unlocked = 'UNLOCK'; - discoveryEntry.discovery_payload.state_topic = true; - discoveryEntry.object_id = 'child_lock'; - } else { - discoveryEntry.discovery_payload.state_locked = state.value_on; - discoveryEntry.discovery_payload.state_unlocked = state.value_off; - } - - if (state.property !== 'state') { - discoveryEntry.discovery_payload.command_topic_postfix = state.property; - } - - discoveryEntries.push(discoveryEntry); - } else if (firstExpose.type === 'cover') { - const state = exposes.find((expose) => expose.features.find((e) => e.name === 'state'))?.features.find((f) => f.name === 'state'); - const position = exposes - .find((expose) => expose.features.find((e) => e.name === 'position')) - ?.features.find((f) => f.name === 'position'); - const tilt = exposes.find((expose) => expose.features.find((e) => e.name === 'tilt'))?.features.find((f) => f.name === 'tilt'); - const motorState = allExposes - ?.filter(isEnumExposeFeature) - .find((e) => ['motor_state', 'moving'].includes(e.name) && e.access === ACCESS_STATE); - const running = allExposes?.find((e) => e.type === 'binary' && e.name === 'running'); - - const discoveryEntry: DiscoveryEntry = { - type: 'cover', - mockProperties: [{property: state.property, value: null}], - object_id: endpoint ? `cover_${endpoint}` : 'cover', - discovery_payload: { - name: endpoint ? utils.capitalize(endpoint) : null, - command_topic_prefix: endpoint, - command_topic: true, - state_topic: true, - state_topic_postfix: endpoint, - }, - }; - - // If curtains have `running` property, use this in discovery. - // The movement direction is calculated (assumed) in this case. - if (running) { - discoveryEntry.discovery_payload.value_template = - `{% if "${running.property}" in value_json ` + - `and value_json.${running.property} %} {% if value_json.${position.property} > 0 %} closing ` + - `{% else %} opening {% endif %} {% else %} stopped {% endif %}`; - } - - // If curtains have `motor_state` or `moving` property, lookup for possible - // state names to detect movement direction and use this in discovery. - if (motorState) { - const openingLookup = ['opening', 'open', 'forward', 'up', 'rising']; - const closingLookup = ['closing', 'close', 'backward', 'back', 'reverse', 'down', 'declining']; - const stoppedLookup = ['stopped', 'stop', 'pause', 'paused']; - - const openingState = motorState.values.find((s) => openingLookup.includes(s.toString().toLowerCase())); - const closingState = motorState.values.find((s) => closingLookup.includes(s.toString().toLowerCase())); - const stoppedState = motorState.values.find((s) => stoppedLookup.includes(s.toString().toLowerCase())); - - if (openingState && closingState && stoppedState) { - discoveryEntry.discovery_payload.state_opening = openingState; - discoveryEntry.discovery_payload.state_closing = closingState; - discoveryEntry.discovery_payload.state_stopped = stoppedState; - discoveryEntry.discovery_payload.value_template = - `{% if "${motorState.property}" in value_json ` + - `and value_json.${motorState.property} %} {{ value_json.${motorState.property} }} {% else %} ` + - `${stoppedState} {% endif %}`; + if (hasColorTemp) { + const colorTemps = (exposes as zhc.Light[]) + .map((expose) => expose.features.find((e) => e.name === 'color_temp')) + .filter((e) => e !== undefined && isNumericExpose(e)); + const max = Math.min(...colorTemps.map((e) => e.value_max).filter((e) => e !== undefined)); + const min = Math.max(...colorTemps.map((e) => e.value_min).filter((e) => e !== undefined)); + discoveryEntry.discovery_payload.max_mireds = max; + discoveryEntry.discovery_payload.min_mireds = min; } - } - // If curtains do not have `running`, `motor_state` or `moving` properties. - if (!discoveryEntry.discovery_payload.value_template) { - discoveryEntry.discovery_payload.value_template = `{{ value_json.${featurePropertyWithoutEndpoint(state)} }}`; - discoveryEntry.discovery_payload.state_open = 'OPEN'; - discoveryEntry.discovery_payload.state_closed = 'CLOSE'; - discoveryEntry.discovery_payload.state_stopped = 'STOP'; - } - - if (!position && !tilt) { - discoveryEntry.discovery_payload.optimistic = true; - } - - if (position) { - discoveryEntry.discovery_payload = { - ...discoveryEntry.discovery_payload, - position_template: `{{ value_json.${featurePropertyWithoutEndpoint(position)} }}`, - set_position_template: `{ "${getProperty(position)}": {{ position }} }`, - set_position_topic: true, - position_topic: true, - }; - } - - if (tilt) { - discoveryEntry.discovery_payload = { - ...discoveryEntry.discovery_payload, - tilt_command_topic: true, - tilt_status_topic: true, - tilt_status_template: `{{ value_json.${featurePropertyWithoutEndpoint(tilt)} }}`, - }; - } - - discoveryEntries.push(discoveryEntry); - } else if (firstExpose.type === 'fan') { - assert(!endpoint, `Endpoint not supported for fan type`); - const discoveryEntry: DiscoveryEntry = { - type: 'fan', - object_id: 'fan', - mockProperties: [{property: 'fan_state', value: null}], - discovery_payload: { - name: null, - state_topic: true, - state_value_template: '{{ value_json.fan_state }}', - command_topic: true, - command_topic_postfix: 'fan_state', - }, - }; - - const speed = firstExpose.features.filter(isEnumExposeFeature).find((e) => e.name === 'mode'); - if (speed) { - // A fan entity in Home Assistant 2021.3 and above may have a speed, - // controlled by a percentage from 1 to 100, and/or non-speed presets. - // The MQTT Fan integration allows the speed percentage to be mapped - // to a narrower range of speeds (e.g. 1-3), and for these speeds to be - // translated to and from MQTT messages via templates. - // - // For the fixed fan modes in ZCL hvacFanCtrl, we model speeds "low", - // "medium", and "high" as three speeds covering the full percentage - // range as done in Home Assistant's zigpy fan integration, plus - // presets "on", "auto" and "smart" to cover the remaining modes in - // ZCL. This supports a generic ZCL HVAC Fan Control fan. "Off" is - // always a valid speed. - let speeds = ['off'].concat( - ['low', 'medium', 'high', '1', '2', '3', '4', '5', '6', '7', '8', '9'].filter((s) => speed.values.includes(s)), + const effects = utils.arrayUnique( + utils.flatten( + allExposes + .filter(isEnumExpose) + .filter((e) => e.name === 'effect') + .map((e) => e.values), + ), ); - let presets = ['on', 'auto', 'smart'].filter((s) => speed.values.includes(s)); - - if (['99432'].includes(definition.model)) { - // The Hampton Bay 99432 fan implements 4 speeds using the ZCL - // hvacFanCtrl values `low`, `medium`, `high`, and `on`, and - // 1 preset called "Comfort Breeze" using the ZCL value `smart`. - // ZCL value `auto` is unused. - speeds = ['off', 'low', 'medium', 'high', 'on']; - presets = ['smart']; + if (effects.length) { + discoveryEntry.discovery_payload.effect = true; + discoveryEntry.discovery_payload.effect_list = effects; } - const allowed = [...speeds, ...presets]; - speed.values.forEach((s) => assert(allowed.includes(s.toString()))); - const percentValues = speeds.map((s, i) => `'${s}':${i}`).join(', '); - const percentCommands = speeds.map((s, i) => `${i}:'${s}'`).join(', '); - const presetList = presets.map((s) => `'${s}'`).join(', '); - - discoveryEntry.discovery_payload.percentage_state_topic = true; - discoveryEntry.discovery_payload.percentage_command_topic = true; - discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${speed.property}] | default('None') }}`; - discoveryEntry.discovery_payload.percentage_command_template = `{{ {${percentCommands}}[value] | default('') }}`; - discoveryEntry.discovery_payload.speed_range_min = 1; - discoveryEntry.discovery_payload.speed_range_max = speeds.length - 1; - assert(presets.length !== 0); - discoveryEntry.discovery_payload.preset_mode_state_topic = true; - discoveryEntry.discovery_payload.preset_mode_command_topic = 'fan_mode'; - discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${speed.property} if value_json.${speed.property} in [${presetList}] else 'None' | default('None') }}`; - discoveryEntry.discovery_payload.preset_modes = presets; + discoveryEntries.push(discoveryEntry); + break; } - - discoveryEntries.push(discoveryEntry); - } else if (isBinaryExposeFeature(firstExpose)) { - const lookup: {[s: string]: KeyValue} = { - activity_led_indicator: {icon: 'mdi:led-on'}, - auto_off: {icon: 'mdi:flash-auto'}, - battery_low: {entity_category: 'diagnostic', device_class: 'battery'}, - button_lock: {entity_category: 'config', icon: 'mdi:lock'}, - calibration: {entity_category: 'config', icon: 'mdi:progress-wrench'}, - capabilities_configurable_curve: {entity_category: 'diagnostic', icon: 'mdi:tune'}, - capabilities_forward_phase_control: {entity_category: 'diagnostic', icon: 'mdi:tune'}, - capabilities_overload_detection: {entity_category: 'diagnostic', icon: 'mdi:tune'}, - capabilities_reactance_discriminator: {entity_category: 'diagnostic', icon: 'mdi:tune'}, - capabilities_reverse_phase_control: {entity_category: 'diagnostic', icon: 'mdi:tune'}, - carbon_monoxide: {device_class: 'carbon_monoxide'}, - card: {entity_category: 'config', icon: 'mdi:clipboard-check'}, - child_lock: {entity_category: 'config', icon: 'mdi:account-lock'}, - color_sync: {entity_category: 'config', icon: 'mdi:sync-circle'}, - consumer_connected: {device_class: 'plug'}, - contact: {device_class: 'door'}, - garage_door_contact: {device_class: 'garage_door', payload_on: false, payload_off: true}, - eco_mode: {entity_category: 'config', icon: 'mdi:leaf'}, - expose_pin: {entity_category: 'config', icon: 'mdi:pin'}, - flip_indicator_light: {entity_category: 'config', icon: 'mdi:arrow-left-right'}, - gas: {device_class: 'gas'}, - indicator_mode: {entity_category: 'config', icon: 'mdi:led-on'}, - invert_cover: {entity_category: 'config', icon: 'mdi:arrow-left-right'}, - led_disabled_night: {entity_category: 'config', icon: 'mdi:led-off'}, - led_indication: {entity_category: 'config', icon: 'mdi:led-on'}, - led_enable: {entity_category: 'config', icon: 'mdi:led-on'}, - legacy: {entity_category: 'config', icon: 'mdi:cog'}, - motor_reversal: {entity_category: 'config', icon: 'mdi:arrow-left-right'}, - moving: {device_class: 'moving'}, - no_position_support: {entity_category: 'config', icon: 'mdi:minus-circle-outline'}, - noise_detected: {device_class: 'sound'}, - occupancy: {device_class: 'occupancy'}, - power_outage_memory: {entity_category: 'config', icon: 'mdi:memory'}, - presence: {device_class: 'presence'}, - setup: {device_class: 'running'}, - smoke: {device_class: 'smoke'}, - sos: {device_class: 'safety'}, - schedule: {icon: 'mdi:calendar'}, - status_capacitive_load: {entity_category: 'diagnostic', icon: 'mdi:tune'}, - status_forward_phase_control: {entity_category: 'diagnostic', icon: 'mdi:tune'}, - status_inductive_load: {entity_category: 'diagnostic', icon: 'mdi:tune'}, - status_overload: {entity_category: 'diagnostic', icon: 'mdi:tune'}, - status_reverse_phase_control: {entity_category: 'diagnostic', icon: 'mdi:tune'}, - tamper: {device_class: 'tamper'}, - temperature_scale: {entity_category: 'config', icon: 'mdi:temperature-celsius'}, - test: {entity_category: 'diagnostic', icon: 'mdi:test-tube'}, - th_heater: {icon: 'mdi:heat-wave'}, - trigger_indicator: {icon: 'mdi:led-on'}, - valve_alarm: {device_class: 'problem'}, - valve_detection: {icon: 'mdi:pipe-valve'}, - valve_state: {device_class: 'opening'}, - vibration: {device_class: 'vibration'}, - water_leak: {device_class: 'moisture'}, - window: {device_class: 'window'}, - window_detection: {icon: 'mdi:window-open-variant'}, - window_open: {device_class: 'window'}, - }; - - /** - * If Z2M binary attribute has SET access then expose it as `switch` in HA - * There is also a check on the values for typeof boolean to prevent invalid values and commands - * silently failing - commands work fine but some devices won't reject unexpected values. - * https://github.com/Koenkk/zigbee2mqtt/issues/7740 - */ - if (firstExpose.access & ACCESS_SET) { + case 'switch': { + const state = (firstExpose as zhc.Switch).features.filter(isBinaryExpose).find((f) => f.name === 'state'); + assert(state, `Switch expose must have a 'state'`); + const property = getProperty(state); const discoveryEntry: DiscoveryEntry = { type: 'switch', - mockProperties: [{property: firstExpose.property, value: null}], - object_id: endpoint ? `switch_${firstExpose.name}_${endpoint}` : `switch_${firstExpose.name}`, + object_id: endpoint ? `switch_${endpoint}` : 'switch', + mockProperties: [{property: property, value: null}], discovery_payload: { - name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, - value_template: - typeof firstExpose.value_on === 'boolean' - ? `{% if value_json.${firstExpose.property} %} true {% else %} false {% endif %}` - : `{{ value_json.${firstExpose.property} }}`, - payload_on: firstExpose.value_on.toString(), - payload_off: firstExpose.value_off.toString(), + name: endpoint ? utils.capitalize(endpoint) : null, + payload_off: state.value_off, + payload_on: state.value_on, + value_template: `{{ value_json.${property} }}`, command_topic: true, command_topic_prefix: endpoint, - command_topic_postfix: firstExpose.property, - ...(lookup[firstExpose.name] || {}), }, }; - discoveryEntries.push(discoveryEntry); - } else { - const discoveryEntry: DiscoveryEntry = { - type: 'binary_sensor', - 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} }}`, - payload_on: firstExpose.value_on, - payload_off: firstExpose.value_off, - ...(lookup[firstExpose.name] || {}), - }, - }; + if (SWITCH_DIFFERENT.includes(property)) { + discoveryEntry.discovery_payload.name = firstExpose.label; + discoveryEntry.discovery_payload.command_topic_postfix = property; + discoveryEntry.discovery_payload.state_off = state.value_off; + discoveryEntry.discovery_payload.state_on = state.value_on; + discoveryEntry.object_id = property; + + if (property === 'window_detection') { + discoveryEntry.discovery_payload.icon = 'mdi:window-open-variant'; + } + } discoveryEntries.push(discoveryEntry); + break; } - } else if (isNumericExposeFeature(firstExpose)) { - const lookup: {[s: string]: KeyValue} = { - ac_frequency: {device_class: 'frequency', enabled_by_default: false, entity_category: 'diagnostic', state_class: 'measurement'}, - action_duration: {icon: 'mdi:timer', device_class: 'duration'}, - alarm_humidity_max: {device_class: 'humidity', entity_category: 'config', icon: 'mdi:water-plus'}, - alarm_humidity_min: {device_class: 'humidity', entity_category: 'config', icon: 'mdi:water-minus'}, - alarm_temperature_max: {device_class: 'temperature', entity_category: 'config', icon: 'mdi:thermometer-high'}, - alarm_temperature_min: {device_class: 'temperature', entity_category: 'config', icon: 'mdi:thermometer-low'}, - angle: {icon: 'angle-acute'}, - angle_axis: {icon: 'angle-acute'}, - aqi: {device_class: 'aqi', state_class: 'measurement'}, - auto_relock_time: {entity_category: 'config', icon: 'mdi:timer'}, - away_preset_days: {entity_category: 'config', icon: 'mdi:timer'}, - away_preset_temperature: {entity_category: 'config', icon: 'mdi:thermometer'}, - ballast_maximum_level: {entity_category: 'config'}, - ballast_minimum_level: {entity_category: 'config'}, - ballast_physical_maximum_level: {entity_category: 'diagnostic'}, - ballast_physical_minimum_level: {entity_category: 'diagnostic'}, - battery: {device_class: 'battery', state_class: 'measurement'}, - battery2: {device_class: 'battery', entity_category: 'diagnostic', state_class: 'measurement'}, - battery_voltage: {device_class: 'voltage', entity_category: 'diagnostic', state_class: 'measurement', enabled_by_default: true}, - boost_heating_countdown: {device_class: 'duration'}, - boost_heating_countdown_time_set: {entity_category: 'config', icon: 'mdi:timer'}, - boost_time: {entity_category: 'config', icon: 'mdi:timer'}, - calibration: {entity_category: 'config', icon: 'mdi:wrench-clock'}, - calibration_time: {entity_category: 'config', icon: 'mdi:wrench-clock'}, - co2: {device_class: 'carbon_dioxide', state_class: 'measurement'}, - comfort_temperature: {entity_category: 'config', icon: 'mdi:thermometer'}, - cpu_temperature: { - device_class: 'temperature', - entity_category: 'diagnostic', - state_class: 'measurement', - }, - cube_side: {icon: 'mdi:cube'}, - current: { - device_class: 'current', - enabled_by_default: false, - entity_category: 'diagnostic', - state_class: 'measurement', - }, - current_phase_b: { - device_class: 'current', - enabled_by_default: false, - entity_category: 'diagnostic', - state_class: 'measurement', - }, - current_phase_c: { - device_class: 'current', - enabled_by_default: false, - entity_category: 'diagnostic', - state_class: 'measurement', - }, - deadzone_temperature: {entity_category: 'config', icon: 'mdi:thermometer'}, - detection_interval: {icon: 'mdi:timer'}, - device_temperature: { - device_class: 'temperature', - entity_category: 'diagnostic', - state_class: 'measurement', - }, - duration: {entity_category: 'config', icon: 'mdi:timer'}, - eco2: {device_class: 'carbon_dioxide', state_class: 'measurement'}, - eco_temperature: {entity_category: 'config', icon: 'mdi:thermometer'}, - energy: {device_class: 'energy', state_class: 'total_increasing'}, - external_temperature_input: {icon: 'mdi:thermometer'}, - formaldehyd: {state_class: 'measurement'}, - gas_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'}, - hcho: {icon: 'mdi:air-filter', state_class: 'measurement'}, - humidity: {device_class: 'humidity', state_class: 'measurement'}, - humidity_calibration: {entity_category: 'config', icon: 'mdi:wrench-clock'}, - humidity_max: {entity_category: 'config', icon: 'mdi:water-percent'}, - humidity_min: {entity_category: 'config', icon: 'mdi:water-percent'}, - illuminance_calibration: {entity_category: 'config', icon: 'mdi:wrench-clock'}, - illuminance_lux: {device_class: 'illuminance', state_class: 'measurement'}, - illuminance: {device_class: 'illuminance', enabled_by_default: false, state_class: 'measurement'}, - linkquality: { - enabled_by_default: false, - entity_category: 'diagnostic', - icon: 'mdi:signal', - state_class: 'measurement', - }, - local_temperature: {device_class: 'temperature', state_class: 'measurement'}, - max_temperature: {entity_category: 'config', icon: 'mdi:thermometer-high'}, - max_temperature_limit: {entity_category: 'config', icon: 'mdi:thermometer-high'}, - min_temperature_limit: {entity_category: 'config', icon: 'mdi:thermometer-low'}, - min_temperature: {entity_category: 'config', icon: 'mdi:thermometer-low'}, - minimum_on_level: {entity_category: 'config'}, - measurement_poll_interval: {entity_category: 'config', icon: 'mdi:clock-out'}, - noise: {device_class: 'sound_pressure', state_class: 'measurement'}, - noise_detect_level: {icon: 'mdi:volume-equal'}, - noise_timeout: {icon: 'mdi:timer'}, - occupancy_level: {icon: 'mdi:motion-sensor'}, - occupancy_sensitivity: {icon: 'mdi:motion-sensor'}, - occupancy_timeout: {entity_category: 'config', icon: 'mdi:timer'}, - overload_protection: {icon: 'mdi:flash'}, - pm10: {device_class: 'pm10', state_class: 'measurement'}, - pm25: {device_class: 'pm25', state_class: 'measurement'}, - people: {state_class: 'measurement', icon: 'mdi:account-multiple'}, - position: {icon: 'mdi:valve', state_class: 'measurement'}, - power: {device_class: 'power', entity_category: 'diagnostic', state_class: 'measurement'}, - power_factor: {device_class: 'power_factor', enabled_by_default: false, entity_category: 'diagnostic', state_class: 'measurement'}, - power_outage_count: {icon: 'mdi:counter', enabled_by_default: false}, - precision: {entity_category: 'config', icon: 'mdi:decimal-comma-increase'}, - pressure: {device_class: 'atmospheric_pressure', state_class: 'measurement'}, - presence_timeout: {entity_category: 'config', icon: 'mdi:timer'}, - reporting_time: {entity_category: 'config', icon: 'mdi:clock-time-one-outline'}, - requested_brightness_level: { - enabled_by_default: false, - entity_category: 'diagnostic', - icon: 'mdi:brightness-5', - }, - requested_brightness_percent: { - enabled_by_default: false, - entity_category: 'diagnostic', - icon: 'mdi:brightness-5', - }, - smoke_density: {icon: 'mdi:google-circles-communities', state_class: 'measurement'}, - soil_moisture: {device_class: 'moisture', state_class: 'measurement'}, - temperature: {device_class: 'temperature', state_class: 'measurement'}, - temperature_calibration: {entity_category: 'config', icon: 'mdi:wrench-clock'}, - temperature_max: {entity_category: 'config', icon: 'mdi:thermometer-plus'}, - temperature_min: {entity_category: 'config', icon: 'mdi:thermometer-minus'}, - temperature_offset: {icon: 'mdi:thermometer-lines'}, - transition: {entity_category: 'config', icon: 'mdi:transition'}, - trigger_count: {icon: 'mdi:counter', enabled_by_default: false}, - voc: {device_class: 'volatile_organic_compounds', state_class: 'measurement'}, - voc_index: {state_class: 'measurement', icon: 'mdi:molecule'}, - voc_parts: {device_class: 'volatile_organic_compounds_parts', state_class: 'measurement'}, - vibration_timeout: {entity_category: 'config', icon: 'mdi:timer'}, - voltage: { - device_class: 'voltage', - enabled_by_default: false, - entity_category: 'diagnostic', - state_class: 'measurement', - }, - voltage_phase_b: { - device_class: 'voltage', - enabled_by_default: false, - entity_category: 'diagnostic', - state_class: 'measurement', - }, - voltage_phase_c: { - device_class: 'voltage', - enabled_by_default: false, - entity_category: 'diagnostic', - state_class: 'measurement', - }, - water_consumed: { - device_class: 'water', - state_class: 'total_increasing', - }, - x_axis: {icon: 'mdi:axis-x-arrow'}, - y_axis: {icon: 'mdi:axis-y-arrow'}, - z_axis: {icon: 'mdi:axis-z-arrow'}, - }; + case 'climate': { + const setpointProperties = ['occupied_heating_setpoint', 'current_heating_setpoint']; + const setpoint = (firstExpose as zhc.Climate).features.filter(isNumericExpose).find((f) => setpointProperties.includes(f.name)); + assert( + setpoint && setpoint.value_min !== undefined && setpoint.value_max !== undefined, + 'No setpoint found or it is missing value_min/max', + ); + const temperature = (firstExpose as zhc.Climate).features.find((f) => f.name === 'local_temperature'); + assert(temperature, 'No temperature found'); - const extraAttrs = {}; - - // If a variable includes Wh, mark it as energy - if (firstExpose.unit && ['Wh', 'kWh'].includes(firstExpose.unit)) { - 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. - if (firstExpose.name === 'voc' && firstExpose.unit && ['ppb', 'ppm'].includes(firstExpose.unit)) { - key = 'voc_parts'; - } - - const discoveryEntry: DiscoveryEntry = { - type: 'sensor', - 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} }}`, - enabled_by_default: !allowsSet, - ...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}), - ...lookup[key], - ...extraAttrs, - }, - }; - - // When a device_class is set, unit_of_measurement must be set, otherwise warnings are generated. - // https://github.com/Koenkk/zigbee2mqtt/issues/15958#issuecomment-1377483202 - if (discoveryEntry.discovery_payload.device_class && !discoveryEntry.discovery_payload.unit_of_measurement) { - delete discoveryEntry.discovery_payload.device_class; - } - - // entity_category config is not allowed for sensors - // https://github.com/Koenkk/zigbee2mqtt/issues/20252 - if (discoveryEntry.discovery_payload.entity_category === 'config') { - discoveryEntry.discovery_payload.entity_category = 'diagnostic'; - } - - 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}], + type: 'climate', + object_id: endpoint ? `climate_${endpoint}` : 'climate', + mockProperties: [], discovery_payload: { - name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, - value_template: `{{ value_json.${firstExpose.property} }}`, - command_topic: true, + name: endpoint ? utils.capitalize(endpoint) : null, + // Static + state_topic: false, + temperature_unit: 'C', + // Setpoint + temp_step: setpoint.value_step, + min_temp: setpoint.value_min.toString(), + max_temp: setpoint.value_max.toString(), + // Temperature + current_temperature_topic: true, + current_temperature_template: `{{ value_json.${temperature.property} }}`, command_topic_prefix: endpoint, - command_topic_postfix: firstExpose.property, - ...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}), - ...(firstExpose.value_step && {step: firstExpose.value_step}), - ...lookup[firstExpose.name], }, }; - if (lookup[firstExpose.name]?.device_class === 'temperature') { - discoveryEntry.discovery_payload.device_class = lookup[firstExpose.name]?.device_class; + const mode = (firstExpose as zhc.Climate).features.filter(isEnumExpose).find((f) => f.name === 'system_mode'); + if (mode) { + if (mode.values.includes('sleep')) { + // 'sleep' is not supported by Home Assistant, but is valid according to ZCL + // TRV that support sleep (e.g. Viessmann) will have it removed from here, + // this allows other expose consumers to still use it, e.g. the frontend. + mode.values.splice(mode.values.indexOf('sleep'), 1); + } + discoveryEntry.discovery_payload.mode_state_topic = true; + discoveryEntry.discovery_payload.mode_state_template = `{{ value_json.${mode.property} }}`; + discoveryEntry.discovery_payload.modes = mode.values; + discoveryEntry.discovery_payload.mode_command_topic = true; + } + + const state = (firstExpose as zhc.Climate).features.find((f) => f.name === 'running_state'); + if (state) { + discoveryEntry.mockProperties.push({property: state.property, value: null}); + discoveryEntry.discovery_payload.action_topic = true; + discoveryEntry.discovery_payload.action_template = + `{% set values = ` + + `{None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'}` + + ` %}{{ values[value_json.${state.property}] }}`; + } + + const coolingSetpoint = (firstExpose as zhc.Climate).features.find((f) => f.name === 'occupied_cooling_setpoint'); + if (coolingSetpoint) { + discoveryEntry.discovery_payload.temperature_low_command_topic = setpoint.name; + discoveryEntry.discovery_payload.temperature_low_state_template = `{{ value_json.${setpoint.property} }}`; + discoveryEntry.discovery_payload.temperature_low_state_topic = true; + discoveryEntry.discovery_payload.temperature_high_command_topic = coolingSetpoint.name; + discoveryEntry.discovery_payload.temperature_high_state_template = `{{ value_json.${coolingSetpoint.property} }}`; + discoveryEntry.discovery_payload.temperature_high_state_topic = true; } else { + discoveryEntry.discovery_payload.temperature_command_topic = setpoint.name; + discoveryEntry.discovery_payload.temperature_state_template = `{{ value_json.${setpoint.property} }}`; + discoveryEntry.discovery_payload.temperature_state_topic = true; + } + + const fanMode = (firstExpose as zhc.Climate).features.filter(isEnumExpose).find((f) => f.name === 'fan_mode'); + if (fanMode) { + discoveryEntry.discovery_payload.fan_modes = fanMode.values; + discoveryEntry.discovery_payload.fan_mode_command_topic = true; + discoveryEntry.discovery_payload.fan_mode_state_template = `{{ value_json.${fanMode.property} }}`; + discoveryEntry.discovery_payload.fan_mode_state_topic = true; + } + + const swingMode = (firstExpose as zhc.Climate).features.filter(isEnumExpose).find((f) => f.name === 'swing_mode'); + if (swingMode) { + discoveryEntry.discovery_payload.swing_modes = swingMode.values; + discoveryEntry.discovery_payload.swing_mode_command_topic = true; + discoveryEntry.discovery_payload.swing_mode_state_template = `{{ value_json.${swingMode.property} }}`; + discoveryEntry.discovery_payload.swing_mode_state_topic = true; + } + + const preset = (firstExpose as zhc.Climate).features.filter(isEnumExpose).find((f) => f.name === 'preset'); + if (preset) { + discoveryEntry.discovery_payload.preset_modes = preset.values; + discoveryEntry.discovery_payload.preset_mode_command_topic = 'preset'; + discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${preset.property} }}`; + discoveryEntry.discovery_payload.preset_mode_state_topic = true; + } + + const tempCalibration = (firstExpose as zhc.Climate).features + .filter(isNumericExpose) + .find((f) => f.name === 'local_temperature_calibration'); + if (tempCalibration) { + const discoveryEntry: DiscoveryEntry = { + type: 'number', + object_id: endpoint ? `${tempCalibration.name}_${endpoint}` : `${tempCalibration.name}`, + mockProperties: [{property: tempCalibration.property, value: null}], + discovery_payload: { + name: endpoint ? `${tempCalibration.label} ${endpoint}` : tempCalibration.label, + value_template: `{{ value_json.${tempCalibration.property} }}`, + command_topic: true, + command_topic_prefix: endpoint, + command_topic_postfix: tempCalibration.property, + device_class: 'temperature', + entity_category: 'config', + icon: 'mdi:math-compass', + ...(tempCalibration.unit && {unit_of_measurement: tempCalibration.unit}), + }, + }; + + // istanbul ignore else + if (tempCalibration.value_min != null) discoveryEntry.discovery_payload.min = tempCalibration.value_min; + // istanbul ignore else + if (tempCalibration.value_max != null) discoveryEntry.discovery_payload.max = tempCalibration.value_max; + // istanbul ignore else + if (tempCalibration.value_step != null) { + discoveryEntry.discovery_payload.step = tempCalibration.value_step; + } + discoveryEntries.push(discoveryEntry); + } + + const piHeatingDemand = (firstExpose as zhc.Climate).features.filter(isNumericExpose).find((f) => f.name === 'pi_heating_demand'); + if (piHeatingDemand) { + const discoveryEntry: DiscoveryEntry = { + type: 'sensor', + object_id: endpoint ? /* istanbul ignore next */ `${piHeatingDemand.name}_${endpoint}` : `${piHeatingDemand.name}`, + mockProperties: [{property: piHeatingDemand.property, value: null}], + discovery_payload: { + name: endpoint ? /* istanbul ignore next */ `${piHeatingDemand.label} ${endpoint}` : piHeatingDemand.label, + value_template: `{{ value_json.${piHeatingDemand.property} }}`, + ...(piHeatingDemand.unit && {unit_of_measurement: piHeatingDemand.unit}), + entity_category: 'diagnostic', + icon: 'mdi:radiator', + }, + }; + + discoveryEntries.push(discoveryEntry); + } + + discoveryEntries.push(discoveryEntry); + break; + } + case 'lock': { + assert(!endpoint, `Endpoint not supported for lock type`); + const state = (firstExpose as zhc.Lock).features.filter(isBinaryExpose).find((f) => f.name === 'state'); + assert(state, `Lock expose must have a 'state'`); + const discoveryEntry: DiscoveryEntry = { + type: 'lock', + object_id: 'lock', + mockProperties: [{property: state.property, value: null}], + discovery_payload: { + name: null, + command_topic: true, + value_template: `{{ value_json.${state.property} }}`, + }, + }; + + // istanbul ignore if + if (state.property === 'keypad_lockout') { + // deprecated: keypad_lockout is messy, but changing is breaking + discoveryEntry.discovery_payload.name = firstExpose.label; + discoveryEntry.discovery_payload.payload_lock = state.value_on; + discoveryEntry.discovery_payload.payload_unlock = state.value_off; + discoveryEntry.discovery_payload.state_topic = true; + discoveryEntry.object_id = 'keypad_lock'; + } else if (state.property === 'child_lock') { + // deprecated: child_lock is messy, but changing is breaking + discoveryEntry.discovery_payload.name = firstExpose.label; + discoveryEntry.discovery_payload.payload_lock = state.value_on; + discoveryEntry.discovery_payload.payload_unlock = state.value_off; + discoveryEntry.discovery_payload.state_locked = 'LOCK'; + discoveryEntry.discovery_payload.state_unlocked = 'UNLOCK'; + discoveryEntry.discovery_payload.state_topic = true; + discoveryEntry.object_id = 'child_lock'; + } else { + discoveryEntry.discovery_payload.state_locked = state.value_on; + discoveryEntry.discovery_payload.state_unlocked = state.value_off; + } + + if (state.property !== 'state') { + discoveryEntry.discovery_payload.command_topic_postfix = state.property; + } + + discoveryEntries.push(discoveryEntry); + break; + } + case 'cover': { + const state = (exposes as zhc.Cover[]) + .find((expose) => expose.features.find((e) => e.name === 'state')) + ?.features.find((f) => f.name === 'state'); + assert(state, `Cover expose must have a 'state'`); + const position = (exposes as zhc.Cover[]) + .find((expose) => expose.features.find((e) => e.name === 'position')) + ?.features.find((f) => f.name === 'position'); + const tilt = (exposes as zhc.Cover[]) + .find((expose) => expose.features.find((e) => e.name === 'tilt')) + ?.features.find((f) => f.name === 'tilt'); + const motorState = allExposes + ?.filter(isEnumExpose) + .find((e) => ['motor_state', 'moving'].includes(e.name) && e.access === ACCESS_STATE); + const running = allExposes?.find((e) => e.type === 'binary' && e.name === 'running'); + + const discoveryEntry: DiscoveryEntry = { + type: 'cover', + mockProperties: [{property: state.property, value: null}], + object_id: endpoint ? `cover_${endpoint}` : 'cover', + discovery_payload: { + name: endpoint ? utils.capitalize(endpoint) : null, + command_topic_prefix: endpoint, + command_topic: true, + state_topic: true, + state_topic_postfix: endpoint, + }, + }; + + // If curtains have `running` property, use this in discovery. + // The movement direction is calculated (assumed) in this case. + if (running) { + assert(position, `Cover must have 'position' when it has 'running'`); + discoveryEntry.discovery_payload.value_template = + `{% if "${running.property}" in value_json ` + + `and value_json.${running.property} %} {% if value_json.${position.property} > 0 %} closing ` + + `{% else %} opening {% endif %} {% else %} stopped {% endif %}`; + } + + // If curtains have `motor_state` or `moving` property, lookup for possible + // state names to detect movement direction and use this in discovery. + if (motorState) { + const openingState = motorState.values.find((s) => COVER_OPENING_LOOKUP.includes(s.toString().toLowerCase())); + const closingState = motorState.values.find((s) => COVER_CLOSING_LOOKUP.includes(s.toString().toLowerCase())); + const stoppedState = motorState.values.find((s) => COVER_STOPPED_LOOKUP.includes(s.toString().toLowerCase())); + + // istanbul ignore else + if (openingState && closingState && stoppedState) { + discoveryEntry.discovery_payload.state_opening = openingState; + discoveryEntry.discovery_payload.state_closing = closingState; + discoveryEntry.discovery_payload.state_stopped = stoppedState; + discoveryEntry.discovery_payload.value_template = + `{% if "${motorState.property}" in value_json ` + + `and value_json.${motorState.property} %} {{ value_json.${motorState.property} }} {% else %} ` + + `${stoppedState} {% endif %}`; + } + } + + // If curtains do not have `running`, `motor_state` or `moving` properties. + if (!discoveryEntry.discovery_payload.value_template) { + discoveryEntry.discovery_payload.value_template = `{{ value_json.${featurePropertyWithoutEndpoint(state)} }}`; + discoveryEntry.discovery_payload.state_open = 'OPEN'; + discoveryEntry.discovery_payload.state_closed = 'CLOSE'; + discoveryEntry.discovery_payload.state_stopped = 'STOP'; + } + + // istanbul ignore if + if (!position && !tilt) { + discoveryEntry.discovery_payload.optimistic = true; + } + + if (position) { + discoveryEntry.discovery_payload = { + ...discoveryEntry.discovery_payload, + position_template: `{{ value_json.${featurePropertyWithoutEndpoint(position)} }}`, + set_position_template: `{ "${getProperty(position)}": {{ position }} }`, + set_position_topic: true, + position_topic: true, + }; + } + + if (tilt) { + discoveryEntry.discovery_payload = { + ...discoveryEntry.discovery_payload, + tilt_command_topic: true, + tilt_status_topic: true, + tilt_status_template: `{{ value_json.${featurePropertyWithoutEndpoint(tilt)} }}`, + }; + } + + discoveryEntries.push(discoveryEntry); + break; + } + case 'fan': { + assert(!endpoint, `Endpoint not supported for fan type`); + const discoveryEntry: DiscoveryEntry = { + type: 'fan', + object_id: 'fan', + mockProperties: [{property: 'fan_state', value: null}], + discovery_payload: { + name: null, + state_topic: true, + state_value_template: '{{ value_json.fan_state }}', + command_topic: true, + command_topic_postfix: 'fan_state', + }, + }; + + const speed = (firstExpose as zhc.Fan).features.filter(isEnumExpose).find((e) => e.name === 'mode'); + // istanbul ignore else + if (speed) { + // A fan entity in Home Assistant 2021.3 and above may have a speed, + // controlled by a percentage from 1 to 100, and/or non-speed presets. + // The MQTT Fan integration allows the speed percentage to be mapped + // to a narrower range of speeds (e.g. 1-3), and for these speeds to be + // translated to and from MQTT messages via templates. + // + // For the fixed fan modes in ZCL hvacFanCtrl, we model speeds "low", + // "medium", and "high" as three speeds covering the full percentage + // range as done in Home Assistant's zigpy fan integration, plus + // presets "on", "auto" and "smart" to cover the remaining modes in + // ZCL. This supports a generic ZCL HVAC Fan Control fan. "Off" is + // always a valid speed. + let speeds = ['off'].concat( + ['low', 'medium', 'high', '1', '2', '3', '4', '5', '6', '7', '8', '9'].filter((s) => speed.values.includes(s)), + ); + let presets = ['on', 'auto', 'smart'].filter((s) => speed.values.includes(s)); + + if (['99432'].includes(definition!.model)) { + // The Hampton Bay 99432 fan implements 4 speeds using the ZCL + // hvacFanCtrl values `low`, `medium`, `high`, and `on`, and + // 1 preset called "Comfort Breeze" using the ZCL value `smart`. + // ZCL value `auto` is unused. + speeds = ['off', 'low', 'medium', 'high', 'on']; + presets = ['smart']; + } + + const allowed = [...speeds, ...presets]; + speed.values.forEach((s) => assert(allowed.includes(s.toString()))); + const percentValues = speeds.map((s, i) => `'${s}':${i}`).join(', '); + const percentCommands = speeds.map((s, i) => `${i}:'${s}'`).join(', '); + const presetList = presets.map((s) => `'${s}'`).join(', '); + + discoveryEntry.discovery_payload.percentage_state_topic = true; + discoveryEntry.discovery_payload.percentage_command_topic = true; + discoveryEntry.discovery_payload.percentage_value_template = `{{ {${percentValues}}[value_json.${speed.property}] | default('None') }}`; + discoveryEntry.discovery_payload.percentage_command_template = `{{ {${percentCommands}}[value] | default('') }}`; + discoveryEntry.discovery_payload.speed_range_min = 1; + discoveryEntry.discovery_payload.speed_range_max = speeds.length - 1; + assert(presets.length !== 0); + discoveryEntry.discovery_payload.preset_mode_state_topic = true; + discoveryEntry.discovery_payload.preset_mode_command_topic = 'fan_mode'; + discoveryEntry.discovery_payload.preset_mode_value_template = `{{ value_json.${speed.property} if value_json.${speed.property} in [${presetList}] else 'None' | default('None') }}`; + discoveryEntry.discovery_payload.preset_modes = presets; + } + + discoveryEntries.push(discoveryEntry); + break; + } + case 'binary': { + /** + * If Z2M binary attribute has SET access then expose it as `switch` in HA + * There is also a check on the values for typeof boolean to prevent invalid values and commands + * silently failing - commands work fine but some devices won't reject unexpected values. + * https://github.com/Koenkk/zigbee2mqtt/issues/7740 + */ + assertBinaryExpose(firstExpose); + if (firstExpose.access & ACCESS_SET) { + const discoveryEntry: DiscoveryEntry = { + type: 'switch', + mockProperties: [{property: firstExpose.property, value: null}], + object_id: endpoint ? `switch_${firstExpose.name}_${endpoint}` : `switch_${firstExpose.name}`, + discovery_payload: { + name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, + value_template: + typeof firstExpose.value_on === 'boolean' + ? `{% if value_json.${firstExpose.property} %} true {% else %} false {% endif %}` + : `{{ value_json.${firstExpose.property} }}`, + payload_on: firstExpose.value_on.toString(), + payload_off: firstExpose.value_off.toString(), + command_topic: true, + command_topic_prefix: endpoint, + command_topic_postfix: firstExpose.property, + ...(BINARY_DISCOVERY_LOOKUP[firstExpose.name] || {}), + }, + }; + + discoveryEntries.push(discoveryEntry); + } else { + const discoveryEntry: DiscoveryEntry = { + type: 'binary_sensor', + 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} }}`, + payload_on: firstExpose.value_on, + payload_off: firstExpose.value_off, + ...(BINARY_DISCOVERY_LOOKUP[firstExpose.name] || {}), + }, + }; + + discoveryEntries.push(discoveryEntry); + } + break; + } + case 'numeric': { + assertNumericExpose(firstExpose); + const extraAttrs = {}; + + // If a variable includes Wh, mark it as energy + if (firstExpose.unit && ['Wh', 'kWh'].includes(firstExpose.unit)) { + 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. + if (firstExpose.name === 'voc' && firstExpose.unit && ['ppb', 'ppm'].includes(firstExpose.unit)) { + key = 'voc_parts'; + } + + const discoveryEntry: DiscoveryEntry = { + type: 'sensor', + 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} }}`, + enabled_by_default: !allowsSet, + ...(firstExpose.unit && {unit_of_measurement: firstExpose.unit}), + ...NUMERIC_DISCOVERY_LOOKUP[key], + ...extraAttrs, + }, + }; + + // When a device_class is set, unit_of_measurement must be set, otherwise warnings are generated. + // https://github.com/Koenkk/zigbee2mqtt/issues/15958#issuecomment-1377483202 + if (discoveryEntry.discovery_payload.device_class && !discoveryEntry.discovery_payload.unit_of_measurement) { delete discoveryEntry.discovery_payload.device_class; } - if (firstExpose.value_min != null) discoveryEntry.discovery_payload.min = firstExpose.value_min; - if (firstExpose.value_max != null) discoveryEntry.discovery_payload.max = firstExpose.value_max; + // entity_category config is not allowed for sensors + // https://github.com/Koenkk/zigbee2mqtt/issues/20252 + if (discoveryEntry.discovery_payload.entity_category === 'config') { + discoveryEntry.discovery_payload.entity_category = 'diagnostic'; + } discoveryEntries.push(discoveryEntry); - } - } else if (isEnumExposeFeature(firstExpose)) { - const lookup: {[s: string]: KeyValue} = { - action: {icon: 'mdi:gesture-double-tap'}, - alarm_humidity: {entity_category: 'config', icon: 'mdi:water-percent-alert'}, - alarm_temperature: {entity_category: 'config', icon: 'mdi:thermometer-alert'}, - backlight_auto_dim: {entity_category: 'config', icon: 'mdi:brightness-auto'}, - backlight_mode: {entity_category: 'config', icon: 'mdi:lightbulb'}, - calibrate: {icon: 'mdi:tune'}, - color_power_on_behavior: {entity_category: 'config', icon: 'mdi:palette'}, - control_mode: {entity_category: 'config', icon: 'mdi:tune'}, - device_mode: {entity_category: 'config', icon: 'mdi:tune'}, - effect: {enabled_by_default: false, icon: 'mdi:palette'}, - force: {entity_category: 'config', icon: 'mdi:valve'}, - keep_time: {entity_category: 'config', icon: 'mdi:av-timer'}, - identify: {device_class: 'identify'}, - keypad_lockout: {entity_category: 'config', icon: 'mdi:lock'}, - load_detection_mode: {entity_category: 'config', icon: 'mdi:tune'}, - load_dimmable: {entity_category: 'config', icon: 'mdi:chart-bell-curve'}, - load_type: {entity_category: 'config', icon: 'mdi:led-on'}, - melody: {entity_category: 'config', icon: 'mdi:music-note'}, - mode_phase_control: {entity_category: 'config', icon: 'mdi:tune'}, - mode: {entity_category: 'config', icon: 'mdi:tune'}, - mode_switch: {icon: 'mdi:tune'}, - motion_sensitivity: {entity_category: 'config', icon: 'mdi:tune'}, - operation_mode: {entity_category: 'config', icon: 'mdi:tune'}, - power_on_behavior: {entity_category: 'config', icon: 'mdi:power-settings'}, - power_outage_memory: {entity_category: 'config', icon: 'mdi:power-settings'}, - power_supply_mode: {entity_category: 'config', icon: 'mdi:power-settings'}, - power_type: {entity_category: 'config', icon: 'mdi:lightning-bolt-circle'}, - restart: {device_class: 'restart'}, - sensitivity: {entity_category: 'config', icon: 'mdi:tune'}, - sensor: {icon: 'mdi:tune'}, - sensors_type: {entity_category: 'config', icon: 'mdi:tune'}, - sound_volume: {entity_category: 'config', icon: 'mdi:volume-high'}, - status: {icon: 'mdi:state-machine'}, - switch_type: {entity_category: 'config', icon: 'mdi:tune'}, - temperature_display_mode: {entity_category: 'config', icon: 'mdi:thermometer'}, - temperature_sensor_select: {entity_category: 'config', icon: 'mdi:home-thermometer'}, - thermostat_unit: {entity_category: 'config', icon: 'mdi:thermometer'}, - update: {device_class: 'update'}, - volume: {entity_category: 'config', icon: 'mdi: volume-high'}, - week: {entity_category: 'config', icon: 'mdi:calendar-clock'}, - }; - const valueTemplate = firstExpose.access & ACCESS_STATE ? `{{ value_json.${firstExpose.property} }}` : undefined; + /** + * 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 (firstExpose.access & ACCESS_STATE) { - discoveryEntries.push({ - type: 'sensor', - 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), - ...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; + } - /** - * 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 (firstExpose.access & ACCESS_SET) { - discoveryEntries.push({ - type: 'select', - object_id: firstExpose.property, - mockProperties: [], // Already mocked above in case access STATE is supported - discovery_payload: { - name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, - value_template: valueTemplate, - state_topic: !!(firstExpose.access & ACCESS_STATE), - command_topic_prefix: endpoint, - 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 - ...lookup[firstExpose.name], - }, - }); - } + // 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; - /** - * 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). - */ - if (firstExpose.access & ACCESS_SET && firstExpose.values.length === 1) { - discoveryEntries.push({ - type: 'button', - object_id: firstExpose.property, - mockProperties: [], - discovery_payload: { - name: endpoint ? `${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(), - ...lookup[firstExpose.name], - }, - }); + discoveryEntries.push(discoveryEntry); + } + break; } - } else if (firstExpose.type === 'text' || firstExpose.type === 'composite' || firstExpose.type === 'list') { - // Deprecated: remove text sensor - const settableText = firstExpose.type === 'text' && firstExpose.access & ACCESS_SET; - const lookup: {[s: string]: KeyValue} = { - action: {icon: 'mdi:gesture-double-tap'}, - color_options: {icon: 'mdi:palette'}, - level_config: {entity_category: 'diagnostic'}, - programming_mode: {icon: 'mdi:calendar-clock'}, - schedule_settings: {icon: 'mdi:calendar-clock'}, - }; - if (firstExpose.access & ACCESS_STATE) { - const discoveryEntry: DiscoveryEntry = { - type: 'sensor', - object_id: firstExpose.property, - mockProperties: [{property: firstExpose.property, value: null}], - discovery_payload: { - name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, - // Truncate text if it's too long - // https://github.com/Koenkk/zigbee2mqtt/issues/23199 - value_template: `{{ value_json.${firstExpose.property}|default('',True) | truncate(254, True, '', 0) }}`, - enabled_by_default: !settableText, - ...lookup[firstExpose.name], - }, - }; - discoveryEntries.push(discoveryEntry); + case 'enum': { + assertEnumExpose(firstExpose); + const valueTemplate = firstExpose.access & ACCESS_STATE ? `{{ value_json.${firstExpose.property} }}` : undefined; + + if (firstExpose.access & ACCESS_STATE) { + discoveryEntries.push({ + type: 'sensor', + 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), + ...ENUM_DISCOVERY_LOOKUP[firstExpose.name], + }, + }); + } + + /** + * 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 (firstExpose.access & ACCESS_SET) { + discoveryEntries.push({ + type: 'select', + object_id: firstExpose.property, + mockProperties: [], // Already mocked above in case access STATE is supported + discovery_payload: { + name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, + value_template: valueTemplate, + state_topic: !!(firstExpose.access & ACCESS_STATE), + command_topic_prefix: endpoint, + 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], + }, + }); + } + + /** + * 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). + */ + if (firstExpose.access & ACCESS_SET && firstExpose.values.length === 1) { + discoveryEntries.push({ + type: 'button', + object_id: firstExpose.property, + mockProperties: [], + 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(), + ...ENUM_DISCOVERY_LOOKUP[firstExpose.name], + }, + }); + } + break; } - if (settableText) { - discoveryEntries.push({ - type: 'text', - object_id: firstExpose.property, - mockProperties: [], // Already mocked above in case access STATE is supported - discovery_payload: { - name: endpoint ? `${firstExpose.label} ${endpoint}` : firstExpose.label, - state_topic: firstExpose.access & ACCESS_STATE, - value_template: `{{ value_json.${firstExpose.property} }}`, - command_topic_prefix: endpoint, - command_topic: true, - command_topic_postfix: firstExpose.property, - ...lookup[firstExpose.name], - }, - }); + case 'text': + case 'composite': + case 'list': { + // Deprecated: 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', + 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) | truncate(254, True, '', 0) }}`, + enabled_by_default: !settableText, + ...LIST_DISCOVERY_LOOKUP[firstExposeTyped.name], + }, + }; + discoveryEntries.push(discoveryEntry); + } + if (settableText) { + discoveryEntries.push({ + type: 'text', + object_id: firstExposeTyped.property, + mockProperties: [], // Already mocked above in case access STATE is supported + 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, + ...LIST_DISCOVERY_LOOKUP[firstExposeTyped.name], + }, + }); + } + break; } - } else { - throw new Error(`Unsupported exposes type: '${firstExpose.type}'`); + /* istanbul ignore next */ + default: + throw new Error(`Unsupported exposes type: '${firstExpose.type}'`); } // Exposes with category 'config' or 'diagnostic' are always added to the respective category. @@ -1211,7 +1266,7 @@ export default class HomeAssistant extends Extension { const discovered = this.getDiscovered(data.id); for (const topic of Object.keys(discovered.messages)) { - await this.mqtt.publish(topic, null, {retain: true, qos: 1}, this.discoveryTopic, false, false); + await this.mqtt.publish(topic, '', {retain: true, qos: 1}, this.discoveryTopic, false, false); } delete this.discovered[data.id]; @@ -1230,10 +1285,17 @@ export default class HomeAssistant extends Extension { * Here we retrieve all the attributes with the _l1 values and republish them on * zigbee2mqtt/mydevice/l1. */ - const entity = this.zigbee.resolveEntity(data.entity.name); + const entity = this.zigbee.resolveEntity(data.entity.name)!; if (entity.isDevice()) { - for (const topic of Object.keys(this.getDiscovered(entity).messages)) { - const objectID = topic.match(this.discoveryRegexWoTopic)?.[3]; + for (const topic in this.getDiscovered(entity).messages) { + const topicMatch = topic.match(this.discoveryRegexWoTopic); + + // istanbul ignore if + if (!topicMatch) { + continue; + } + + const objectID = topicMatch[3]; const lightMatch = /^light_(.*)/.exec(objectID); const coverMatch = /^cover_(.*)/.exec(objectID); @@ -1260,7 +1322,7 @@ export default class HomeAssistant extends Extension { * can use Home Assistant entities in automations. * https://github.com/Koenkk/zigbee2mqtt/issues/959#issuecomment-480341347 */ - if (settings.get().homeassistant.legacy_triggers) { + if (this.legacyTrigger) { const keys = ['action', 'click'].filter((k) => data.message[k]); for (const key of keys) { await this.publishEntityState(data.entity, {[key]: ''}); @@ -1291,7 +1353,7 @@ export default class HomeAssistant extends Extension { if (data.homeAssisantRename) { const discovered = this.getDiscovered(data.entity); for (const topic of Object.keys(discovered.messages)) { - await this.mqtt.publish(topic, null, {retain: true, qos: 1}, this.discoveryTopic, false, false); + await this.mqtt.publish(topic, '', {retain: true, qos: 1}, this.discoveryTopic, false, false); } discovered.messages = {}; @@ -1324,17 +1386,18 @@ export default class HomeAssistant extends Extension { configs.push(...this.exposeToConfig([expose], 'device', exposes, entity.definition)); } - for (const mapping of legacyMapping) { - if (mapping.models.includes(entity.definition.model)) { + for (const mapping of LEGACY_MAPPING) { + if (mapping.models.includes(entity.definition!.model)) { configs.push(mapping.discovery); } } - // Deprecated in favour of exposes + // @ts-expect-error deprecated in favour of exposes + const haConfig = entity.definition?.homeassistant; + /* istanbul ignore if */ - if (entity.definition.hasOwnProperty('homeassistant')) { - // @ts-ignore - configs.push(entity.definition.homeassistant); + if (haConfig != undefined) { + configs.push(haConfig); } } else if (isGroup) { // group @@ -1347,12 +1410,13 @@ export default class HomeAssistant extends Extension { .forEach((device) => { const exposes = device.exposes(); allExposes.push(...exposes); - for (const expose of exposes.filter((e) => groupSupportedTypes.includes(e.type))) { + for (const expose of exposes.filter((e) => GROUP_SUPPORTED_TYPES.includes(e.type))) { let key = expose.type; if (['switch', 'lock', 'cover'].includes(expose.type) && expose.endpoint) { // A device can have multiple of these types which have to discovered separately. // e.g. switch with property state and valve_detection. - const state = expose.features.find((f) => f.name === 'state'); + const state = (expose as zhc.Switch | zhc.Lock | zhc.Cover).features.find((f) => f.name === 'state'); + assert(state, `'switch', 'lock' or 'cover' is missing state`); key += featurePropertyWithoutEndpoint(state); } @@ -1361,7 +1425,9 @@ export default class HomeAssistant extends Extension { } }); - configs = [].concat(...Object.values(exposesByType).map((exposes) => this.exposeToConfig(exposes, 'group', allExposes))); + configs = ([] as DiscoveryEntry[]).concat( + ...Object.values(exposesByType).map((exposes) => this.exposeToConfig(exposes, 'group', allExposes)), + ); } else { // Discover bridge config. configs.push(...entity.configs); @@ -1389,7 +1455,7 @@ export default class HomeAssistant extends Extension { configs.push(config); } - if (isDevice && entity.definition.ota) { + if (isDevice && entity.definition?.ota) { const updateStateSensor: DiscoveryEntry = { type: 'sensor', object_id: 'update_state', @@ -1463,10 +1529,10 @@ export default class HomeAssistant extends Extension { }); if (isDevice && entity.options.hasOwnProperty('legacy') && !entity.options.legacy) { - configs = configs.filter((c) => c !== sensorClick); + configs = configs.filter((c) => c !== SENSOR_CLICK); } - if (!settings.get().homeassistant.legacy_triggers) { + if (!this.legacyTrigger) { configs = configs.filter((c) => c.object_id !== 'action' && c.object_id !== 'click'); } @@ -1705,7 +1771,7 @@ export default class HomeAssistant extends Extension { } if (entity.isDevice()) { - entity.definition.meta?.overrideHaDiscoveryPayload?.(payload); + entity.definition?.meta?.overrideHaDiscoveryPayload?.(payload); } const topic = this.getDiscoveryTopic(config, entity); @@ -1726,9 +1792,9 @@ export default class HomeAssistant extends Extension { } for (const topic of lastDiscoveredTopics) { - const isDeviceAutomation = topic.match(this.discoveryRegexWoTopic)[1] === 'device_automation'; + const isDeviceAutomation = topic.match(this.discoveryRegexWoTopic)?.[1] === 'device_automation'; if (!newDiscoveredTopics.has(topic) && !isDeviceAutomation) { - await this.mqtt.publish(topic, null, {retain: true, qos: 1}, this.discoveryTopic, false, false); + await this.mqtt.publish(topic, '', {retain: true, qos: 1}, this.discoveryTopic, false, false); } } } @@ -1738,7 +1804,8 @@ export default class HomeAssistant extends Extension { const isDeviceAutomation = discoveryMatch && discoveryMatch[1] === 'device_automation'; if (discoveryMatch) { // Clear outdated discovery configs and remember already discovered device_automations - let message: KeyValue = null; + let message: KeyValue; + try { message = JSON.parse(data.message); const baseTopic = settings.get().mqtt.base_topic + '/'; @@ -1768,20 +1835,21 @@ export default class HomeAssistant extends Extension { } const topic = data.topic.substring(this.discoveryTopic.length + 1); - if (!clear && !isDeviceAutomation && !(topic in this.getDiscovered(entity).messages)) { + if (!clear && !isDeviceAutomation && entity && !(topic in this.getDiscovered(entity).messages)) { clear = true; } // Device was flagged to be excluded from homeassistant discovery - clear = clear || (entity.options.hasOwnProperty('homeassistant') && !entity.options.homeassistant); + clear = clear || Boolean(entity && entity.options.homeassistant !== undefined && !entity.options.homeassistant); + /* istanbul ignore else */ if (clear) { logger.debug(`Clearing outdated Home Assistant config '${data.topic}'`); - await this.mqtt.publish(topic, null, {retain: true, qos: 1}, this.discoveryTopic, false, false); - } else { + await this.mqtt.publish(topic, '', {retain: true, qos: 1}, this.discoveryTopic, false, false); + } else if (entity) { this.getDiscovered(entity).messages[topic] = {payload: stringify(message), published: true}; } - } else if ((data.topic === this.statusTopic || data.topic === defaultStatusTopic) && data.message.toLowerCase() === 'online') { + } else if ((data.topic === this.statusTopic || data.topic === DEFAULT_STATUS_TOPIC) && data.message.toLowerCase() === 'online') { const timer = setTimeout(async () => { // Publish all device states. for (const entity of this.zigbee.devicesAndGroupsIterator(utils.deviceNotCoordinator)) { @@ -1810,7 +1878,7 @@ export default class HomeAssistant extends Extension { for (const topic of Object.keys(discovered.messages)) { if (topic.startsWith('scene')) { - await this.mqtt.publish(topic, null, {retain: true, qos: 1}, this.discoveryTopic, false, false); + await this.mqtt.publish(topic, '', {retain: true, qos: 1}, this.discoveryTopic, false, false); delete discovered.messages[topic]; } } @@ -1842,6 +1910,7 @@ export default class HomeAssistant extends Extension { const url = settings.get().frontend?.url ?? ''; if (entity.isDevice()) { + assert(entity.definition, `Cannot 'getDevicePayload' for unsupported device`); payload.model = `${entity.definition.description} (${entity.definition.model})`; payload.manufacturer = entity.definition.vendor; payload.sw_version = entity.zh.softwareBuildID; diff --git a/lib/extension/legacy/bridgeLegacy.ts b/lib/extension/legacy/bridgeLegacy.ts index b3b8d5b84..2eb4fd238 100644 --- a/lib/extension/legacy/bridgeLegacy.ts +++ b/lib/extension/legacy/bridgeLegacy.ts @@ -10,7 +10,8 @@ import Extension from '../extension'; const configRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+/factory_reset)|(?:\\w+))`); export default class BridgeLegacy extends Extension { - private lastJoinedDeviceName: string = null; + private lastJoinedDeviceName?: string; + // @ts-expect-error initialized in `start` private supportedOptions: {[s: string]: (topic: string, message: string) => Promise | void}; override async start(): Promise { @@ -39,7 +40,7 @@ export default class BridgeLegacy extends Extension { this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('deviceJoined', data, data.device)); this.eventBus.onDeviceInterview(this, (data) => this.onZigbeeEvent_('deviceInterview', data, data.device)); this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data, data.device)); - this.eventBus.onDeviceLeave(this, (data) => this.onZigbeeEvent_('deviceLeave', data, null)); + this.eventBus.onDeviceLeave(this, (data) => this.onZigbeeEvent_('deviceLeave', data, undefined)); this.eventBus.onMQTTMessage(this, this.onMQTTMessage); await this.publish(); @@ -206,11 +207,11 @@ export default class BridgeLegacy extends Extension { async _renameInternal(from: string, to: string): Promise { try { - const isGroup = settings.getGroup(from) !== null; + const isGroup = settings.getGroup(from) != undefined; settings.changeFriendlyName(from, to); logger.info(`Successfully renamed - ${from} to ${to} `); const entity = this.zigbee.resolveEntity(to); - if (entity.isDevice()) { + if (entity?.isDevice()) { this.eventBus.emitEntityRenamed({homeAssisantRename: false, from, to, entity}); } @@ -333,11 +334,13 @@ export default class BridgeLegacy extends Extension { @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise { const {topic, message} = data; - if (!topic.match(configRegex)) { + const match = topic.match(configRegex); + + if (!match) { return; } - const option = topic.match(configRegex)[1]; + const option = match[1]; if (!this.supportedOptions.hasOwnProperty(option)) { return; @@ -364,36 +367,36 @@ export default class BridgeLegacy extends Extension { await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0}); } - async onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device): Promise { - if (type === 'deviceJoined' && resolvedEntity) { - this.lastJoinedDeviceName = resolvedEntity.name; - } - - if (type === 'deviceJoined') { - await this.mqtt.publish('bridge/log', stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}})); - } else if (type === 'deviceInterview') { - if (data.status === 'successful') { - if (resolvedEntity.isSupported) { - const {vendor, description, model} = resolvedEntity.definition; - const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true}; - await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta: log})); - } else { - const meta = {friendly_name: resolvedEntity.name, supported: false}; - await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta})); - } - } else if (data.status === 'failed') { - const meta = {friendly_name: resolvedEntity.name}; - await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_failed', meta})); - } else { - /* istanbul ignore else */ - if (data.status === 'started') { + async onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device | undefined): Promise { + if (resolvedEntity) { + /* istanbul ignore else */ + if (type === 'deviceJoined') { + this.lastJoinedDeviceName = resolvedEntity.name; + await this.mqtt.publish('bridge/log', stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}})); + } else if (type === 'deviceInterview') { + if (data.status === 'successful') { + if (resolvedEntity.isSupported) { + const {vendor, description, model} = resolvedEntity.definition!; // checked by `isSupported` + const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true}; + await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta: log})); + } else { + const meta = {friendly_name: resolvedEntity.name, supported: false}; + await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta})); + } + } else if (data.status === 'failed') { const meta = {friendly_name: resolvedEntity.name}; - await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_started', meta})); + await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_failed', meta})); + } else { + /* istanbul ignore else */ + if (data.status === 'started') { + const meta = {friendly_name: resolvedEntity.name}; + await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_started', meta})); + } } + } else if (type === 'deviceAnnounce') { + const meta = {friendly_name: resolvedEntity.name}; + await this.mqtt.publish('bridge/log', stringify({type: `device_announced`, message: 'announce', meta})); } - } else if (type === 'deviceAnnounce') { - const meta = {friendly_name: resolvedEntity.name}; - await this.mqtt.publish('bridge/log', stringify({type: `device_announced`, message: 'announce', meta})); } else { /* istanbul ignore else */ if (type === 'deviceLeave') { diff --git a/lib/extension/legacy/deviceGroupMembership.ts b/lib/extension/legacy/deviceGroupMembership.ts index 39ab5352f..8024b3e68 100644 --- a/lib/extension/legacy/deviceGroupMembership.ts +++ b/lib/extension/legacy/deviceGroupMembership.ts @@ -1,4 +1,5 @@ /* istanbul ignore file */ +import assert from 'assert'; import bind from 'bind-decorator'; import Device from '../../model/device'; @@ -15,23 +16,27 @@ export default class DeviceGroupMembership extends Extension { @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise { const match = data.topic.match(topicRegex); + if (!match) { - return null; + return; } const parsed = this.zigbee.resolveEntityAndEndpoint(match[1]); const device = parsed?.entity as Device; + if (!device || !(device instanceof Device)) { logger.error(`Device '${match[1]}' does not exist`); return; } const endpoint = parsed.endpoint; + if (parsed.endpointID && !endpoint) { logger.error(`Device '${parsed.ID}' does not have endpoint '${parsed.endpointID}'`); return; } + assert(endpoint !== undefined); const response = await endpoint.command(`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {}); if (!response) { diff --git a/lib/extension/legacy/report.ts b/lib/extension/legacy/report.ts index 51289da44..f7b0abc40 100644 --- a/lib/extension/legacy/report.ts +++ b/lib/extension/legacy/report.ts @@ -74,7 +74,7 @@ export default class Report extends Extension { private failed: Set = new Set(); private enabled = settings.get().advanced.report; - shouldIgnoreClusterForDevice(cluster: string, definition: zhc.Definition): boolean { + shouldIgnoreClusterForDevice(cluster: string, definition?: zhc.Definition): boolean { if (definition === ZNLDP12LM && cluster === 'closuresWindowCovering') { // Device announces it but doesn't support it // https://github.com/Koenkk/zigbee2mqtt/issues/2611 @@ -99,7 +99,7 @@ export default class Report extends Extension { const items = []; for (const entry of configuration) { - if (!entry.hasOwnProperty('condition') || (await entry.condition(ep))) { + if (entry.condition == undefined || (await entry.condition(ep))) { const toAdd = {...entry}; if (!this.enabled) toAdd.maximumReportInterval = 0xffff; items.push(toAdd); @@ -128,7 +128,7 @@ export default class Report extends Extension { this.eventBus.emitDevicesChanged(); } catch (error) { - logger.error(`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${error.stack}`); + logger.error(`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${(error as Error).stack}`); this.failed.add(device.ieeeAddr); } @@ -137,7 +137,7 @@ export default class Report extends Extension { this.queue.delete(device.ieeeAddr); } - shouldSetupReporting(device: Device, messageType: string): boolean { + shouldSetupReporting(device: Device, messageType?: string): boolean { if (!device || !device.zh || !device.definition) return false; // Handle messages of type endDeviceAnnce and devIncoming. @@ -157,10 +157,15 @@ export default class Report extends Extension { return true; } - // These do not support reproting. + // These do not support reporting. // https://github.com/Koenkk/zigbee-herdsman/issues/110 - const philipsIgnoreSw = ['5.127.1.26581', '5.130.1.30000']; - if (device.zh.manufacturerName === 'Philips' && philipsIgnoreSw.includes(device.zh.softwareBuildID)) return false; + if ( + device.zh.manufacturerName === 'Philips' && + /* istanbul ignore next */ + (device.zh.softwareBuildID === '5.127.1.26581' || device.zh.softwareBuildID === '5.130.1.30000') + ) { + return false; + } if (device.zh.interviewing === true) return false; if (device.zh.type !== 'Router' || device.zh.powerSource === 'Battery') return false; @@ -180,7 +185,7 @@ export default class Report extends Extension { override async start(): Promise { for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) { - if (this.shouldSetupReporting(device, null)) { + if (this.shouldSetupReporting(device, undefined)) { await this.setupReporting(device); } } diff --git a/lib/extension/legacy/softReset.ts b/lib/extension/legacy/softReset.ts index 7eed07a17..7df9d0e2e 100644 --- a/lib/extension/legacy/softReset.ts +++ b/lib/extension/legacy/softReset.ts @@ -9,7 +9,7 @@ import Extension from '../extension'; * This extensions soft resets the ZNP after a certain timeout. */ export default class SoftReset extends Extension { - private timer: NodeJS.Timeout = null; + private timer?: NodeJS.Timeout; private timeout = utils.seconds(settings.get().advanced.soft_reset_timeout); override async start(): Promise { @@ -23,10 +23,8 @@ export default class SoftReset extends Extension { } private clearTimer(): void { - if (this.timer) { - clearTimeout(this.timer); - this.timer = null; - } + clearTimeout(this.timer); + this.timer = undefined; } private resetTimer(): void { @@ -45,7 +43,7 @@ export default class SoftReset extends Extension { await this.zigbee.reset('soft'); logger.warning('Soft reset ZNP due to timeout'); } catch (error) { - logger.warning(`Soft reset failed, trying stop/start (${error.message})`); + logger.warning(`Soft reset failed, trying stop/start (${(error as Error).message})`); await this.zigbee.stop(); logger.warning('Zigbee stopped'); @@ -53,7 +51,7 @@ export default class SoftReset extends Extension { try { await this.zigbee.start(); } catch (error) { - logger.error(`Failed to restart! (${error.message})`); + logger.error(`Failed to restart! (${(error as Error).message})`); } } diff --git a/lib/extension/networkMap.ts b/lib/extension/networkMap.ts index 81545f9fa..414b8425b 100644 --- a/lib/extension/networkMap.ts +++ b/lib/extension/networkMap.ts @@ -25,11 +25,11 @@ interface Topology { friendlyName: string; type: string; networkAddress: number; - manufacturerName: string; - modelID: string; + manufacturerName: string | undefined; + modelID: string | undefined; failed: string[]; - lastSeen: number; - definition: {model: string; vendor: string; supports: string; description: string}; + lastSeen: number | undefined; + definition?: {model: string; vendor: string; supports: string; description: string}; }[]; links: Link[]; } @@ -42,15 +42,14 @@ export default class NetworkMap extends Extension { private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/networkmap`; private legacyTopicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`; private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`; - private supportedFormats: {[s: string]: (topology: Topology) => KeyValue | string}; + 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); - this.supportedFormats = { - raw: this.raw, - graphviz: this.graphviz, - plantuml: this.plantuml, - }; } @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise { @@ -76,9 +75,9 @@ export default class NetworkMap extends Extension { 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}, null))); + await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {routes, type, value}))); } catch (error) { - await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {}, error.message))); + await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {}, (error as Error).message))); } } } @@ -238,9 +237,9 @@ export default class NetworkMap extends Extension { lqis.set(device, result); logger.debug(`LQI succeeded for '${device.name}'`); } catch (error) { - failed.get(device).push('lqi'); + failed.get(device)!.push('lqi'); // set above logger.error(`Failed to execute LQI for '${device.name}'`); - logger.debug(error.stack); + logger.debug((error as Error).stack!); } if (includeRoutes) { @@ -249,9 +248,9 @@ export default class NetworkMap extends Extension { routingTables.set(device, result); logger.debug(`Routing table succeeded for '${device.name}'`); } catch (error) { - failed.get(device).push('routingTable'); + failed.get(device)!.push('routingTable'); // set above logger.error(`Failed to execute routing table for '${device.name}'`); - logger.debug(error.stack); + logger.debug((error as Error).stack!); } } } @@ -275,12 +274,12 @@ export default class NetworkMap extends Extension { supports: Array.from( new Set( device.exposes().map((e) => { - return e.name ?? `${e.type} (${e.features.map((f) => f.name).join(', ')})`; + return e.name ?? `${e.type} (${e.features?.map((f) => f.name).join(', ')})`; }), ), ).join(', '), } - : null; + : undefined; topology.nodes.push({ ieeeAddr: device.ieeeAddr, @@ -289,7 +288,7 @@ export default class NetworkMap extends Extension { networkAddress: device.zh.networkAddress, manufacturerName: device.zh.manufacturerName, modelID: device.zh.modelID, - failed: failed.get(device), + failed: failed.get(device)!, lastSeen: device.zh.lastSeen, definition, }); diff --git a/lib/extension/otaUpdate.ts b/lib/extension/otaUpdate.ts index 33c70c666..a2d408d23 100644 --- a/lib/extension/otaUpdate.ts +++ b/lib/extension/otaUpdate.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import bind from 'bind-decorator'; import stringify from 'json-stable-stringify-without-jsonify'; import path from 'path'; @@ -86,8 +87,8 @@ export default class OTAUpdate extends Extension { logger.debug(`Device '${data.device.name}' requested OTA`); const automaticOTACheckDisabled = settings.get().ota.disable_automatic_update_check; - let supportsOTA = !!data.device.definition.ota; - if (supportsOTA && !automaticOTACheckDisabled) { + + if (data.device.definition.ota && !automaticOTACheckDisabled) { // When a device does a next image request, it will usually do it a few times after each other // with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update // each time, so this interval can be set by the user. The default is 1,440 minutes (one day). @@ -98,13 +99,12 @@ export default class OTAUpdate extends Extension { if (!check) return; this.lastChecked[data.device.ieeeAddr] = Date.now(); - let availableResult: zhc.OtaUpdateAvailableResult = null; + let availableResult: zhc.OtaUpdateAvailableResult | undefined; + try { availableResult = await data.device.definition.ota.isUpdateAvailable(data.device.zh, data.data as zhc.ota.ImageInfo); - } catch (e) { - supportsOTA = false; - logger.debug(`Failed to check if update available for '${data.device.name}' (${e.message})`); - logger.debug(e.stack); + } catch (error) { + logger.debug(`Failed to check if update available for '${data.device.name}' (${error})`); } const payload = this.getEntityPublishPayload(data.device, availableResult ?? 'idle'); @@ -134,21 +134,25 @@ export default class OTAUpdate extends Extension { logger.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`); } - private async readSoftwareBuildIDAndDateCode(device: Device, sendPolicy?: 'immediate'): Promise<{softwareBuildID: string; dateCode: string}> { + private async readSoftwareBuildIDAndDateCode( + device: Device, + sendPolicy?: 'immediate', + ): Promise<{softwareBuildID: string; dateCode: string} | undefined> { try { const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic')); + assert(endpoint); const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId'], {sendPolicy}); return {softwareBuildID: result.swBuildId, dateCode: result.dateCode}; } catch { - return null; + return undefined; } } private getEntityPublishPayload( device: Device, state: zhc.OtaUpdateAvailableResult | UpdateState, - progress: number = null, - remaining: number = null, + progress?: number, + remaining?: number, ): UpdatePayload { const deviceUpdateState = this.state.get(device).update; const payload: UpdatePayload = { @@ -158,8 +162,14 @@ export default class OTAUpdate extends Extension { latest_version: typeof state === 'string' ? deviceUpdateState?.latest_version : state.otaFileVersion, }, }; - if (progress !== null) payload.update.progress = progress; - if (remaining !== null) payload.update.remaining = Math.round(remaining); + + if (progress != undefined) { + payload.update.progress = progress; + } + + if (remaining != undefined) { + payload.update.remaining = Math.round(remaining); + } /* istanbul ignore else */ if (this.legacyApi) { @@ -171,7 +181,7 @@ export default class OTAUpdate extends Extension { @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise { if ((!this.legacyApi || !data.topic.match(legacyTopicRegex)) && !data.topic.match(topicRegex)) { - return null; + return; } const message = utils.parseJSON(data.message, data.message); @@ -179,8 +189,8 @@ export default class OTAUpdate extends Extension { const device = this.zigbee.resolveEntity(ID); const type = data.topic.substring(data.topic.lastIndexOf('/') + 1); const responseData: {id: string; updateAvailable?: boolean; from?: string; to?: string} = {id: ID}; - let error = null; - let errorStack = null; + let error: string | undefined; + let errorStack: string | undefined; if (!(device instanceof Device)) { error = `Device '${ID}' does not exist`; @@ -208,7 +218,7 @@ export default class OTAUpdate extends Extension { } try { - const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, null); + const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, undefined); const msg = `${availableResult.available ? 'Update' : 'No update'} available for '${device.name}'`; logger.info(msg); @@ -226,8 +236,8 @@ export default class OTAUpdate extends Extension { this.lastChecked[device.ieeeAddr] = Date.now(); responseData.updateAvailable = availableResult.available; } catch (e) { - error = `Failed to check if update available for '${device.name}' (${e.message})`; - errorStack = e.stack; + error = `Failed to check if update available for '${device.name}' (${(e as Error).message})`; + errorStack = (e as Error).stack; /* istanbul ignore else */ if (settings.get().advanced.legacy_api) { @@ -294,8 +304,8 @@ export default class OTAUpdate extends Extension { } } catch (e) { logger.debug(`Update of '${device.name}' failed (${e})`); - error = `Update of '${device.name}' failed (${e.message})`; - errorStack = e.stack; + error = `Update of '${device.name}' failed (${(e as Error).message})`; + errorStack = (e as Error).stack; this.removeProgressAndRemainingFromState(device); const payload = this.getEntityPublishPayload(device, 'available'); @@ -313,6 +323,7 @@ export default class OTAUpdate extends Extension { } const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex); + if (!triggeredViaLegacyApi) { const response = utils.getResponse(message, responseData, error); await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response)); @@ -320,6 +331,7 @@ export default class OTAUpdate extends Extension { if (error) { logger.error(error); + if (errorStack) { logger.debug(errorStack); } diff --git a/lib/extension/publish.ts b/lib/extension/publish.ts index 43d3865bb..44c1a14f3 100644 --- a/lib/extension/publish.ts +++ b/lib/extension/publish.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import bind from 'bind-decorator'; import stringify from 'json-stable-stringify-without-jsonify'; import * as zhc from 'zigbee-herdsman-converters'; @@ -17,11 +18,11 @@ export const loadTopicGetSetRegex = (): void => { }; loadTopicGetSetRegex(); -const stateValues = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock']; -const sceneConverterKeys = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all', 'scene_rename']; +const STATE_VALUES: ReadonlyArray = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock']; +const SCENE_CONVERTER_KEYS: ReadonlyArray = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all', 'scene_rename']; // Legacy: don't provide default converters anymore, this is required by older z2m installs not saving group members -const defaultGroupConverters = [ +const DEFAULT_GROUP_CONVERTERS: ReadonlyArray = [ zhc.toZigbee.light_onoff_brightness, zhc.toZigbee.light_color_colortemp, philips.tz.effect, // Support Hue effects for groups @@ -39,7 +40,7 @@ const defaultGroupConverters = [ interface ParsedTopic { ID: string; - endpoint: string; + endpoint: string | undefined; attribute: string; type: 'get' | 'set'; } @@ -49,7 +50,7 @@ export default class Publish extends Extension { this.eventBus.onMQTTMessage(this, this.onMQTTMessage); } - parseTopic(topic: string): ParsedTopic | null { + parseTopic(topic: string): ParsedTopic | undefined { // The function supports the following topic formats (below are for 'set'. 'get' will look the same): // - /device_name/set (endpoint and attribute is defined in the payload) // - /device_name/set/attribute (default endpoint used) @@ -60,7 +61,10 @@ export default class Publish extends Extension { // Before the get/set is the device name and optional endpoint name. // After it there will be an optional attribute name. const match = topic.match(topicGetSetRegex); - if (!match) return null; + + if (!match) { + return undefined; + } const deviceNameAndEndpoint = match[1]; const attribute = match[3]; @@ -70,7 +74,7 @@ export default class Publish extends Extension { return {ID: entity.ID, endpoint: entity.endpointID, type: match[2] as 'get' | 'set', attribute: attribute}; } - parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | null { + parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | undefined { if (parsedTopic.attribute) { try { return {[parsedTopic.attribute]: JSON.parse(data.message)}; @@ -81,10 +85,10 @@ export default class Publish extends Extension { try { return JSON.parse(data.message); } catch { - if (stateValues.includes(data.message.toLowerCase())) { + if (STATE_VALUES.includes(data.message.toLowerCase())) { return {state: data.message}; } else { - return null; + return undefined; } } } @@ -114,7 +118,9 @@ export default class Publish extends Extension { // Only do this when the retrieve_state option is enabled for this device. // retrieve_state == deprecated if (re instanceof Device && result && result.hasOwnProperty('readAfterWriteTime') && re.options.retrieve_state) { - setTimeout(() => converter.convertGet(target, key, meta), result.readAfterWriteTime); + const convertGet = converter.convertGet; + assert(convertGet !== undefined, 'Converter has `readAfterWriteTime` but no `convertGet`'); + setTimeout(() => convertGet(target, key, meta), result.readAfterWriteTime); } } @@ -138,48 +144,63 @@ export default class Publish extends Extension { @bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise { const parsedTopic = this.parseTopic(data.topic); - if (!parsedTopic) return; + + if (!parsedTopic) { + return; + } const re = this.zigbee.resolveEntity(parsedTopic.ID); - if (re == null) { + + if (!re) { await this.legacyLog({type: `entity_not_found`, message: {friendly_name: parsedTopic.ID}}); logger.error(`Entity '${parsedTopic.ID}' is unknown`); return; } // Get entity details - const definition = re instanceof Device ? re.definition : re.membersDefinitions(); + let definition: zhc.Definition | zhc.Definition[]; + if (re instanceof Device) { + if (!re.definition) { + logger.error(`Cannot publish to unsupported device '${re.name}'`); + return; + } + definition = re.definition; + } else { + definition = re.membersDefinitions(); + } const target = re instanceof Group ? re.zh : re.endpoint(parsedTopic.endpoint); - if (target == null) { + + if (!target) { logger.error(`Device '${re.name}' has no endpoint '${parsedTopic.endpoint}'`); return; } - const device = re instanceof Device ? re.zh : null; + + // Convert the MQTT message to a Zigbee message. + const message = this.parseMessage(parsedTopic, data); + + if (!message) { + logger.error(`Invalid message '${message}', skipping...`); + return; + } + + const device = re instanceof Device ? re.zh : undefined; const entitySettings = re.options; const entityState = this.state.get(re); const membersState = re instanceof Group ? Object.fromEntries( - re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr))]), + re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr)!)]), ) - : null; - let converters: zhc.Tz.Converter[]; - { - if (Array.isArray(definition)) { - const c = new Set(definition.map((d) => d.toZigbee).flat()); - if (c.size == 0) converters = defaultGroupConverters; - else converters = Array.from(c); - } else { - converters = definition.toZigbee; - } + : undefined; + let converters: ReadonlyArray; + + if (Array.isArray(definition)) { + const c = new Set(definition.map((d) => d.toZigbee).flat()); + converters = c.size === 0 ? DEFAULT_GROUP_CONVERTERS : Array.from(c); + } else { + converters = definition?.toZigbee; } - // Convert the MQTT message to a Zigbee message. - const message = this.parseMessage(parsedTopic, data); - if (message == null) { - logger.error(`Invalid message '${message}', skipping...`); - return; - } this.updateMessageHomeAssistant(message, entityState); /** @@ -201,29 +222,35 @@ export default class Publish extends Extension { const toPublishEntity: {[s: number | string]: Device | Group} = {}; const addToToPublish = (entity: Device | Group, payload: KeyValue): void => { const ID = entity.ID; + if (!(ID in toPublish)) { toPublish[ID] = {}; toPublishEntity[ID] = entity; } + toPublish[ID] = {...toPublish[ID], ...payload}; }; const endpointNames = re instanceof Device ? re.getEndpointNames() : []; const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join('|')})$`); + let scenesChanged = false; for (const entry of entries) { let key = entry[0]; const value = entry[1]; let endpointName = parsedTopic.endpoint; let localTarget = target; - let endpointOrGroupID = utils.isEndpoint(target) ? target.ID : target.groupID; + let endpointOrGroupID = utils.isZHEndpoint(target) ? target.ID : target.groupID; // When the key has a endpointName included (e.g. state_right), this will override the target. const propertyEndpointMatch = key.match(propertyEndpointRegex); + if (re instanceof Device && propertyEndpointMatch) { endpointName = propertyEndpointMatch[2]; key = propertyEndpointMatch[1]; - localTarget = re.endpoint(endpointName); + // endpointName is always matched to an existing endpoint of the device + // since `propertyEndpointRegex` only contains valid endpoints for this device. + localTarget = re.endpoint(endpointName)!; endpointOrGroupID = localTarget.ID; } @@ -232,7 +259,7 @@ export default class Publish extends Extension { // Match any key if the toZigbee converter defines no key. const converter = converters.find((c) => (!c.key || c.key.includes(key)) && (!c.endpoint || c.endpoint == endpointName)); - if (parsedTopic.type === 'set' && usedConverters[endpointOrGroupID].includes(converter)) { + if (parsedTopic.type === 'set' && converter && usedConverters[endpointOrGroupID].includes(converter)) { // Use a converter for set only once // (e.g. light_onoff_brightness converters can convert state and brightness) continue; @@ -244,17 +271,16 @@ export default class Publish extends Extension { } // If the endpoint_name name is a number, try to map it to a friendlyName - if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(localTarget) && re.endpointName(localTarget)) { + if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isZHEndpoint(localTarget) && re.endpointName(localTarget)) { endpointName = re.endpointName(localTarget); } // Converter didn't return a result, skip const entitySettingsKeyValue: KeyValue = entitySettings; - const meta = { + const meta: zhc.Tz.Meta = { endpoint_name: endpointName, options: entitySettingsKeyValue, message: {...message}, - logger, device, state: entityState, membersState, @@ -277,6 +303,7 @@ export default class Publish extends Extension { logger.debug(`Publishing '${parsedTopic.type}' '${key}' to '${re.name}'`); const result = await converter.convertSet(localTarget, key, value, meta); const optimistic = !entitySettings.hasOwnProperty('optimistic') || entitySettings.optimistic; + if (result && result.state && optimistic) { const msg = result.state; @@ -295,7 +322,7 @@ export default class Publish extends Extension { if (result && result.membersState && optimistic) { for (const [ieeeAddr, state] of Object.entries(result.membersState)) { - addToToPublish(this.zigbee.resolveEntity(ieeeAddr), state); + addToToPublish(this.zigbee.resolveEntity(ieeeAddr)!, state); } } @@ -310,11 +337,15 @@ export default class Publish extends Extension { } catch (error) { const message = `Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`; logger.error(message); - logger.debug(error.stack); + logger.debug((error as Error).stack!); await this.legacyLog({type: `zigbee_publish_error`, message, meta: {friendly_name: re.name}}); } usedConverters[endpointOrGroupID].push(converter); + + if (!scenesChanged && converter.key) { + scenesChanged = converter.key.some((k) => SCENE_CONVERTER_KEYS.includes(k)); + } } for (const [ID, payload] of Object.entries(toPublish)) { @@ -323,7 +354,6 @@ export default class Publish extends Extension { } } - const scenesChanged = Object.values(usedConverters).some((cl) => cl.some((c) => c.key?.some((k) => sceneConverterKeys.includes(k)))); if (scenesChanged) { this.eventBus.emitScenesChanged({entity: re}); } diff --git a/lib/extension/receive.ts b/lib/extension/receive.ts index 2292eb991..29812d113 100755 --- a/lib/extension/receive.ts +++ b/lib/extension/receive.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import bind from 'bind-decorator'; import debounce from 'debounce'; import stringify from 'json-stable-stringify-without-jsonify'; @@ -37,7 +38,7 @@ export default class Receive extends Extension { } } - publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[]): void { + publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[] | undefined): void { if (!this.debouncers[device.ieeeAddr]) { this.debouncers[device.ieeeAddr] = { payload: {}, @@ -69,7 +70,7 @@ export default class Receive extends Extension { // then all newPayload values with key present in debounce_ignore // should equal or be undefined in oldPayload // otherwise payload is conflicted - isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | null): boolean { + isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | undefined): boolean { let result = false; Object.keys(oldPayload) .filter((key) => (debounceIgnore || []).includes(key)) @@ -82,20 +83,12 @@ export default class Receive extends Extension { return result; } - shouldProcess(data: eventdata.DeviceMessage): boolean { - if (!data.device.definition || data.device.zh.interviewing) { - logger.debug(`Skipping message, still interviewing`); - return false; - } - - return true; - } - @bind async onDeviceMessage(data: eventdata.DeviceMessage): Promise { /* istanbul ignore next */ if (!data.device) return; - if (!this.shouldProcess(data)) { + if (!data.device.definition || data.device.zh.interviewing) { + logger.debug(`Skipping message, still interviewing`); await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState); return; } @@ -122,6 +115,7 @@ export default class Receive extends Extension { // - If NO payload is returned do nothing. This is for non-standard behaviour // for e.g. click switches where we need to count number of clicks and detect long presses. const publish = async (payload: KeyValue): Promise => { + assert(data.device.definition); const options: KeyValue = data.device.options; zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options); @@ -158,8 +152,8 @@ export default class Receive extends Extension { payload = {...payload, ...converted}; } } catch (error) /* istanbul ignore next */ { - logger.error(`Exception while calling fromZigbee converter: ${error.message}}`); - logger.debug(error.stack); + logger.error(`Exception while calling fromZigbee converter: ${(error as Error).message}}`); + logger.debug((error as Error).stack!); } } diff --git a/lib/model/device.ts b/lib/model/device.ts index a533c5dcf..bacc809b1 100644 --- a/lib/model/device.ts +++ b/lib/model/device.ts @@ -1,4 +1,5 @@ /* eslint-disable brace-style */ +import assert from 'assert'; import {CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype'; import * as zhc from 'zigbee-herdsman-converters'; @@ -6,8 +7,8 @@ import * as settings from '../util/settings'; export default class Device { public zh: zh.Device; - public definition: zhc.Definition; - private _definitionModelID: string; + public definition?: zhc.Definition; + private _definitionModelID?: string; get ieeeAddr(): string { return this.zh.ieeeAddr; @@ -15,14 +16,15 @@ export default class Device { get ID(): string { return this.zh.ieeeAddr; } - get options(): DeviceOptions { - return {...settings.get().device_options, ...settings.getDevice(this.ieeeAddr)}; + get options(): DeviceOptionsWithId { + const deviceOptions = settings.getDevice(this.ieeeAddr) ?? {friendly_name: this.ieeeAddr, ID: this.ieeeAddr}; + return {...settings.get().device_options, ...deviceOptions}; } get name(): string { - return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name || this.ieeeAddr; + return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name; } get isSupported(): boolean { - return this.zh.type === 'Coordinator' || (this.definition && !this.definition.generated); + return this.zh.type === 'Coordinator' || Boolean(this.definition && !this.definition.generated); } get customClusters(): CustomClusters { return this.zh.customClusters; @@ -33,6 +35,7 @@ export default class Device { } exposes(): zhc.Expose[] { + assert(this.definition, 'Cannot retreive exposes before definition is resolved'); /* istanbul ignore if */ if (typeof this.definition.exposes == 'function') { const options: KeyValue = this.options; @@ -55,28 +58,40 @@ export default class Device { } } - endpoint(key?: string | number): zh.Endpoint { - let endpoint: zh.Endpoint; - if (key == null || key == '') key = 'default'; + endpoint(key?: string | number): zh.Endpoint | undefined { + let endpoint: zh.Endpoint | undefined; + + if (key == null || key == '') { + key = 'default'; + } if (!isNaN(Number(key))) { endpoint = this.zh.getEndpoint(Number(key)); } else if (this.definition?.endpoint) { const ID = this.definition?.endpoint?.(this.zh)[key]; - if (ID) endpoint = this.zh.getEndpoint(ID); - else if (key === 'default') endpoint = this.zh.endpoints[0]; - else return null; + + if (ID) { + endpoint = this.zh.getEndpoint(ID); + } else if (key === 'default') { + endpoint = this.zh.endpoints[0]; + } else { + return undefined; + } } else { /* istanbul ignore next */ - if (key !== 'default') return null; + if (key !== 'default') { + return undefined; + } + endpoint = this.zh.endpoints[0]; } return endpoint; } - endpointName(endpoint: zh.Endpoint): string { - let epName = null; + endpointName(endpoint: zh.Endpoint): string | undefined { + let epName = undefined; + if (this.definition?.endpoint) { const mapping = this.definition?.endpoint(this.zh); for (const [name, id] of Object.entries(mapping)) { @@ -85,12 +100,21 @@ export default class Device { } } } + /* istanbul ignore next */ - return epName === 'default' ? null : epName; + return epName === 'default' ? undefined : epName; } getEndpointNames(): string[] { - return Object.keys(this.definition?.endpoint?.(this.zh) ?? {}).filter((name) => name !== 'default'); + const names: string[] = []; + + for (const name in this.definition?.endpoint?.(this.zh) ?? {}) { + if (name !== 'default') { + names.push(name); + } + } + + return names; } isIkeaTradfri(): boolean { diff --git a/lib/model/group.ts b/lib/model/group.ts index 0eaac89cd..ad443ff79 100644 --- a/lib/model/group.ts +++ b/lib/model/group.ts @@ -1,23 +1,23 @@ -/* eslint-disable brace-style */ import * as zhc from 'zigbee-herdsman-converters'; import * as settings from '../util/settings'; export default class Group { public zh: zh.Group; - private resolveDevice: (ieeeAddr: string) => Device; + private resolveDevice: (ieeeAddr: string) => Device | undefined; get ID(): number { return this.zh.groupID; } get options(): GroupOptions { - return {...settings.getGroup(this.ID)}; + // XXX: Group always exists in settings + return {...settings.getGroup(this.ID)!}; } get name(): string { return this.options?.friendly_name || this.ID.toString(); } - constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device) { + constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device | undefined) { this.zh = group; this.resolveDevice = resolveDevice; } @@ -27,13 +27,20 @@ export default class Group { } membersDevices(): Device[] { - return this.zh.members.map((e) => this.resolveDevice(e.getDevice().ieeeAddr)).filter((d) => d); + return this.zh.members.map((d) => this.resolveDevice(d.getDevice().ieeeAddr)!); } membersDefinitions(): zhc.Definition[] { - return this.membersDevices() - .map((d) => d.definition) - .filter((d) => d); + const definitions: zhc.Definition[] = []; + + for (const member of this.membersDevices()) { + /* istanbul ignore else */ + if (member.definition) { + definitions.push(member.definition); + } + } + + return definitions; } isDevice(): this is Device { diff --git a/lib/mqtt.ts b/lib/mqtt.ts index b2f4e7df9..9263aa371 100644 --- a/lib/mqtt.ts +++ b/lib/mqtt.ts @@ -12,11 +12,12 @@ const NS = 'z2m:mqtt'; export default class MQTT { private publishedTopics: Set = new Set(); - private connectionTimer: NodeJS.Timeout; + private connectionTimer?: NodeJS.Timeout; + // @ts-expect-error initialized in `connect` private client: mqtt.MqttClient; private eventBus: EventBus; private initialConnect = true; - private republishRetainedTimer: NodeJS.Timeout; + private republishRetainedTimer?: NodeJS.Timeout; public retainedMessages: { [s: string]: {payload: string; options: MQTTOptions; skipLog: boolean; skipReceive: boolean; topic: string; base: string}; } = {}; @@ -126,6 +127,7 @@ export default class MQTT { async disconnect(): Promise { clearTimeout(this.connectionTimer); + clearTimeout(this.republishRetainedTimer); await this.publish('bridge/state', utils.availabilityPayload('offline', settings.get()), {retain: true, qos: 0}); this.eventBus.removeListeners(this); logger.info('Disconnecting from MQTT server'); @@ -150,7 +152,7 @@ export default class MQTT { if (this.republishRetainedTimer && topic === `${settings.get().mqtt.base_topic}/bridge/info`) { clearTimeout(this.republishRetainedTimer); - this.republishRetainedTimer = null; + this.republishRetainedTimer = undefined; } } diff --git a/lib/state.ts b/lib/state.ts index 86b4961f8..5532cf134 100644 --- a/lib/state.ts +++ b/lib/state.ts @@ -33,7 +33,7 @@ const dontCacheProperties = [ class State { private state: {[s: string | number]: KeyValue} = {}; private file = data.joinPath('state.json'); - private timer: NodeJS.Timeout = null; + private timer?: NodeJS.Timeout; constructor( private readonly eventBus: EventBus, @@ -66,7 +66,7 @@ class State { this.state = JSON.parse(fs.readFileSync(this.file, 'utf8')); logger.debug(`Loaded state from file ${this.file}`); } catch (error) { - logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${error.message})`); + logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${(error as Error).message})`); } } else { logger.debug(`Can't load state from file ${this.file} (doesn't exist)`); @@ -79,8 +79,8 @@ class State { const json = JSON.stringify(this.state, null, 4); try { fs.writeFileSync(this.file, json, 'utf8'); - } catch (e) { - logger.error(`Failed to write state to '${this.file}' (${e.message})`); + } catch (error) { + logger.error(`Failed to write state to '${this.file}' (${error})`); } } else { logger.debug(`Not saving state`); @@ -95,7 +95,7 @@ class State { return this.state[entity.ID] || {}; } - set(entity: Group | Device, update: KeyValue, reason: string = null): KeyValue { + set(entity: Group | Device, update: KeyValue, reason?: string): KeyValue { const fromState = this.state[entity.ID] || {}; const toState = objectAssignDeep({}, fromState, update); const newCache = {...toState}; diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts index 49fc62600..19cac675c 100644 --- a/lib/types/types.d.ts +++ b/lib/types/types.d.ts @@ -21,6 +21,8 @@ import type * as zhc from 'zigbee-herdsman-converters'; import {LogLevel} from 'lib/util/settings'; +type OptionalProps = Omit & Partial>; + declare global { // Define some class types as global type EventBus = TypeEventBus; @@ -80,7 +82,7 @@ declare global { entity: Device | Group; from: KeyValue; to: KeyValue; - reason: string | null; + reason?: string; update: KeyValue; }; type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload; @@ -97,7 +99,7 @@ declare global { type Reconfigure = {device: Device}; type DeviceLeave = {ieeeAddr: string; name: string}; type GroupMembersChanged = {group: Group; action: 'remove' | 'add' | 'remove_all'; endpoint: zh.Endpoint; skipDisableReporting: boolean}; - type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason: StateChangeReason; payload: KeyValue}; + type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason?: StateChangeReason; payload: KeyValue}; type DeviceMessage = { type: ZHEvents.MessagePayloadType; device: Device; @@ -120,7 +122,7 @@ declare global { legacy_entity_attributes: boolean; legacy_triggers: boolean; }; - permit_join?: boolean; + permit_join: boolean; availability?: { active: {timeout: number}; passive: {timeout: number}; @@ -179,13 +181,13 @@ declare global { frontend?: { auth_token?: string; host?: string; - port?: number; + port: number; url?: string; ssl_cert?: string; ssl_key?: string; }; - devices?: {[s: string]: DeviceOptions}; - groups?: {[s: string]: GroupOptions}; + devices: {[s: string]: DeviceOptions}; + groups: {[s: string]: OptionalProps, 'devices'>}; device_options: KeyValue; advanced: { legacy_api: boolean; @@ -203,8 +205,8 @@ declare global { pan_id: number | 'GENERATE'; ext_pan_id: number[] | 'GENERATE'; channel: number; - adapter_concurrent: number | null; - adapter_delay: number | null; + adapter_concurrent?: number; + adapter_delay?: number; cache_state: boolean; cache_state_persistent: boolean; cache_state_send_on_startup: boolean; @@ -216,17 +218,16 @@ declare global { transmit_power?: number; // Everything below is deprecated availability_timeout?: number; - availability_blocklist?: string[]; - availability_passlist?: string[]; - availability_blacklist?: string[]; - availability_whitelist?: string[]; + availability_blocklist: string[]; + availability_passlist: string[]; + availability_blacklist: string[]; + availability_whitelist: string[]; soft_reset_timeout: number; report: boolean; }; } interface DeviceOptions { - ID?: string; disabled?: boolean; retention?: number; availability?: boolean | {timeout: number}; @@ -245,9 +246,13 @@ declare global { qos?: 0 | 1 | 2; } + interface DeviceOptionsWithId extends DeviceOptions { + ID: string; + } + interface GroupOptions { - devices?: string[]; - ID?: number; + devices: string[]; + ID: number; optimistic?: boolean; off_state?: 'all_members_off' | 'last_member_state'; filtered_attributes?: string[]; diff --git a/lib/util/data.ts b/lib/util/data.ts index b30ed1711..8480778c6 100644 --- a/lib/util/data.ts +++ b/lib/util/data.ts @@ -1,17 +1,10 @@ import path from 'path'; -let dataPath: string = null; - -function load(): void { - if (process.env.ZIGBEE2MQTT_DATA) { - dataPath = process.env.ZIGBEE2MQTT_DATA; - } else { - dataPath = path.join(__dirname, '..', '..', 'data'); - dataPath = path.normalize(dataPath); - } +function setPath(): string { + return process.env.ZIGBEE2MQTT_DATA ? process.env.ZIGBEE2MQTT_DATA : path.normalize(path.join(__dirname, '..', '..', 'data')); } -load(); +let dataPath = setPath(); function joinPath(file: string): string { return path.resolve(dataPath, file); @@ -21,9 +14,8 @@ function getPath(): string { return dataPath; } -// eslint-disable-next-line camelcase -function testingOnlyReload(): void { - load(); +function _testReload(): void { + dataPath = setPath(); } -export default {joinPath, getPath, testingOnlyReload}; +export default {joinPath, getPath, _testReload}; diff --git a/lib/util/logger.ts b/lib/util/logger.ts index 71ea89a7c..d055a3f4c 100644 --- a/lib/util/logger.ts +++ b/lib/util/logger.ts @@ -11,13 +11,20 @@ import * as settings from './settings'; const NAMESPACE_SEPARATOR = ':'; class Logger { + // @ts-expect-error initalized in `init` private level: settings.LogLevel; + // @ts-expect-error initalized in `init` private output: string[]; + // @ts-expect-error initalized in `init` private directory: string; + // @ts-expect-error initalized in `init` private logger: winston.Logger; + // @ts-expect-error initalized in `init` private fileTransport: winston.transports.FileTransportInstance; private debugNamespaceIgnoreRegex?: RegExp; + // @ts-expect-error initalized in `init` private namespacedLevels: Record; + // @ts-expect-error initalized in `init` private cachedNamespacedLevels: Record; public init(): void { diff --git a/lib/util/settings.ts b/lib/util/settings.ts index 56515bdfc..872b51c4f 100644 --- a/lib/util/settings.ts +++ b/lib/util/settings.ts @@ -5,9 +5,9 @@ import path from 'path'; import data from './data'; import schemaJson from './settings.schema.json'; import utils from './utils'; -import yaml from './yaml'; -export let schema = schemaJson; -// @ts-expect-error +import yaml, {YAMLFileException} from './yaml'; +export let schema: KeyValue = schemaJson; + schema = {}; objectAssignDeep(schema, schemaJson); @@ -23,8 +23,8 @@ objectAssignDeep(schema, schemaJson); delete schema.properties.advanced.properties.rtscts; delete schema.properties.advanced.properties.ikea_ota_use_test_url; delete schema.properties.experimental; - delete schemaJson.properties.whitelist; - delete schemaJson.properties.ban; + delete (schemaJson as KeyValue).properties.whitelist; + delete (schemaJson as KeyValue).properties.ban; } /** NOTE: by order of priority, lower index is lower level (more important) */ @@ -96,8 +96,8 @@ const defaults: RecursivePartial = { pan_id: 0x1a62, ext_pan_id: [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd], channel: 11, - adapter_concurrent: null, - adapter_delay: null, + adapter_concurrent: undefined, + adapter_delay: undefined, cache_state: true, cache_state_persistent: true, cache_state_send_on_startup: true, @@ -116,10 +116,13 @@ const defaults: RecursivePartial = { }, }; -let _settings: Partial; -let _settingsWithDefaults: Settings; +let _settings: Partial | undefined; +let _settingsWithDefaults: Settings | undefined; function loadSettingsWithDefaults(): void { + if (!_settings) { + _settings = read(); + } _settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings; if (!_settingsWithDefaults.devices) { @@ -151,6 +154,7 @@ function loadSettingsWithDefaults(): void { const s = typeof _settingsWithDefaults.homeassistant === 'object' ? _settingsWithDefaults.homeassistant : {}; // @ts-expect-error _settingsWithDefaults.homeassistant = {}; + // @ts-expect-error objectAssignDeep(_settingsWithDefaults.homeassistant, defaults, sLegacy, s); } @@ -159,13 +163,16 @@ function loadSettingsWithDefaults(): void { const s = typeof _settingsWithDefaults.availability === 'object' ? _settingsWithDefaults.availability : {}; // @ts-expect-error _settingsWithDefaults.availability = {}; + // @ts-expect-error objectAssignDeep(_settingsWithDefaults.availability, defaults, s); } if (_settingsWithDefaults.frontend) { const defaults = {port: 8080, auth_token: false}; const s = typeof _settingsWithDefaults.frontend === 'object' ? _settingsWithDefaults.frontend : {}; + // @ts-expect-error _settingsWithDefaults.frontend = {}; + // @ts-expect-error objectAssignDeep(_settingsWithDefaults.frontend, defaults, s); } @@ -259,12 +266,12 @@ function write(): void { // If an array, only write to first file and only devices which are not in the other files. if (Array.isArray(actual[type])) { - actual[type] - .filter((f: string, i: number) => i !== 0) - .map((f: string) => yaml.readIfExists(data.joinPath(f), {})) - .map((c: KeyValue) => Object.keys(c)) - // @ts-expect-error - .forEach((k: string) => delete content[k]); + // skip i==0 + for (let i = 1; i < actual[type].length; i++) { + for (const key in yaml.readIfExists(data.joinPath(actual[type][i]))) { + delete content[key]; + } + } } yaml.writeIfChanged(data.joinPath(fileToWrite), content); @@ -285,18 +292,20 @@ export function validate(): string[] { try { getInternalSettings(); } catch (error) { - if (error.name === 'YAMLException') { + if (error instanceof YAMLFileException) { return [`Your YAML file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`]; } - return [error.message]; + return [`${error}`]; } if (!ajvSetting(_settings)) { - return ajvSetting.errors.map((v) => `${v.instancePath.substring(1)} ${v.message}`); + // When `ajvSetting()` return false it always has `errors`. + return ajvSetting.errors!.map((v) => `${v.instancePath.substring(1)} ${v.message}`); } const errors = []; + if ( _settings.advanced && _settings.advanced.network_key && @@ -405,7 +414,7 @@ function read(): Settings { const files: string[] = Array.isArray(s[type]) ? s[type] : [s[type]]; s[type] = {}; for (const file of files) { - const content = yaml.readIfExists(data.joinPath(file), {}); + const content = yaml.readIfExists(data.joinPath(file)); /* eslint-disable-line */ // @ts-expect-error s[type] = objectAssignDeep.noMutate(s[type], content); } @@ -420,35 +429,41 @@ function read(): Settings { function applyEnvironmentVariables(settings: Partial): void { const iterate = (obj: KeyValue, path: string[]): void => { - Object.keys(obj).forEach((key) => { + for (const key in obj) { if (key !== 'type') { if (key !== 'properties' && obj[key]) { const type = (obj[key].type || 'object').toString(); const envPart = path.reduce((acc, val) => `${acc}${val}_`, ''); const envVariableName = `ZIGBEE2MQTT_CONFIG_${envPart}${key}`.toUpperCase(); - if (process.env[envVariableName]) { + const envVariable = process.env[envVariableName]; + + if (envVariable) { const setting = path.reduce((acc, val) => { - /* eslint-disable-line */ // @ts-expect-error + // @ts-expect-error acc[val] = acc[val] || {}; - /* eslint-disable-line */ // @ts-expect-error + // @ts-expect-error return acc[val]; }, settings); if (type.indexOf('object') >= 0 || type.indexOf('array') >= 0) { try { - setting[key] = JSON.parse(process.env[envVariableName]); + // @ts-expect-error + setting[key] = JSON.parse(envVariable); } catch { - setting[key] = process.env[envVariableName]; + // @ts-expect-error + setting[key] = envVariable; } } else if (type.indexOf('number') >= 0) { - /* eslint-disable-line */ // @ts-expect-error - setting[key] = process.env[envVariableName] * 1; + // @ts-expect-error + setting[key] = (envVariable as unknown as number) * 1; } else if (type.indexOf('boolean') >= 0) { - setting[key] = process.env[envVariableName].toLowerCase() === 'true'; + // @ts-expect-error + setting[key] = envVariable.toLowerCase() === 'true'; } else { /* istanbul ignore else */ if (type.indexOf('string') >= 0) { - setting[key] = process.env[envVariableName]; + // @ts-expect-error + setting[key] = envVariable; } } } @@ -456,14 +471,17 @@ function applyEnvironmentVariables(settings: Partial): void { if (typeof obj[key] === 'object' && obj[key]) { const newPath = [...path]; + if (key !== 'properties' && key !== 'oneOf' && !Number.isInteger(Number(key))) { newPath.push(key); } + iterate(obj[key], newPath); } } - }); + } }; + iterate(schemaJson.properties, []); } @@ -480,7 +498,7 @@ export function get(): Settings { loadSettingsWithDefaults(); } - return _settingsWithDefaults; + return _settingsWithDefaults!; } export function set(path: string[], value: string | number | boolean | KeyValue): void { @@ -505,7 +523,7 @@ export function set(path: string[], value: string | number | boolean | KeyValue) export function apply(settings: Record): boolean { getInternalSettings(); // Ensure _settings is initialized. - /* eslint-disable-line */ // @ts-expect-error + // @ts-expect-error const newSettings = objectAssignDeep.noMutate(_settings, settings); utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS); ajvSetting(newSettings); @@ -519,13 +537,16 @@ export function apply(settings: Record): boolean { write(); ajvRestartRequired(settings); - const restartRequired = ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart'); + + const restartRequired = Boolean(ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart')); + return restartRequired; } -export function getGroup(IDorName: string | number): GroupOptions { +export function getGroup(IDorName: string | number): GroupOptions | undefined { const settings = get(); const byID = settings.groups[IDorName]; + if (byID) { return {devices: [], ...byID, ID: Number(IDorName)}; } @@ -536,11 +557,12 @@ export function getGroup(IDorName: string | number): GroupOptions { } } - return null; + return undefined; } export function getGroups(): GroupOptions[] { const settings = get(); + return Object.entries(settings.groups).map(([ID, group]) => { return {devices: [], ...group, ID: Number(ID)}; }); @@ -548,6 +570,7 @@ export function getGroups(): GroupOptions[] { function getGroupThrowIfNotExists(IDorName: string): GroupOptions { const group = getGroup(IDorName); + if (!group) { throw new Error(`Group '${IDorName}' does not exist`); } @@ -555,9 +578,10 @@ function getGroupThrowIfNotExists(IDorName: string): GroupOptions { return group; } -export function getDevice(IDorName: string): DeviceOptions { +export function getDevice(IDorName: string): DeviceOptionsWithId | undefined { const settings = get(); const byID = settings.devices[IDorName]; + if (byID) { return {...byID, ID: IDorName}; } @@ -568,10 +592,10 @@ export function getDevice(IDorName: string): DeviceOptions { } } - return null; + return undefined; } -function getDeviceThrowIfNotExists(IDorName: string): DeviceOptions { +function getDeviceThrowIfNotExists(IDorName: string): DeviceOptionsWithId { const device = getDevice(IDorName); if (!device) { throw new Error(`Device '${IDorName}' does not exist`); @@ -580,7 +604,7 @@ function getDeviceThrowIfNotExists(IDorName: string): DeviceOptions { return device; } -export function addDevice(ID: string): DeviceOptions { +export function addDevice(ID: string): DeviceOptionsWithId { if (getDevice(ID)) { throw new Error(`Device '${ID}' already exists`); } @@ -593,7 +617,8 @@ export function addDevice(ID: string): DeviceOptions { settings.devices[ID] = {friendly_name: ID}; write(); - return getDevice(ID); + + return getDevice(ID)!; // valid from creation above } export function addDeviceToPasslist(ID: string): void { @@ -623,13 +648,14 @@ export function blockDevice(ID: string): void { export function removeDevice(IDorName: string): void { const device = getDeviceThrowIfNotExists(IDorName); const settings = getInternalSettings(); - delete settings.devices[device.ID]; + delete settings.devices?.[device.ID]; // Remove device from groups if (settings.groups) { const regex = new RegExp(`^(${device.friendly_name}|${device.ID})(/[^/]+)?$`); + for (const group of Object.values(settings.groups).filter((g) => g.devices)) { - group.devices = group.devices.filter((device) => !device.match(regex)); + group.devices = group.devices?.filter((device) => !device.match(regex)); } } @@ -638,6 +664,7 @@ export function removeDevice(IDorName: string): void { export function addGroup(name: string, ID?: string): GroupOptions { utils.validateFriendlyName(name, true); + if (getGroup(name) || getDevice(name)) { throw new Error(`friendly_name '${name}' is already in use`); } @@ -647,15 +674,17 @@ export function addGroup(name: string, ID?: string): GroupOptions { settings.groups = {}; } - if (ID == null) { + if (ID == undefined) { // look for free ID ID = '1'; + while (settings.groups.hasOwnProperty(ID)) { ID = (Number.parseInt(ID) + 1).toString(); } } else { // ensure provided ID is not in use ID = ID.toString(); + if (settings.groups.hasOwnProperty(ID)) { throw new Error(`Group ID '${ID}' is already in use`); } @@ -664,22 +693,25 @@ export function addGroup(name: string, ID?: string): GroupOptions { settings.groups[ID] = {friendly_name: name}; write(); - return getGroup(ID); + return getGroup(ID)!; // valid from creation above } -function groupGetDevice(group: {devices?: string[]}, keys: string[]): string { +function groupGetDevice(group: {devices?: string[]}, keys: string[]): string | undefined { for (const device of group.devices ?? []) { - if (keys.includes(device)) return device; + if (keys.includes(device)) { + return device; + } } - return null; + return undefined; } export function addDeviceToGroup(IDorName: string, keys: string[]): void { - const groupID = getGroupThrowIfNotExists(IDorName).ID; + const groupID = getGroupThrowIfNotExists(IDorName).ID!; const settings = getInternalSettings(); - const group = settings.groups[groupID]; + const group = settings.groups![groupID]; + if (!groupGetDevice(group, keys)) { if (!group.devices) group.devices = []; group.devices.push(keys[0]); @@ -688,14 +720,16 @@ export function addDeviceToGroup(IDorName: string, keys: string[]): void { } export function removeDeviceFromGroup(IDorName: string, keys: string[]): void { - const groupID = getGroupThrowIfNotExists(IDorName).ID; + const groupID = getGroupThrowIfNotExists(IDorName).ID!; const settings = getInternalSettings(); - const group = settings.groups[groupID]; + const group = settings.groups![groupID]; + if (!group.devices) { return; } const key = groupGetDevice(group, keys); + if (key) { group.devices = group.devices.filter((d) => d != key); write(); @@ -703,9 +737,10 @@ export function removeDeviceFromGroup(IDorName: string, keys: string[]): void { } export function removeGroup(IDorName: string | number): void { - const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID; + const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID!; const settings = getInternalSettings(); - delete settings.groups[groupID]; + + delete settings.groups![groupID]; write(); } @@ -714,21 +749,29 @@ export function changeEntityOptions(IDorName: string, newOptions: KeyValue): boo delete newOptions.friendly_name; delete newOptions.devices; let validator: ValidateFunction; - if (getDevice(IDorName)) { - objectAssignDeep(settings.devices[getDevice(IDorName).ID], newOptions); - utils.removeNullPropertiesFromObject(settings.devices[getDevice(IDorName).ID], NULLABLE_SETTINGS); + const device = getDevice(IDorName); + + if (device) { + objectAssignDeep(settings.devices![device.ID], newOptions); + utils.removeNullPropertiesFromObject(settings.devices![device.ID], NULLABLE_SETTINGS); validator = ajvRestartRequiredDeviceOptions; - } else if (getGroup(IDorName)) { - objectAssignDeep(settings.groups[getGroup(IDorName).ID], newOptions); - utils.removeNullPropertiesFromObject(settings.groups[getGroup(IDorName).ID], NULLABLE_SETTINGS); - validator = ajvRestartRequiredGroupOptions; } else { - throw new Error(`Device or group '${IDorName}' does not exist`); + const group = getGroup(IDorName); + + if (group) { + objectAssignDeep(settings.groups![group.ID], newOptions); + utils.removeNullPropertiesFromObject(settings.groups![group.ID], NULLABLE_SETTINGS); + validator = ajvRestartRequiredGroupOptions; + } else { + throw new Error(`Device or group '${IDorName}' does not exist`); + } } write(); validator(newOptions); - const restartRequired = validator.errors && !!validator.errors.find((e) => e.keyword === 'requiresRestart'); + + const restartRequired = Boolean(validator.errors && !!validator.errors.find((e) => e.keyword === 'requiresRestart')); + return restartRequired; } @@ -739,29 +782,35 @@ export function changeFriendlyName(IDorName: string, newName: string): void { } const settings = getInternalSettings(); - if (getDevice(IDorName)) { - settings.devices[getDevice(IDorName).ID].friendly_name = newName; - } else if (getGroup(IDorName)) { - settings.groups[getGroup(IDorName).ID].friendly_name = newName; + const device = getDevice(IDorName); + + if (device) { + settings.devices![device.ID].friendly_name = newName; } else { - throw new Error(`Device or group '${IDorName}' does not exist`); + const group = getGroup(IDorName); + + if (group) { + settings.groups![group.ID].friendly_name = newName; + } else { + throw new Error(`Device or group '${IDorName}' does not exist`); + } } write(); } export function reRead(): void { - _settings = null; + _settings = undefined; getInternalSettings(); - _settingsWithDefaults = null; + _settingsWithDefaults = undefined; get(); } export const testing = { write, clear: (): void => { - _settings = null; - _settingsWithDefaults = null; + _settings = undefined; + _settingsWithDefaults = undefined; }, defaults, }; diff --git a/lib/util/utils.ts b/lib/util/utils.ts index 96dea0847..f599f61cb 100644 --- a/lib/util/utils.ts +++ b/lib/util/utils.ts @@ -1,5 +1,6 @@ import type * as zhc from 'zigbee-herdsman-converters'; +import assert from 'assert'; import equals from 'fast-deep-equal/es6'; import fs from 'fs'; import humanizeDuration from 'humanize-duration'; @@ -43,19 +44,19 @@ function capitalize(s: string): string { return s[0].toUpperCase() + s.slice(1); } -async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash: string; version: string}> { +async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash?: string; version: string}> { const git = await import('git-last-commit'); const packageJSON = await import('../..' + '/package.json'); if (!includeCommitHash) { - return {version: packageJSON.version, commitHash: null}; + return {version: packageJSON.version, commitHash: undefined}; } return new Promise((resolve) => { const version = packageJSON.version; git.getLastCommit((err: Error, commit: {shortHash: string}) => { - let commitHash = null; + let commitHash = undefined; if (err) { try { @@ -122,12 +123,17 @@ function getObjectProperty(object: KeyValue, key: string, defaultValue: unknown) return object && object.hasOwnProperty(key) ? object[key] : defaultValue; } -function getResponse(request: KeyValue | string, data: KeyValue, error: string): MQTTResponse { +function getResponse(request: KeyValue | string, data: KeyValue, error?: string): MQTTResponse { const response: MQTTResponse = {data, status: error ? 'error' : 'ok'}; - if (error) response.error = error; + + if (error) { + response.error = error; + } + if (typeof request === 'object' && request.hasOwnProperty('transaction')) { response.transaction = request.transaction; } + return response; } @@ -319,12 +325,12 @@ function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settin return true; } -function isEndpoint(obj: unknown): obj is zh.Endpoint { - return obj.constructor.name.toLowerCase() === 'endpoint'; +function isZHEndpoint(obj: unknown): obj is zh.Endpoint { + return obj?.constructor.name.toLowerCase() === 'endpoint'; } function flatten(arr: Type[][]): Type[] { - return [].concat(...arr); + return ([] as Type[]).concat(...arr); } function arrayUnique(arr: Type[]): Type[] { @@ -332,7 +338,7 @@ function arrayUnique(arr: Type[]): Type[] { } function isZHGroup(obj: unknown): obj is zh.Group { - return obj.constructor.name.toLowerCase() === 'group'; + return obj?.constructor.name.toLowerCase() === 'group'; } function availabilityPayload(state: 'online' | 'offline', settings: Settings): string { @@ -362,7 +368,7 @@ async function publishLastSeen( } } -function filterProperties(filter: string[], data: KeyValue): void { +function filterProperties(filter: string[] | undefined, data: KeyValue): void { if (filter) { for (const property of Object.keys(data)) { if (filter.find((p) => property.match(`^${p}$`))) { @@ -372,22 +378,38 @@ function filterProperties(filter: string[], data: KeyValue): void { } } -export function isNumericExposeFeature(feature: zhc.Expose): feature is zhc.Numeric { - return feature?.type === 'numeric'; +export function isNumericExpose(expose: zhc.Expose): expose is zhc.Numeric { + return expose?.type === 'numeric'; } -export function isEnumExposeFeature(feature: zhc.Expose): feature is zhc.Enum { - return feature?.type === 'enum'; +export function assertEnumExpose(expose: zhc.Expose): asserts expose is zhc.Enum { + assert(expose?.type === 'enum'); } -export function isBinaryExposeFeature(feature: zhc.Expose): feature is zhc.Binary { - return feature?.type === 'binary'; +export function assertNumericExpose(expose: zhc.Expose): asserts expose is zhc.Numeric { + assert(expose?.type === 'numeric'); +} + +export function assertBinaryExpose(expose: zhc.Expose): asserts expose is zhc.Binary { + assert(expose?.type === 'binary'); +} + +export function isEnumExpose(expose: zhc.Expose): expose is zhc.Enum { + return expose?.type === 'enum'; +} + +export function isBinaryExpose(expose: zhc.Expose): expose is zhc.Binary { + return expose?.type === 'binary'; +} + +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} = {}; - const endpoints = isEndpoint(entity) ? [entity] : entity.members; - const groupID = isEndpoint(entity) ? 0 : entity.groupID; + const endpoints = isZHEndpoint(entity) ? [entity] : entity.members; + const groupID = isZHEndpoint(entity) ? 0 : entity.groupID; for (const endpoint of endpoints) { for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) { @@ -426,7 +448,7 @@ export default { removeNullPropertiesFromObject, toNetworkAddressHex, toSnakeCase, - isEndpoint, + isZHEndpoint, isZHGroup, hours, minutes, diff --git a/lib/util/yaml.ts b/lib/util/yaml.ts index 82d4d717f..1b2fb4386 100644 --- a/lib/util/yaml.ts +++ b/lib/util/yaml.ts @@ -1,26 +1,41 @@ import equals from 'fast-deep-equal/es6'; import fs from 'fs'; -import yaml from 'js-yaml'; +import yaml, {YAMLException} from 'js-yaml'; + +export class YAMLFileException extends YAMLException { + file: string; + + constructor(error: YAMLException, file: string) { + super(error.reason, error.mark); + + this.name = 'YAMLFileException'; + this.cause = error.cause; + this.message = error.message; + this.stack = error.stack; + this.file = file; + } +} function read(file: string): KeyValue { try { const result = yaml.load(fs.readFileSync(file, 'utf8')); return (result as KeyValue) ?? {}; } catch (error) { - if (error.name === 'YAMLException') { - error.file = file; + if (error instanceof YAMLException) { + throw new YAMLFileException(error, file); } throw error; } } -function readIfExists(file: string, default_?: KeyValue): KeyValue { - return fs.existsSync(file) ? read(file) : default_; +function readIfExists(file: string, fallback: KeyValue = {}): KeyValue { + return fs.existsSync(file) ? read(file) : fallback; } function writeIfChanged(file: string, content: KeyValue): void { const before = readIfExists(file); + if (!equals(before, content)) { fs.writeFileSync(file, yaml.dump(content)); } diff --git a/lib/zigbee.ts b/lib/zigbee.ts index c7196c0e9..e2796c9a2 100644 --- a/lib/zigbee.ts +++ b/lib/zigbee.ts @@ -14,6 +14,7 @@ import utils from './util/utils'; const entityIDRegex = new RegExp(`^(.+?)(?:/([^/]+))?$`); export default class Zigbee { + // @ts-expect-error initialized in start private herdsman: Controller; private eventBus: EventBus; private groupLookup: {[s: number]: Group} = {}; @@ -73,18 +74,18 @@ export default class Zigbee { this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected()); this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => { - this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr), reason: data.reason}); + this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)!, reason: data.reason}); }); this.herdsman.on('permitJoinChanged', (data: ZHEvents.PermitJoinChangedPayload) => { this.eventBus.emitPermitJoinChanged(data); }); this.herdsman.on('deviceNetworkAddressChanged', (data: ZHEvents.DeviceNetworkAddressChangedPayload) => { - const device = this.resolveDevice(data.device.ieeeAddr); + const device = this.resolveDevice(data.device.ieeeAddr)!; logger.debug(`Device '${device.name}' changed network address`); this.eventBus.emitDeviceNetworkAddressChanged({device}); }); this.herdsman.on('deviceAnnounce', (data: ZHEvents.DeviceAnnouncePayload) => { - const device = this.resolveDevice(data.device.ieeeAddr); + const device = this.resolveDevice(data.device.ieeeAddr)!; logger.debug(`Device '${device.name}' announced itself`); this.eventBus.emitDeviceAnnounce({device}); }); @@ -109,7 +110,7 @@ export default class Zigbee { this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name}); }); this.herdsman.on('message', async (data: ZHEvents.MessagePayload) => { - const device = this.resolveDevice(data.device.ieeeAddr); + const device = this.resolveDevice(data.device.ieeeAddr)!; await device.resolveDefinition(); logger.debug( `Received Zigbee message from '${device.name}', type '${data.type}', ` + @@ -133,7 +134,7 @@ export default class Zigbee { try { await device.zh.removeFromNetwork(); } catch (error) { - logger.error(`Failed to remove '${device.ieeeAddr}' (${error.message})`); + logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`); } }; @@ -157,7 +158,7 @@ export default class Zigbee { logger.info(`Successfully interviewed '${name}', device has successfully been paired`); if (data.device.isSupported) { - const {vendor, description, model} = data.device.definition; + const {vendor, description, model} = data.device.definition!; logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`); } else { logger.warning( @@ -206,7 +207,7 @@ export default class Zigbee { async coordinatorCheck(): Promise<{missingRouters: Device[]}> { const check = await this.herdsman.coordinatorCheck(); - return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr))}; + return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr)!)}; } async getNetworkParameters(): Promise { @@ -227,11 +228,11 @@ export default class Zigbee { return this.herdsman.getPermitJoin(); } - getPermitJoinTimeout(): number { + getPermitJoinTimeout(): number | undefined { return this.herdsman.getPermitJoinTimeout(); } - async permitJoin(permit: boolean, device?: Device, time: number = undefined): Promise { + async permitJoin(permit: boolean, device?: Device, time?: number): Promise { if (permit) { logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`); } else { @@ -245,7 +246,7 @@ export default class Zigbee { } } - @bind private resolveDevice(ieeeAddr: string): Device { + @bind private resolveDevice(ieeeAddr: string): Device | undefined { if (!this.deviceLookup[ieeeAddr]) { const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr); if (device) { @@ -269,16 +270,20 @@ export default class Zigbee { return this.groupLookup[groupID]; } - resolveEntity(key: string | number | zh.Device): Device | Group { + resolveEntity(key: string | number | zh.Device): Device | Group | undefined { if (typeof key === 'object') { return this.resolveDevice(key.ieeeAddr); } else if (typeof key === 'string' && key.toLowerCase() === 'coordinator') { return this.resolveDevice(this.herdsman.getDevicesByType('Coordinator')[0].ieeeAddr); } else { const settingsDevice = settings.getDevice(key.toString()); - if (settingsDevice) return this.resolveDevice(settingsDevice.ID); + + if (settingsDevice) { + return this.resolveDevice(settingsDevice.ID); + } const groupSettings = settings.getGroup(key); + if (groupSettings) { const group = this.resolveGroup(groupSettings.ID); // If group does not exist, create it (since it's already in configuration.yaml) @@ -287,7 +292,7 @@ export default class Zigbee { } } - resolveEntityAndEndpoint(ID: string): {ID: string; entity: Device | Group; endpointID: string; endpoint: zh.Endpoint} { + resolveEntityAndEndpoint(ID: string): {ID: string; entity: Device | Group | undefined; endpointID?: string; endpoint?: zh.Endpoint} { // This function matches the following entity formats: // device_name (just device name) // device_name/ep_name (device name and endpoint numeric ID or name) @@ -297,25 +302,28 @@ export default class Zigbee { // The function tries to find an exact match first let entityName = ID; let deviceOrGroup = this.resolveEntity(ID); - let endpointNameOrID = undefined; + let endpointNameOrID: string | undefined; - // If exact match did not happenc, try matching a device_name/endpoint pattern + // If exact match did not happen, try matching a device_name/endpoint pattern if (!deviceOrGroup) { // First split the input token by the latest slash const match = ID.match(entityIDRegex); - // Get the resulting IDs from the match - entityName = match[1]; - deviceOrGroup = this.resolveEntity(match[1]); - endpointNameOrID = match[2]; + /* istanbul ignore else */ + if (match) { + // Get the resulting IDs from the match + entityName = match[1]; + deviceOrGroup = this.resolveEntity(entityName); + endpointNameOrID = match[2]; + } } // If the function returns non-null endpoint name, but the endpoint field is null, then // it means that endpoint was not matched because there is no such endpoint on the device // (or the entity is a group) - const endpoint = deviceOrGroup?.isDevice() ? deviceOrGroup.endpoint(endpointNameOrID) : null; + const endpoint = deviceOrGroup?.isDevice() ? deviceOrGroup.endpoint(endpointNameOrID) : undefined; - return {ID: entityName, entity: deviceOrGroup, endpointID: endpointNameOrID, endpoint: endpoint}; + return {ID: entityName, entity: deviceOrGroup, endpointID: endpointNameOrID, endpoint}; } firstCoordinatorEndpoint(): zh.Endpoint { @@ -327,7 +335,7 @@ export default class Zigbee { groupPredicate?: (value: zh.Group) => boolean, ): Generator { for (const device of this.herdsman.getDevicesIterator(devicePredicate)) { - yield this.resolveDevice(device.ieeeAddr); + yield this.resolveDevice(device.ieeeAddr)!; } for (const group of this.herdsman.getGroupsIterator(groupPredicate)) { @@ -343,7 +351,7 @@ export default class Zigbee { *devicesIterator(predicate?: (value: zh.Device) => boolean): Generator { for (const device of this.herdsman.getDevicesIterator(predicate)) { - yield this.resolveDevice(device.ieeeAddr); + yield this.resolveDevice(device.ieeeAddr)!; } } @@ -397,7 +405,7 @@ export default class Zigbee { return this.resolveGroup(ID); } - deviceByNetworkAddress(networkAddress: number): Device { + deviceByNetworkAddress(networkAddress: number): Device | undefined { const device = this.herdsman.getDeviceByNetworkAddress(networkAddress); return device && this.resolveDevice(device.ieeeAddr); } diff --git a/scripts/zStackEraseAllNvMem.js b/scripts/zStackEraseAllNvMem.js index b22f5006a..3b4abc54e 100644 --- a/scripts/zStackEraseAllNvMem.js +++ b/scripts/zStackEraseAllNvMem.js @@ -65,12 +65,12 @@ class ZStackNvMemEraser { if (len != 0) { console.log(`NVMEM item #${id} - deleting, size: ${len}`); if (needOsal) { - await this.znp.request(Subsystem.SYS, 'osalNvDelete', {id: id, len: len}, null, null, [ + await this.znp.request(Subsystem.SYS, 'osalNvDelete', {id: id, len: len}, undefined, undefined, [ ZnpCommandStatus.SUCCESS, ZnpCommandStatus.NV_ITEM_INITIALIZED, ]); } else { - await this.znp.request(Subsystem.SYS, 'nvDelete', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, null, null, [ + await this.znp.request(Subsystem.SYS, 'nvDelete', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, undefined, undefined, [ ZnpCommandStatus.SUCCESS, ZnpCommandStatus.NV_ITEM_INITIALIZED, ]); diff --git a/test/availability.test.js b/test/availability.test.js index cf93d6b89..3294a88f6 100644 --- a/test/availability.test.js +++ b/test/availability.test.js @@ -13,7 +13,17 @@ import stringify from 'json-stable-stringify-without-jsonify'; const mocks = [MQTT.publish, logger.warning, logger.info]; const devices = zigbeeHerdsman.devices; zigbeeHerdsman.returnDevices.push( - ...[devices.bulb_color.ieeeAddr, devices.bulb_color_2.ieeeAddr, devices.coordinator.ieeeAddr, devices.remote.ieeeAddr], + ...[ + devices.bulb_color.ieeeAddr, + devices.bulb_color_2.ieeeAddr, + devices.coordinator.ieeeAddr, + devices.remote.ieeeAddr, + devices.TS0601_thermostat.ieeeAddr, + devices.bulb_2.ieeeAddr, + devices.ZNCZ02LM.ieeeAddr, + devices.GLEDOPTO_2ID.ieeeAddr, + devices.QBKG03LM.ieeeAddr, + ], ); describe('Availability', () => { @@ -289,7 +299,7 @@ describe('Availability', () => { MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb_color', to: 'bulb_new_name'})); await flushPromises(); - expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', null, {retain: true, qos: 1}, expect.any(Function)); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', '', {retain: true, qos: 1}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', 'online', {retain: true, qos: 1}, expect.any(Function)); await setTimeAndAdvanceTimers(utils.hours(12)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', 'offline', {retain: true, qos: 1}, expect.any(Function)); diff --git a/test/bridge.test.js b/test/bridge.test.js index 029e2e45d..20213507c 100644 --- a/test/bridge.test.js +++ b/test/bridge.test.js @@ -92,8 +92,8 @@ describe('Bridge', () => { commit: version.commitHash, config: { advanced: { - adapter_concurrent: null, - adapter_delay: null, + adapter_concurrent: undefined, + adapter_delay: undefined, availability_blacklist: [], availability_blocklist: [], availability_passlist: [], @@ -239,7 +239,7 @@ describe('Bridge', () => { stringify([ { date_code: null, - definition: null, + // definition: null, disabled: false, endpoints: {1: {bindings: [], clusters: {input: [], output: []}, configured_reportings: [], scenes: []}}, friendly_name: 'Coordinator', @@ -2561,7 +2561,7 @@ describe('Bridge', () => { it('Should republish bridge info when permit join changes', async () => { MQTT.publish.mockClear(); - await zigbeeHerdsman.events.permitJoinChanged({permitted: false, time: 10}); + await zigbeeHerdsman.events.permitJoinChanged({permitted: false, timeout: 10}); await flushPromises(); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), {retain: true, qos: 0}, expect.any(Function)); }); @@ -2569,7 +2569,7 @@ describe('Bridge', () => { it('Shouldnt republish bridge info when permit join changes and hersman is stopping', async () => { MQTT.publish.mockClear(); zigbeeHerdsman.isStopping.mockImplementationOnce(() => true); - await zigbeeHerdsman.events.permitJoinChanged({permitted: false, time: 10}); + await zigbeeHerdsman.events.permitJoinChanged({permitted: false, timeout: 10}); await flushPromises(); expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), {retain: true, qos: 0}, expect.any(Function)); }); @@ -2706,7 +2706,7 @@ describe('Bridge', () => { expect(controller.state[device.ieeeAddr]).toBeUndefined(); expect(device.removeFromNetwork).toHaveBeenCalledTimes(1); expect(device.removeFromDatabase).not.toHaveBeenCalled(); - expect(settings.getDevice('bulb')).toBeNull(); + expect(settings.getDevice('bulb')).toBeUndefined(); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', '', {retain: true, qos: 0}, expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith( @@ -2728,7 +2728,7 @@ describe('Bridge', () => { await flushPromises(); expect(device.removeFromNetwork).toHaveBeenCalledTimes(1); expect(device.removeFromDatabase).not.toHaveBeenCalled(); - expect(settings.getDevice('bulb')).toBeNull(); + expect(settings.getDevice('bulb')).toBeUndefined(); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/response/device/remove', @@ -2745,7 +2745,7 @@ describe('Bridge', () => { await flushPromises(); expect(device.removeFromDatabase).toHaveBeenCalledTimes(1); expect(device.removeFromNetwork).not.toHaveBeenCalled(); - expect(settings.getDevice('bulb')).toBeNull(); + expect(settings.getDevice('bulb')).toBeUndefined(); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/response/device/remove', @@ -2761,7 +2761,7 @@ describe('Bridge', () => { MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', stringify({id: 'bulb', block: true, force: true})); await flushPromises(); expect(device.removeFromDatabase).toHaveBeenCalledTimes(1); - expect(settings.getDevice('bulb')).toBeNull(); + expect(settings.getDevice('bulb')).toBeUndefined(); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/response/device/remove', @@ -2778,7 +2778,7 @@ describe('Bridge', () => { MQTT.events.message('zigbee2mqtt/bridge/request/group/remove', 'group_1'); await flushPromises(); expect(group.removeFromNetwork).toHaveBeenCalledTimes(1); - expect(settings.getGroup('group_1')).toBeNull(); + expect(settings.getGroup('group_1')).toBeUndefined(); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/response/group/remove', @@ -2794,7 +2794,7 @@ describe('Bridge', () => { MQTT.events.message('zigbee2mqtt/bridge/request/group/remove', stringify({id: 'group_1', force: true})); await flushPromises(); expect(group.removeFromDatabase).toHaveBeenCalledTimes(1); - expect(settings.getGroup('group_1')).toBeNull(); + expect(settings.getGroup('group_1')).toBeUndefined(); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/response/group/remove', @@ -2858,7 +2858,7 @@ describe('Bridge', () => { MQTT.publish.mockClear(); MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb', to: 'bulb_new_name'})); await flushPromises(); - expect(settings.getDevice('bulb')).toBeNull(); + expect(settings.getDevice('bulb')).toBeUndefined(); expect(settings.getDevice('bulb_new_name')).toStrictEqual({ ID: '0x000b57fffec6a5b2', friendly_name: 'bulb_new_name', @@ -2892,7 +2892,7 @@ describe('Bridge', () => { MQTT.publish.mockClear(); MQTT.events.message('zigbee2mqtt/bridge/request/group/rename', stringify({from: 'group_1', to: 'group_new_name'})); await flushPromises(); - expect(settings.getGroup('group_1')).toBeNull(); + expect(settings.getGroup('group_1')).toBeUndefined(); expect(settings.getGroup('group_new_name')).toStrictEqual({ID: 1, devices: [], friendly_name: 'group_new_name', retain: false}); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); expect(MQTT.publish).toHaveBeenCalledWith( @@ -2932,7 +2932,7 @@ describe('Bridge', () => { await zigbeeHerdsman.events.deviceJoined({device: zigbeeHerdsman.devices.bulb}); MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({last: true, to: 'bulb_new_name'})); await flushPromises(); - expect(settings.getDevice('bulb')).toBeNull(); + expect(settings.getDevice('bulb')).toBeUndefined(); expect(settings.getDevice('bulb_new_name')).toStrictEqual({ ID: '0x000b57fffec6a5b2', friendly_name: 'bulb_new_name', diff --git a/test/controller.test.js b/test/controller.test.js index c53d1412b..a77196abe 100644 --- a/test/controller.test.js +++ b/test/controller.test.js @@ -79,7 +79,7 @@ describe('Controller', () => { databaseBackupPath: path.join(data.mockDir, 'database.db.backup'), backupPath: path.join(data.mockDir, 'coordinator_backup.json'), acceptJoiningDeviceHandler: expect.any(Function), - adapter: {concurrent: null, delay: null, disableLED: false, transmitPower: 14}, + adapter: {concurrent: undefined, delay: undefined, disableLED: false, transmitPower: 14}, serialPort: {baudRate: undefined, rtscts: undefined, path: '/dev/dummy'}, }); expect(zigbeeHerdsman.start).toHaveBeenCalledTimes(1); @@ -382,7 +382,7 @@ describe('Controller', () => { it('Should add entities which are missing from configuration but are in database to configuration', async () => { await controller.start(); const device = zigbeeHerdsman.devices.notInSettings; - expect(settings.getDevice(device.ieeeAddr)).not.toBeNull(); + expect(settings.getDevice(device.ieeeAddr)).not.toBeUndefined(); }); it('On zigbee deviceJoined', async () => { diff --git a/test/data.test.js b/test/data.test.js index ca7f958ca..34b80302f 100644 --- a/test/data.test.js +++ b/test/data.test.js @@ -15,13 +15,13 @@ describe('Data', () => { it('Should return correct path when ZIGBEE2MQTT_DATA set', () => { const expected = tmp.dirSync().name; process.env.ZIGBEE2MQTT_DATA = expected; - data.testingOnlyReload(); + data._testReload(); const actual = data.getPath(); expect(actual).toBe(expected); expect(data.joinPath('test')).toStrictEqual(path.join(expected, 'test')); expect(data.joinPath('/test')).toStrictEqual(path.resolve(expected, '/test')); delete process.env.ZIGBEE2MQTT_DATA; - data.testingOnlyReload(); + data._testReload(); }); }); }); diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index 493ebe471..c30e7bb2e 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -462,7 +462,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).not.toHaveBeenCalledWith(topic1, expect.anything(), expect.any(Object), expect.any(Function)); // Device automation should not be cleared - expect(MQTT.publish).not.toHaveBeenCalledWith(topic2, null, expect.any(Object), expect.any(Function)); + expect(MQTT.publish).not.toHaveBeenCalledWith(topic2, '', expect.any(Object), expect.any(Function)); expect(logger.debug).toHaveBeenCalledWith(`Skipping discovery of 'sensor/0x0017880104e45522/humidity/config', already discovered`); }); @@ -1413,31 +1413,31 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/sensor/0x0017880104e45522/temperature/config', - null, + '', {retain: true, qos: 1}, expect.any(Function), ); expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/sensor/0x0017880104e45522/humidity/config', - null, + '', {retain: true, qos: 1}, expect.any(Function), ); expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/sensor/0x0017880104e45522/pressure/config', - null, + '', {retain: true, qos: 1}, expect.any(Function), ); expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/sensor/0x0017880104e45522/battery/config', - null, + '', {retain: true, qos: 1}, expect.any(Function), ); expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/sensor/0x0017880104e45522/linkquality/config', - null, + '', {retain: true, qos: 1}, expect.any(Function), ); @@ -1450,7 +1450,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/light/1221051039810110150109113116116_9/light/config', - null, + '', {retain: true, qos: 1}, expect.any(Function), ); @@ -1502,7 +1502,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/sensor/0x0017880104e45522/temperature/config', - null, + '', {retain: true, qos: 1}, expect.any(Function), ); @@ -1587,7 +1587,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/light/1221051039810110150109113116116_9/light/config', - null, + '', {retain: true, qos: 1}, expect.any(Function), ); @@ -1603,7 +1603,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).not.toHaveBeenCalledWith( 'homeassistant/sensor/0x0017880104e45522/temperature/config', - null, + '', {retain: true, qos: 1}, expect.any(Function), ); @@ -1995,7 +1995,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/light/1221051039810110150109113116116_91231/light/config', - null, + '', {qos: 1, retain: true}, expect.any(Function), ); @@ -2014,7 +2014,7 @@ describe('HomeAssistant extension', () => { await MQTT.events.message('homeassistant/light/9/light/config', stringify({availability: [{topic: 'zigbee2mqtt/bridge/state'}]})); await flushPromises(); expect(MQTT.publish).toHaveBeenCalledTimes(1); - expect(MQTT.publish).toHaveBeenCalledWith('homeassistant/light/9/light/config', null, {qos: 1, retain: true}, expect.any(Function)); + expect(MQTT.publish).toHaveBeenCalledWith('homeassistant/light/9/light/config', '', {qos: 1, retain: true}, expect.any(Function)); // Existing group, non existing config -> clear MQTT.publish.mockClear(); @@ -2026,7 +2026,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/light/1221051039810110150109113116116_9/switch/config', - null, + '', {qos: 1, retain: true}, expect.any(Function), ); @@ -2036,12 +2036,7 @@ describe('HomeAssistant extension', () => { await MQTT.events.message('homeassistant/sensor/0x123/temperature/config', stringify({availability: [{topic: 'zigbee2mqtt/bridge/state'}]})); await flushPromises(); expect(MQTT.publish).toHaveBeenCalledTimes(1); - expect(MQTT.publish).toHaveBeenCalledWith( - 'homeassistant/sensor/0x123/temperature/config', - null, - {qos: 1, retain: true}, - expect.any(Function), - ); + expect(MQTT.publish).toHaveBeenCalledWith('homeassistant/sensor/0x123/temperature/config', '', {qos: 1, retain: true}, expect.any(Function)); // Existing device -> don't clear MQTT.publish.mockClear(); @@ -2071,7 +2066,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/sensor/0x000b57fffec6a5b2/update_available/config', - null, + '', {qos: 1, retain: true}, expect.any(Function), ); @@ -2112,7 +2107,7 @@ describe('HomeAssistant extension', () => { await flushPromises(); expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/sensor/0x000b57fffec6a5b2/update_available/config', - null, + '', {qos: 1, retain: true}, expect.any(Function), ); @@ -2124,7 +2119,7 @@ describe('HomeAssistant extension', () => { await flushPromises(); expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/device_automation/0x000b57fffec6a5b2/action_button_3_single/config', - null, + '', {qos: 1, retain: true}, expect.any(Function), ); @@ -2406,7 +2401,7 @@ describe('HomeAssistant extension', () => { // Discovery messages for scenes have been purged. expect(MQTT.publish).toHaveBeenCalledWith( `homeassistant/scene/0x000b57fffec6a5b4/scene_1/config`, - null, + '', {retain: true, qos: 1}, expect.any(Function), ); @@ -2450,7 +2445,7 @@ describe('HomeAssistant extension', () => { // Discovery messages for scenes have been purged. expect(MQTT.publish).toHaveBeenCalledWith( `homeassistant/scene/1221051039810110150109113116116_9/scene_4/config`, - null, + '', {retain: true, qos: 1}, expect.any(Function), ); @@ -2514,7 +2509,7 @@ describe('HomeAssistant extension', () => { }); await flushPromises(); - expect(MQTT.publish).not.toHaveBeenCalledWith(topic, null, {retain: true, qos: 1}, expect.any(Function)); + expect(MQTT.publish).not.toHaveBeenCalledWith(topic, '', {retain: true, qos: 1}, expect.any(Function)); }); it('Should discover bridge entities', async () => { @@ -2737,7 +2732,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledWith( 'homeassistant/light/0xf4ce368a38be56a1/light_l2/config', - null, + '', {retain: true, qos: 1}, expect.any(Function), ); diff --git a/test/legacy/bridgeLegacy.test.js b/test/legacy/bridgeLegacy.test.js index 342894d02..5ee9c5112 100644 --- a/test/legacy/bridgeLegacy.test.js +++ b/test/legacy/bridgeLegacy.test.js @@ -253,7 +253,7 @@ describe('Bridge legacy', () => { expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: false}); MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'bulb_color', new: 'bulb_color2'})); await flushPromises(); - expect(settings.getDevice('bulb_color')).toStrictEqual(null); + expect(settings.getDevice('bulb_color')).toBeUndefined(); expect(settings.getDevice('bulb_color2')).toStrictEqual(bulb_color2); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/log', @@ -374,7 +374,7 @@ describe('Bridge legacy', () => { const group = zigbeeHerdsman.groups.group_1; MQTT.events.message('zigbee2mqtt/bridge/config/remove_group', 'group_1'); await flushPromises(); - expect(settings.getGroup('to_be_removed')).toStrictEqual(null); + expect(settings.getGroup('to_be_removed')).toBeUndefined(); expect(group.removeFromNetwork).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/log', @@ -388,7 +388,7 @@ describe('Bridge legacy', () => { const group = zigbeeHerdsman.groups.group_1; MQTT.events.message('zigbee2mqtt/bridge/config/force_remove_group', 'group_1'); await flushPromises(); - expect(settings.getGroup('to_be_removed')).toStrictEqual(null); + expect(settings.getGroup('to_be_removed')).toBeUndefined(); expect(group.removeFromDatabase).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/log', @@ -424,7 +424,7 @@ describe('Bridge legacy', () => { await flushPromises(); expect(device.removeFromNetwork).toHaveBeenCalledTimes(1); expect(controller.state[device.ieeeAddr]).toBeUndefined(); - expect(settings.getDevice('bulb_color')).toBeNull(); + expect(settings.getDevice('bulb_color')).toBeUndefined(); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/log', stringify({type: 'device_removed', message: 'bulb_color'}), @@ -446,7 +446,7 @@ describe('Bridge legacy', () => { await flushPromises(); expect(device.removeFromDatabase).toHaveBeenCalledTimes(1); expect(controller.state[device.ieeeAddr]).toBeUndefined(); - expect(settings.getDevice('bulb_color')).toBeNull(); + expect(settings.getDevice('bulb_color')).toBeUndefined(); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/log', stringify({type: 'device_force_removed', message: 'bulb_color'}), @@ -467,7 +467,7 @@ describe('Bridge legacy', () => { await flushPromises(); expect(device.removeFromNetwork).toHaveBeenCalledTimes(1); expect(controller.state[device.ieeeAddr]).toBeUndefined(); - expect(settings.getDevice('bulb_color')).toBeNull(); + expect(settings.getDevice('bulb_color')).toBeUndefined(); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/log', stringify({type: 'device_banned', message: 'bulb_color'}), diff --git a/test/networkMap.test.js b/test/networkMap.test.js index ca40b4b29..fb192f8d6 100644 --- a/test/networkMap.test.js +++ b/test/networkMap.test.js @@ -187,7 +187,7 @@ describe('Networkmap', () => { ], nodes: [ { - definition: null, + // definition: null, failed: [], friendlyName: 'Coordinator', ieeeAddr: '0x00124b00120144ae', @@ -533,7 +533,7 @@ describe('Networkmap', () => { ], nodes: [ { - definition: null, + // definition: null, failed: [], friendlyName: 'Coordinator', ieeeAddr: '0x00124b00120144ae', @@ -717,7 +717,7 @@ describe('Networkmap', () => { ], nodes: [ { - definition: null, + // definition: null, failed: [], friendlyName: 'Coordinator', ieeeAddr: '0x00124b00120144ae', @@ -873,7 +873,7 @@ describe('Networkmap', () => { ], nodes: [ { - definition: null, + // definition: null, failed: [], friendlyName: 'Coordinator', ieeeAddr: '0x00124b00120144ae', diff --git a/test/publish.test.js b/test/publish.test.js index c15ef2f7a..3d0b129bc 100644 --- a/test/publish.test.js +++ b/test/publish.test.js @@ -594,6 +594,15 @@ describe('Publish', () => { expect(endpoint2.read).toHaveBeenCalledTimes(0); }); + it('Should log error when device has no definition', async () => { + const device = zigbeeHerdsman.devices.interviewing; + logger.error.mockClear(); + await MQTT.events.message(`zigbee2mqtt/${device.ieeeAddr}/set`, stringify({state: 'OFF'})); + await flushPromises(); + console.log(logger.error.mock.calls); + expect(logger.error).toHaveBeenCalledWith(`Cannot publish to unsupported device 'button_double_key_interviewing'`); + }); + it('Should log error when device has no such endpoint (via property)', async () => { const device = zigbeeHerdsman.devices.QBKG03LM; const endpoint2 = device.getEndpoint(2); diff --git a/test/settings.test.js b/test/settings.test.js index 7f4b2273d..a0c3c14f3 100644 --- a/test/settings.test.js +++ b/test/settings.test.js @@ -795,7 +795,7 @@ describe('Settings', () => { it('Should throw error when yaml file does not exist', () => { settings.testing.clear(); - expect(settings.validate()[0].startsWith(`ENOENT: no such file or directory, open `)).toBeTruthy(); + expect(settings.validate()[0]).toContain(`ENOENT: no such file or directory, open `); }); it('Configuration shouldnt be valid when invalid QOS value is used', async () => { diff --git a/test/stub/zigbeeHerdsman.js b/test/stub/zigbeeHerdsman.js index f8f54f931..c433e9502 100644 --- a/test/stub/zigbeeHerdsman.js +++ b/test/stub/zigbeeHerdsman.js @@ -861,7 +861,7 @@ const mock = { for (const key in devices) { const device = devices[key]; - if ((returnDevices.length === 0 || returnDevices.includes(device.ieeeAddr)) && (!predicate || predicate(device))) { + if ((returnDevices.length === 0 || returnDevices.includes(device.ieeeAddr)) && !device.isDeleted && (!predicate || predicate(device))) { yield device; } } diff --git a/tsconfig.json b/tsconfig.json index bba5a30da..f7dc5307f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "esModuleInterop": true, "target": "ES2022", "lib": ["ES2022"], + "strict": true, "noImplicitAny": true, "noImplicitThis": true, "moduleResolution": "node",