Skip to content

Commit

Permalink
feat: Generate definition for unsupported devices (disabled for now) (#…
Browse files Browse the repository at this point in the history
…6692)

* feat: Generate definition for unknown devices

* add test

* fix definition processing

* Update definition generation to be dynamic

* Update generator

* Add tests

* update extenders

* update generation tests

* fix lint

* Update generateDefinition.ts

* Update index.ts

* small refactor

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
  • Loading branch information
ffenix113 and Koenkk authored Dec 22, 2023
1 parent 473b9c0 commit 3468c09
Show file tree
Hide file tree
Showing 8 changed files with 369 additions and 120 deletions.
32 changes: 26 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import assert from 'assert';
import * as ota from './lib/ota';
import allDefinitions from './devices';
import { Definition, Fingerprint, Zh, OnEventData, OnEventType, Configure, Expose, Tz, OtaUpdateAvailableResult } from './lib/types';
import {generateDefinition} from './lib/generateDefinition';

export {
Definition as Definition,
Expand Down Expand Up @@ -88,7 +89,7 @@ function validateDefinition(definition: Definition) {
assert.ok(Array.isArray(definition.exposes) || typeof definition.exposes === 'function', 'Exposes incorrect');
}

export function addDefinition(definition: Definition) {
function processExtensions(definition: Definition): Definition {
if ('extend' in definition) {
if (Array.isArray(definition.extend)) {
// Modern extend, merges properties, e.g. when both extend and definition has toZigbee, toZigbee will be combined
Expand Down Expand Up @@ -172,6 +173,12 @@ export function addDefinition(definition: Definition) {
}
}

return definition
}

function prepareDefinition(definition: Definition): Definition {
definition = processExtensions(definition);

definition.toZigbee.push(
toZigbee.scene_store, toZigbee.scene_recall, toZigbee.scene_add, toZigbee.scene_remove, toZigbee.scene_remove_all,
toZigbee.scene_rename, toZigbee.read, toZigbee.write,
Expand All @@ -182,7 +189,6 @@ export function addDefinition(definition: Definition) {
}

validateDefinition(definition);
definitions.splice(0, 0, definition);

if (!definition.options) definition.options = [];
const optionKeys = definition.options.map((o) => o.name);
Expand All @@ -198,6 +204,14 @@ export function addDefinition(definition: Definition) {
}
}

return definition
}

export function addDefinition(definition: Definition) {
definition = prepareDefinition(definition)

definitions.splice(0, 0, definition);

if ('fingerprint' in definition) {
for (const fingerprint of definition.fingerprint) {
addToLookup(fingerprint.modelID, definition);
Expand All @@ -215,8 +229,8 @@ for (const definition of allDefinitions) {
addDefinition(definition);
}

export function findByDevice(device: Zh.Device) {
let definition = findDefinition(device);
export function findByDevice(device: Zh.Device, generateForUnknown: boolean = false) {
let definition = 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) {
Expand All @@ -231,14 +245,20 @@ export function findByDevice(device: Zh.Device) {
return definition;
}

export function findDefinition(device: Zh.Device): Definition {
export function findDefinition(device: Zh.Device, generateForUnknown: boolean = false): Definition {
if (!device) {
return null;
}

const candidates = getFromLookup(device.modelID);
if (!candidates) {
return null;
if (!generateForUnknown || device.type === 'Coordinator') {
return null;
}

// Do not add this definition to cache,
// as device configuration might change.
return prepareDefinition(generateDefinition(device));
} else if (candidates.length === 1 && candidates[0].hasOwnProperty('zigbeeModel')) {
return candidates[0];
} else {
Expand Down
65 changes: 65 additions & 0 deletions src/lib/generateDefinition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {Cluster} from 'zigbee-herdsman/dist/zcl/tstype';
import {Definition, ModernExtend, Zh} from './types';
import * as e from './modernExtend';
import {Endpoint} from 'zigbee-herdsman/dist/controller/model';

export function generateDefinition(device: Zh.Device): Definition {
const deviceExtenders: ModernExtend[] = [];

device.endpoints.forEach((endpoint) => {
const addExtenders = (cluster: Cluster, knownExtenders: extendersObject) => {
const clusterName = cluster.name || cluster.ID.toString();
if (!knownExtenders.hasOwnProperty(clusterName)) {
return;
}

const extenderProviders = knownExtenders[clusterName];
const extenders = extenderProviders.map((extender: extenderProvider): ModernExtend => {
if (typeof extender !== 'function') {
return extender;
}
return extender(endpoint, cluster);
});

deviceExtenders.push(...(extenders));
};

endpoint.getInputClusters().forEach((cluster) => {
addExtenders(cluster, inputExtenders);
});
endpoint.getOutputClusters().forEach((cluster) => {
addExtenders(cluster, outputExtenders);
});
});

const definition: Definition = {
zigbeeModel: [device.modelID],
model: device.modelID ?? '',
vendor: device.manufacturerName ?? '',
description: 'Generated from device information',
extend: deviceExtenders,
generated: true,
};

return definition;
}

// This configurator type provides some flexibility in terms of how ModernExtend configuration can be obtained.
// I.e. if cluster has optional attributes - this type can be used
// to define function that will generate more feature-full extension.
type extenderConfigurator = (endpoint: Endpoint, cluster: Cluster) => ModernExtend
// extenderProvider defines a type that will produce a `ModernExtend`
// either directly, or by calling a function.
type extenderProvider = ModernExtend | extenderConfigurator
type extendersObject = {[name: string]: extenderProvider[]}

const inputExtenders: extendersObject = {
'msTemperatureMeasurement': [e.temperature()],
'msPressureMeasurement': [e.pressure()],
'msRelativeHumidity': [e.humidity()],
'genOnOff': [e.onOff({powerOnBehavior: false})],
};

const outputExtenders: extendersObject = {
'genIdentify': [e.identify()],
};
20 changes: 20 additions & 0 deletions src/lib/modernExtend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ export function setupConfigureForReporting(
return configure;
}

export function identify(): ModernExtend {
return {
toZigbee: [tz.identify],
isModernExtend: true,
};
}

export interface OnOffArgs {
powerOnBehavior?: boolean, ota?: DefinitionOta, skipDuplicateTransaction?: boolean, endpoints?: {[s: string]: number},
configureReporting?: boolean,
Expand Down Expand Up @@ -548,3 +555,16 @@ export function humidity(args?: Partial<NumericArgs>) {
});
}

export function pressure(args?: Partial<NumericArgs>): ModernExtend {
return numeric({
name: 'pressure',
cluster: 'msPressureMeasurement',
attribute: 'measuredValue',
reporting: {min: '10_SECONDS', max: '1_HOUR', change: 100},
description: 'The measured atmospheric pressure',
unit: 'hPa',
scale: 100,
readOnly: true,
...args,
});
}
1 change: 1 addition & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export type Definition = {
meta?: DefinitionMeta,
onEvent?: OnEvent,
ota?: DefinitionOta,
generated?: boolean,
} & ({ zigbeeModel: string[] } | { fingerprint: Fingerprint[] })
& ({ extend: Extend | ModernExtend[], fromZigbee?: Fz.Converter[], toZigbee?: Tz.Converter[],
exposes?: (Expose[] | ((device: Zh.Device | undefined, options: KeyValue | undefined) => Expose[])) } |
Expand Down
106 changes: 106 additions & 0 deletions test/generateDefinition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Definition } from '../src/lib/types';
import fz from '../src/converters/fromZigbee'

import { repInterval } from '../src/lib/constants';
import {assertDefintion, AssertDefinitionArgs, mockDevice, reportingItem} from './utils';
import { findByDevice} from '../src';
import Device from 'zigbee-herdsman/dist/controller/model/device';

const assertGeneratedDefinition = async (args: AssertDefinitionArgs) => {
const getDefinition = (device: Device): Definition => {
return findByDevice(device, true);
}

const definition = getDefinition(args.device)

expect(definition.model).toEqual(args.device.modelID)

return await assertDefintion({findByDeviceFn: getDefinition, ...args})
}

describe('GenerateDefinition', () => {
test('empty', async () => {
await assertGeneratedDefinition({
device: mockDevice({modelID: 'empty', endpoints: [{inputClusters: [], outputClusters:[]}]}),
meta: undefined,
fromZigbee: [],
toZigbee: [],
exposes: ['linkquality'],
bind: [],
read: [],
configureReporting: [],
});
});

test('input(msTemperatureMeasurement),output(genIdentify)', async () => {
await assertGeneratedDefinition({
device: mockDevice({modelID: 'temp', endpoints: [{inputClusters: ['msTemperatureMeasurement'], outputClusters:['genIdentify']}]}),
meta: undefined,
fromZigbee: [expect.objectContaining({cluster: 'msTemperatureMeasurement'})],
toZigbee: ['temperature', 'identify'],
exposes: ['linkquality', 'temperature'],
bind: {1: ['msTemperatureMeasurement']},
read: {1: [['msTemperatureMeasurement', ['measuredValue']]]},
configureReporting: {
1: [
['msTemperatureMeasurement', [reportingItem('measuredValue', 10, repInterval.HOUR, 100)]],
],
},
});
});

test('input(msPressureMeasurement)', async () => {
await assertGeneratedDefinition({
device: mockDevice({modelID: 'pressure', endpoints: [{inputClusters: ['msPressureMeasurement'], outputClusters:[]}]}),
meta: undefined,
fromZigbee: [expect.objectContaining({cluster: 'msPressureMeasurement'})],
toZigbee: ['pressure'],
exposes: ['linkquality', 'pressure'],
bind: {1: ['msPressureMeasurement']},
read: {1: [['msPressureMeasurement', ['measuredValue']]]},
configureReporting: {
1: [
['msPressureMeasurement', [reportingItem('measuredValue', 10, repInterval.HOUR, 100)]],
],
},
});
});

test('input(msRelativeHumidity)', async () => {
await assertGeneratedDefinition({
device: mockDevice({modelID: 'humidity', endpoints: [{inputClusters: ['msRelativeHumidity'], outputClusters:[]}]}),
meta: undefined,
fromZigbee: [expect.objectContaining({cluster: 'msRelativeHumidity'})],
toZigbee: ['humidity'],
exposes: ['humidity', 'linkquality'],
bind: {1: ['msRelativeHumidity']},
read: {1: [['msRelativeHumidity', ['measuredValue']]]},
configureReporting: {
1: [
['msRelativeHumidity', [reportingItem('measuredValue', 10, repInterval.HOUR, 100)]],
],
},
});
});

test('input(msTemperatureMeasurement, genOnOff)', async () => {
await assertGeneratedDefinition({
device: mockDevice({modelID: 'combo', endpoints: [{inputClusters: ['msTemperatureMeasurement', 'genOnOff'], outputClusters:[]}]}),
meta: undefined,
fromZigbee: [expect.objectContaining({cluster: 'msTemperatureMeasurement'}), fz.on_off],
toZigbee: ['temperature', 'state', 'on_time', 'off_wait_time'],
exposes: ['linkquality', 'switch(state)', 'temperature'],
bind: {1: ['msTemperatureMeasurement', 'genOnOff']},
read: {1: [
['msTemperatureMeasurement', ['measuredValue']],
['genOnOff', ['onOff']],
]},
configureReporting: {
1: [
['msTemperatureMeasurement', [reportingItem('measuredValue', 10, repInterval.HOUR, 100)]],
['genOnOff', [reportingItem('onOff', 0, repInterval.MAX, 1)]],
],
},
});
});
});
31 changes: 31 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,37 @@ describe('index.js', () => {
expect(definition).toBeNull();
});

it('Find by device should generate for unknown', () => {
const endpoints = [
{
ID: 1, profileID: undefined, deviceID: undefined,
getInputClusters() {
return [];
},
getOutputClusters() {
return [{name: 'genIdentify'}]
},
},
];
const device = {
type: 'EndDevice',
manufacturerID: undefined,
modelID: 'test_generate',
endpoints,
getEndpoint: (ID) => endpoints.find((e) => e.ID === ID),
};

const definition = index.findByDevice(device, true);
expect(definition.model).toBe('test_generate');
expect(definition.vendor).toBe('');
expect(definition.description).toBe('Generated from device information');
expect(definition.extend).toBeUndefined();
expect(definition.fromZigbee).toHaveLength(0);
expect(definition.toZigbee).toHaveLength(11);
expect(definition.exposes).toHaveLength(1);
expect(definition.options).toHaveLength(0);
});

it('Find by device when device has modelID should match', () => {
const endpoints = [
{ID: 1, profileID: undefined, deviceID: undefined, inputClusters: [], outputClusters: []},
Expand Down
Loading

0 comments on commit 3468c09

Please sign in to comment.