diff --git a/src/index.ts b/src/index.ts index c400cc2a4d6e8..0dba75eda3f4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,31 +63,60 @@ export const getConfigureKey = configureKey.getConfigureKey; const lookup = new Map(); export const definitions: Definition[] = []; -function arrayEquals(as: T[], bs: T[]) { - if (as.length !== bs.length) return false; - for (const a of as) if (!bs.includes(a)) return false; +// expected to be at the beginning of `definitions` array +let externalDefinitionsCount = 0; + +function arrayEquals(as: T[], bs: T[]): boolean { + if (as.length !== bs.length) { + return false; + } + + for (const a of as) { + if (!bs.includes(a)) { + return false; + } + } + return true; } -function addToLookup(zigbeeModel: string, definition: Definition) { - zigbeeModel = zigbeeModel ? zigbeeModel.toLowerCase() : null; - if (!lookup.has(zigbeeModel)) { - lookup.set(zigbeeModel, []); +function addToLookup(zigbeeModel: string | undefined, definition: Definition): void { + const lookupModel = zigbeeModel ? zigbeeModel.toLowerCase() : 'null'; + + if (!lookup.has(lookupModel)) { + lookup.set(lookupModel, []); } - if (!lookup.get(zigbeeModel).includes(definition)) { - lookup.get(zigbeeModel).splice(0, 0, definition); + // key created above as needed + if (!lookup.get(lookupModel)!.includes(definition)) { + lookup.get(lookupModel)!.splice(0, 0, definition); } } -function getFromLookup(zigbeeModel: string) { - zigbeeModel = zigbeeModel ? zigbeeModel.toLowerCase() : null; - if (lookup.has(zigbeeModel)) { - return lookup.get(zigbeeModel); +function removeFromLookup(zigbeeModel: string | undefined, definition: Definition): void { + const lookupModel = zigbeeModel ? zigbeeModel.toLowerCase() : 'null'; + + if (lookup.has(lookupModel)) { + const i = lookup.get(lookupModel)!.indexOf(definition); + + if (i > -1) { + lookup.get(lookupModel)!.splice(i, 1); + } + + if (lookup.get(lookupModel)!.length === 0) { + lookup.delete(lookupModel); + } } +} + +function getFromLookup(zigbeeModel: string | undefined): Definition[] | undefined { + const lookupModel = zigbeeModel ? zigbeeModel.toLowerCase() : 'null'; - zigbeeModel = zigbeeModel ? zigbeeModel.replace(/\0(.|\n)*$/g, '').trim() : null; - return lookup.get(zigbeeModel); + if (lookup.has(lookupModel)) { + return lookup.get(lookupModel); + } + + return lookup.get(lookupModel.replace(/\0(.|\n)*$/g, '').trim()); } const converterRequiredFields = { @@ -98,17 +127,15 @@ const converterRequiredFields = { toZigbee: 'Array', }; -function validateDefinition(definition: Definition) { +function validateDefinition(definition: Definition): asserts definition is Definition { for (const [field, expectedType] of Object.entries(converterRequiredFields)) { - // @ts-expect-error ignore - assert.notStrictEqual(null, definition[field], `Converter field ${field} is null`); - // @ts-expect-error ignore - assert.notStrictEqual(undefined, definition[field], `Converter field ${field} is undefined`); - // @ts-expect-error ignore - const msg = `Converter field ${field} expected type doenst match to ${definition[field]}`; - // @ts-expect-error ignore - assert.strictEqual(definition[field].constructor.name, expectedType, msg); + const val = definition[field as keyof Definition]; + + assert(val !== null, `Converter field ${field} is null`); + assert(val !== undefined, `Converter field ${field} is undefined`); + assert(val.constructor.name === expectedType, `Converter field ${field} expected type doenst match to ${val}`); } + assert.ok(Array.isArray(definition.exposes) || typeof definition.exposes === 'function', 'Exposes incorrect'); } @@ -207,6 +234,7 @@ function processExtensions(definition: DefinitionWithExtend): Definition { if (actionExposes.length > 0) { const actions: string[] = []; + for (const expose of actionExposes) { if (expose instanceof EnumClass) { for (const action of expose.values) { @@ -214,11 +242,13 @@ function processExtensions(definition: DefinitionWithExtend): Definition { } } } + const uniqueActions = actions.filter((value, index, array) => array.indexOf(value) === index); + allExposes.push(exposesLib.presets.action(uniqueActions)); } - let configure: Configure = null; + let configure: Configure | undefined; if (configures.length !== 0) { configure = async (device, coordinatorEndpoint, configureDefinition) => { @@ -228,7 +258,7 @@ function processExtensions(definition: DefinitionWithExtend): Definition { }; } - let onEvent: OnEvent = null; + let onEvent: OnEvent | undefined; if (onEvents.length !== 0) { onEvent = async (type, data, device, settings, state) => { @@ -286,26 +316,37 @@ function prepareDefinition(definition: DefinitionWithExtend): Definition { definition.exposes = definition.exposes.concat([exposesLib.presets.linkquality()]); } - validateDefinition(definition); + if (definition.externalConverterName) { + validateDefinition(definition); + } // Add all the options - if (!definition.options) definition.options = []; + if (!definition.options) { + definition.options = []; + } + const optionKeys = definition.options.map((o) => o.name); // Add calibration/precision options based on expose - for (const expose of Array.isArray(definition.exposes) ? definition.exposes : definition.exposes(null, null)) { + for (const expose of Array.isArray(definition.exposes) ? definition.exposes : definition.exposes(undefined, undefined)) { if ( !optionKeys.includes(expose.name) && utils.isNumericExpose(expose) && expose.name in utils.calibrateAndPrecisionRoundOptionsDefaultPrecision ) { // Battery voltage is not calibratable - if (expose.name === 'voltage' && expose.unit === 'mV') continue; + if (expose.name === 'voltage' && expose.unit === 'mV') { + continue; + } + const type = utils.calibrateAndPrecisionRoundOptionsIsPercentual(expose.name) ? 'percentual' : 'absolute'; + definition.options.push(exposesLib.options.calibration(expose.name, type)); + if (utils.calibrateAndPrecisionRoundOptionsDefaultPrecision[expose.name] !== 0) { definition.options.push(exposesLib.options.precision(expose.name)); } + optionKeys.push(expose.name); } } @@ -313,6 +354,7 @@ function prepareDefinition(definition: DefinitionWithExtend): Definition { for (const converter of [...definition.toZigbee, ...definition.fromZigbee]) { if (converter.options) { const options = typeof converter.options === 'function' ? converter.options(definition) : converter.options; + for (const option of options) { if (!optionKeys.includes(option.name)) { definition.options.push(option); @@ -325,33 +367,65 @@ function prepareDefinition(definition: DefinitionWithExtend): Definition { return definition; } -export function postProcessConvertedFromZigbeeMessage(definition: Definition, payload: KeyValue, options: KeyValue) { +export function postProcessConvertedFromZigbeeMessage(definition: Definition, payload: KeyValue, options: KeyValue): void { // Apply calibration/precision options for (const [key, value] of Object.entries(payload)) { - const definitionExposes = Array.isArray(definition.exposes) ? definition.exposes : definition.exposes(null, null); + const definitionExposes = Array.isArray(definition.exposes) ? definition.exposes : definition.exposes(undefined, undefined); const expose = definitionExposes.find((e) => e.property === key); - if (expose?.name in utils.calibrateAndPrecisionRoundOptionsDefaultPrecision && value !== '' && utils.isNumber(value)) { + + if (expose?.name && expose.name in utils.calibrateAndPrecisionRoundOptionsDefaultPrecision && value !== '' && utils.isNumber(value)) { try { payload[key] = utils.calibrateAndPrecisionRoundOptions(value, options, expose.name); } catch (error) { - logger.logger.error(`Failed to apply calibration to '${expose.name}': ${error.message}`, NS); + logger.logger.error(`Failed to apply calibration to '${expose.name}': ${(error as Error).message}`, NS); + } + } + } +} + +export function removeExternalDefinitions(converterName?: string): void { + for (let i = 0; i < externalDefinitionsCount; i++) { + const definition = definitions[i]; + + if (converterName && definition.externalConverterName !== converterName) { + continue; + } + + if ('zigbeeModel' in definition && definition.zigbeeModel) { + for (const zigbeeModel of definition.zigbeeModel) { + removeFromLookup(zigbeeModel, definition); + } + } + + if ('fingerprint' in definition && definition.fingerprint) { + for (const fingerprint of definition.fingerprint) { + removeFromLookup(fingerprint.modelID, definition); } } + + definitions.splice(i, 1); + + externalDefinitionsCount--; + i--; } } -export function addDefinition(definition: DefinitionWithExtend) { +export function addDefinition(definition: DefinitionWithExtend): void { definition = prepareDefinition(definition); definitions.splice(0, 0, definition); - if ('fingerprint' in definition) { + if (definition.externalConverterName) { + externalDefinitionsCount++; + } + + if ('fingerprint' in definition && definition.fingerprint) { for (const fingerprint of definition.fingerprint) { addToLookup(fingerprint.modelID, definition); } } - if ('zigbeeModel' in definition) { + if ('zigbeeModel' in definition && definition.zigbeeModel) { for (const zigbeeModel of definition.zigbeeModel) { addToLookup(zigbeeModel, definition); } @@ -362,10 +436,12 @@ for (const definition of allDefinitions) { addDefinition(definition as DefinitionWithExtend); } -export async function findByDevice(device: Zh.Device, generateForUnknown: boolean = false) { +export async function findByDevice(device: Zh.Device, generateForUnknown: boolean = false): Promise { let definition = await findDefinition(device, generateForUnknown); + if (definition && definition.whiteLabel) { const match = definition.whiteLabel.find((w) => 'fingerprint' in w && w.fingerprint.find((f) => isFingerprintMatch(f, device))); + if (match) { definition = { ...definition, @@ -375,19 +451,20 @@ export async function findByDevice(device: Zh.Device, generateForUnknown: boolea }; } } + return definition; } -export async function findDefinition(device: Zh.Device, generateForUnknown: boolean = false): Promise { +export async function findDefinition(device: Zh.Device, generateForUnknown: boolean = false): Promise { if (!device) { - return null; + return undefined; } const candidates = getFromLookup(device.modelID); if (!candidates) { if (!generateForUnknown || device.type === 'Coordinator') { - return null; + return undefined; } // Do not add this definition to cache, @@ -397,13 +474,16 @@ export async function findDefinition(device: Zh.Device, generateForUnknown: bool return candidates[0]; } else { // First try to match based on fingerprint, return the first matching one. - const fingerprintMatch: {priority: number; definition: Definition} = {priority: null, definition: null}; + const fingerprintMatch: {priority?: number; definition?: Definition} = {priority: undefined, definition: undefined}; for (const candidate of candidates) { if (candidate.fingerprint) { for (const fingerprint of candidate.fingerprint) { const priority = fingerprint.priority ?? 0; - if (isFingerprintMatch(fingerprint, device) && (!fingerprintMatch.definition || priority > fingerprintMatch.priority)) { + if ( + isFingerprintMatch(fingerprint, device) && + (fingerprintMatch.priority === undefined || priority > fingerprintMatch.priority) + ) { fingerprintMatch.definition = candidate; fingerprintMatch.priority = priority; } @@ -417,20 +497,20 @@ export async function findDefinition(device: Zh.Device, generateForUnknown: bool // Match based on fingerprint failed, return first matching definition based on zigbeeModel for (const candidate of candidates) { - if (candidate.zigbeeModel && candidate.zigbeeModel.includes(device.modelID)) { + if (candidate.zigbeeModel && device.modelID && candidate.zigbeeModel.includes(device.modelID)) { return candidate; } } } - return null; + return undefined; } export async function generateExternalDefinitionSource(device: Zh.Device): Promise { return (await generateDefinition(device)).externalDefinitionSource; } -function isFingerprintMatch(fingerprint: Fingerprint, device: Zh.Device) { +function isFingerprintMatch(fingerprint: Fingerprint, device: Zh.Device): boolean { let match = (!fingerprint.applicationVersion || device.applicationVersion === fingerprint.applicationVersion) && (!fingerprint.manufacturerID || device.manufacturerID === fingerprint.manufacturerID) && @@ -443,7 +523,7 @@ function isFingerprintMatch(fingerprint: Fingerprint, device: Zh.Device) { (!fingerprint.softwareBuildID || device.softwareBuildID === fingerprint.softwareBuildID) && (!fingerprint.stackVersion || device.stackVersion === fingerprint.stackVersion) && (!fingerprint.zclVersion || device.zclVersion === fingerprint.zclVersion) && - (!fingerprint.ieeeAddr || device.ieeeAddr.match(fingerprint.ieeeAddr)) && + (!fingerprint.ieeeAddr || device.ieeeAddr.match(fingerprint.ieeeAddr) !== null) && (!fingerprint.endpoints || arrayEquals( device.endpoints.map((e) => e.ID), @@ -452,34 +532,39 @@ function isFingerprintMatch(fingerprint: Fingerprint, device: Zh.Device) { if (match && fingerprint.endpoints) { for (const fingerprintEndpoint of fingerprint.endpoints) { - const deviceEndpoint = device.getEndpoint(fingerprintEndpoint.ID); + const deviceEndpoint = fingerprintEndpoint.ID !== undefined ? device.getEndpoint(fingerprintEndpoint.ID) : undefined; match = match && - (!fingerprintEndpoint.deviceID || deviceEndpoint.deviceID === fingerprintEndpoint.deviceID) && - (!fingerprintEndpoint.profileID || deviceEndpoint.profileID === fingerprintEndpoint.profileID) && - (!fingerprintEndpoint.inputClusters || arrayEquals(deviceEndpoint.inputClusters, fingerprintEndpoint.inputClusters)) && - (!fingerprintEndpoint.outputClusters || arrayEquals(deviceEndpoint.outputClusters, fingerprintEndpoint.outputClusters)); + (!fingerprintEndpoint.deviceID || (deviceEndpoint !== undefined && deviceEndpoint.deviceID === fingerprintEndpoint.deviceID)) && + (!fingerprintEndpoint.profileID || (deviceEndpoint !== undefined && deviceEndpoint.profileID === fingerprintEndpoint.profileID)) && + (!fingerprintEndpoint.inputClusters || + (deviceEndpoint !== undefined && arrayEquals(deviceEndpoint.inputClusters, fingerprintEndpoint.inputClusters))) && + (!fingerprintEndpoint.outputClusters || + (deviceEndpoint !== undefined && arrayEquals(deviceEndpoint.outputClusters, fingerprintEndpoint.outputClusters))); } } return match; } -export function findByModel(model: string) { +export function findByModel(model: string): Definition | undefined { /* Search device description by definition model name. Useful when redefining, expanding device descriptions in external converters. */ model = model.toLowerCase(); + return definitions.find((definition) => { - const whiteLabelMatch = definition.whiteLabel && definition.whiteLabel.find((dd) => dd.model.toLowerCase() === model); - return definition.model.toLowerCase() == model || whiteLabelMatch; + return ( + definition.model.toLowerCase() == model || + (definition.whiteLabel && definition.whiteLabel.find((dwl) => dwl.model.toLowerCase() === model)) + ); }); } // Can be used to handle events for devices which are not fully paired yet (no modelID). // Example usecase: https://github.com/Koenkk/zigbee2mqtt/issues/2399#issuecomment-570583325 -export async function onEvent(type: OnEventType, data: OnEventData, device: Zh.Device) { +export async function onEvent(type: OnEventType, data: OnEventData, device: Zh.Device): Promise { // support Legrand security protocol // when pairing, a powered device will send a read frame to every device on the network // it expects at least one answer. The payload contains the number of seconds @@ -490,11 +575,14 @@ export async function onEvent(type: OnEventType, data: OnEventData, device: Zh.D if (frame.isCluster('genBasic') && frame.payload.find((i: {attrId: number}) => i.attrId === 61440)) { const options = {manufacturerCode: Zcl.ManufacturerCode.LEGRAND_GROUP, disableDefaultResponse: true}; const payload = {0xf000: {value: 23, type: 35}}; + endpoint.readResponse('genBasic', frame.header.transactionSequenceNumber, payload, options).catch((e) => { logger.logger.warning(`Legrand security read response failed: ${e}`, NS); }); + return true; } + return false; }; } @@ -507,11 +595,14 @@ export async function onEvent(type: OnEventType, data: OnEventData, device: Zh.D const oneJanuary2000 = new Date('January 01, 2000 00:00:00 UTC+00:00').getTime(); const secondsUTC = Math.round((new Date().getTime() - oneJanuary2000) / 1000); const secondsLocal = secondsUTC - new Date().getTimezoneOffset() * 60; + endpoint.readResponse('genTime', frame.header.transactionSequenceNumber, {time: secondsLocal}).catch((e) => { logger.logger.warning(`ZNCWWSQ01LM custom time response failed: ${e}`, NS); }); + return true; } + return false; }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index e35a2a2c672be..9e72984ddccef 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -247,6 +247,8 @@ export type DefinitionExposesFunction = (device: Zh.Device | undefined, options: export type DefinitionExposes = Expose[] | DefinitionExposesFunction; +type DefinitionMatcher = {zigbeeModel: string[]; fingerprint?: Fingerprint[]} | {zigbeeModel?: string[]; fingerprint: Fingerprint[]}; + type DefinitionBase = { model: string; vendor: string; @@ -258,29 +260,21 @@ type DefinitionBase = { meta?: DefinitionMeta; onEvent?: OnEvent; ota?: true | Ota.ExtraMetas; - generated?: boolean; -} & ({zigbeeModel: string[]; fingerprint?: Fingerprint[]} | {zigbeeModel?: string[]; fingerprint: Fingerprint[]}); + generated?: true; + externalConverterName?: string; +}; -export type Definition = DefinitionBase & { +type DefinitionFeatures = { fromZigbee: Fz.Converter[]; toZigbee: Tz.Converter[]; exposes: DefinitionExposes; }; -export type DefinitionWithExtend = DefinitionBase & - ( - | { - extend: ModernExtend[]; - fromZigbee?: Fz.Converter[]; - toZigbee?: Tz.Converter[]; - exposes?: DefinitionExposes; - } - | { - fromZigbee: Fz.Converter[]; - toZigbee: Tz.Converter[]; - exposes: DefinitionExposes; - } - ); +export type Definition = DefinitionMatcher & DefinitionBase & DefinitionFeatures; + +export type DefinitionWithExtend = DefinitionMatcher & + DefinitionBase & + (({extend: ModernExtend[]} & Partial) | DefinitionFeatures); export namespace Fz { export interface Message { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 29787eb7e3854..4d353c28f4c37 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -373,11 +373,11 @@ export function replaceToZigbeeConvertersInArray( return clone; } -export function filterObject(obj: KeyValue, keys: string[]) { - const result: KeyValue = {}; +export function filterObject(obj: T, keys: string[]): Partial { + const result: Partial = {}; for (const [key, value] of Object.entries(obj)) { if (keys.includes(key)) { - result[key] = value; + result[key as keyof T] = value; } } return result; diff --git a/test/index.test.js b/test/index.test.js index a4059f0500905..1a880c7b5fb28 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -30,7 +30,7 @@ describe('index.js', () => { expect(() => utils.toNumber('')).toThrowError('Value is not a number, got string ()'); }); - it('Find by device where modelID is null', async () => { + it('Find by device where modelID is undefined', async () => { const endpoints = [ {ID: 230, profileID: 49413, deviceID: 1, inputClusters: [], outputClusters: []}, {ID: 232, profileID: 49413, deviceID: 1, inputClusters: [], outputClusters: []}, @@ -47,7 +47,7 @@ describe('index.js', () => { expect(definition.model).toBe('XBee'); }); - it("Find by device shouldn't match when modelID is null and there is no fingerprint match", async () => { + it("Find by device shouldn't match when modelID is undefined and there is no fingerprint match", async () => { const endpoints = [{ID: 1, profileID: undefined, deviceID: undefined, inputClusters: [], outputClusters: []}]; const device = { type: undefined, @@ -58,7 +58,7 @@ describe('index.js', () => { }; const definition = await index.findByDevice(device); - expect(definition).toBeNull(); + expect(definition).toBeUndefined(); }); it('Find by device should generate for unknown', async () => { @@ -250,9 +250,9 @@ describe('index.js', () => { }); }); - it('Verify addDefinition', () => { + it('Verify addDefinition for external converters', () => { const mockZigbeeModel = 'my-mock-device'; - let mockDevice = {toZigbee: []}; + let mockDevice = {toZigbee: [], externalConverterName: 'mock-model.js'}; const undefinedDevice = index.findByModel('mock-model'); expect(undefinedDevice).toBeUndefined(); const beforeAdditionDeviceCount = index.definitions.length; @@ -291,6 +291,33 @@ describe('index.js', () => { expect((await index.findByDevice(device)).vendor).toBe('other-vendor'); }); + it('Handles external converter definition addition/removal', async () => { + const converterDef = { + mock: true, + zigbeeModel: ['external_converter_device'], + vendor: 'external', + model: 'external_converter_device', + description: 'external', + fromZigbee: [], + toZigbee: [], + exposes: [], + externalConverterName: 'foo.js', + }; + + const count = index.definitions.length; + + index.addDefinition(converterDef); + + expect(index.definitions.length).toStrictEqual(count + 1); + expect(index.definitions[0].zigbeeModel[0]).toStrictEqual(converterDef.zigbeeModel[0]); + expect(index.findByModel('external_converter_device')).toBeDefined(); + + index.removeExternalDefinitions(converterDef.externalConverterName); + + expect(index.definitions.length).toStrictEqual(count); + expect(index.findByModel('external_converter_device')).toBeUndefined(); + }); + it('Exposes light with endpoint', () => { const expected = { type: 'light',