Skip to content

Commit

Permalink
feat(ignore): Extend modernExtend (#6588)
Browse files Browse the repository at this point in the history
* feat: Setup attributes in switch modernExtend

* feat: Add more modern extend

* cleanup

* dummy
  • Loading branch information
Koenkk committed Nov 30, 2023
1 parent 30ec89f commit 66bfa64
Show file tree
Hide file tree
Showing 8 changed files with 211 additions and 30 deletions.
2 changes: 0 additions & 2 deletions src/converters/toZigbee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4018,7 +4018,6 @@ const converters2 = {
time_report: ['closuresDoorLock', 0x0056],
};
const v = utils.getFromLookup(key, payloads);
// @ts-expect-error
await entity.read(v[0], [v[1]]);
},
} satisfies Tz.Converter,
Expand Down Expand Up @@ -4510,7 +4509,6 @@ const converters2 = {
convertGet: async (entity, key, meta) => {
// Apparently, reading values may interfere with a commandStatusChangeNotification for changed occupancy.
// Therefore, read "zoneStatus" as well.
// @ts-expect-error
await entity.read('ssIasZone', ['currentZoneSensitivityLevel', 61441, 'zoneStatus'], {sendWhen: 'active'});
},
} satisfies Tz.Converter,
Expand Down
1 change: 0 additions & 1 deletion src/devices/legrand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ const definitions: Definition[] = [
await reporting.readEletricalMeasurementMultiplierDivisors(endpoint);
await reporting.activePower(endpoint);
// Read configuration values that are not sent periodically as well as current power (activePower).
// @ts-expect-error
await endpoint.read('haElectricalMeasurement', ['activePower', 0xf000, 0xf001, 0xf002]);
},
},
Expand Down
1 change: 0 additions & 1 deletion src/devices/tuya.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2189,7 +2189,6 @@ const definitions: Definition[] = [
meta: {multiEndpoint: true, multiEndpointSkip: ['energy', 'current', 'voltage', 'power']},
configure: async (device, coordinatorEndpoint, logger) => {
const endpoint = device.getEndpoint(1);
// @ts-expect-error
await endpoint.read('genBasic', ['manufacturerName', 'zclVersion', 'appVersion', 'modelId', 'powerSource', 0xfffe]);
await reporting.bind(endpoint, coordinatorEndpoint, ['genOnOff', 'haElectricalMeasurement', 'seMetering']);
await reporting.rmsVoltage(endpoint, {change: 5});
Expand Down
21 changes: 13 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import toZigbee from './converters/toZigbee';
import fromZigbee from './converters/fromZigbee';
import assert from 'assert';
import allDefinitions from './devices';
import { Definition, Fingerprint, Zh, OnEventData, OnEventType } from './lib/types';
import { Definition, Fingerprint, Zh, OnEventData, OnEventType, Configure } from './lib/types';

// key: zigbeeModel, value: array of definitions (most of the times 1)
const lookup = new Map();
Expand Down Expand Up @@ -63,14 +63,15 @@ function addDefinition(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
let {extend, toZigbee, fromZigbee, exposes, meta, configure, onEvent, ota, ...definitionWithoutExtend} = definition;
let {extend, toZigbee, fromZigbee, exposes, meta, configure: definitionConfigure, onEvent, ota, ...definitionWithoutExtend} = definition;
if (typeof exposes === 'function') {
assert.fail(`'${definition.model}' has function exposes which is not allowed`);
}

toZigbee = [...toZigbee ?? []];
fromZigbee = [...fromZigbee ?? []];
exposes = [...exposes ?? []];
const configures: Configure[] = definitionConfigure ? [definitionConfigure] : [];

for (const ext of extend) {
if (!ext.isModernExtend) {
Expand All @@ -80,12 +81,7 @@ function addDefinition(definition: Definition) {
if (ext.fromZigbee) fromZigbee.push(...ext.fromZigbee);
if (ext.exposes) exposes.push(...ext.exposes);
if (ext.meta) meta = {...ext.meta, ...meta};
if (ext.configure) {
if (configure) {
assert.fail(`'${definition.model}' has multiple 'configure', this is not allowed`);
}
configure = ext.configure;
}
if (ext.configure) configures.push(ext.configure);
if (ext.ota) {
if (ota) {
assert.fail(`'${definition.model}' has multiple 'ota', this is not allowed`);
Expand All @@ -99,6 +95,15 @@ function addDefinition(definition: Definition) {
onEvent = ext.onEvent;
}
}

let configure: Configure = null;
if (configures.length !== 0) {
configure = async (device, coordinatorEndpoint, logger) => {
for (const func of configures) {
await func(device, coordinatorEndpoint, logger);
}
}
}
definition = {toZigbee, fromZigbee, exposes, meta, configure, onEvent, ota, ...definitionWithoutExtend};
} else {
// Legacy extend, overrides properties, e.g. when both extend and definition has toZigbee, definition toZigbee will be used
Expand Down
10 changes: 6 additions & 4 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ export const OneJanuary2000 = new Date('January 01, 2000 00:00:00 UTC+00:00').ge
export const defaultBindGroup = 901;

export const repInterval = {
MAX: 62000,
HOUR: 3600,
MINUTES_30: 1800,
MINUTES_15: 900,
MAX: 65000,
MINUTE: 60,
SECONDS_10: 10,
MINUTES_10: 600,
MINUTES_15: 900,
MINUTES_30: 1800,
MINUTES_5: 300,
MINUTE: 60,
SECONDS_5: 5,
};

export const thermostatControlSequenceOfOperations: KeyValueNumberString = {
Expand Down
157 changes: 144 additions & 13 deletions src/lib/modernExtend.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,163 @@
import tz from '../converters/toZigbee';
import fz from '../converters/fromZigbee';
import {Fz, Tz, ModernExtend, Range} from './types';
import {Enum, Numeric, access, Light, Binary, presets as exposePresets} from './exposes';
import {Fz, Tz, ModernExtend, Range, Zh, Logger} from './types';
import {Enum, Numeric, access, Light, Binary, presets as e, access as ea} from './exposes';
import {KeyValue, Configure, Expose, DefinitionMeta} from './types';
import {configure as lightConfigure} from './light';
import {getFromLookupByValue, isString, getFromLookup, getEndpointName, assertNumber, postfixWithEndpointName, isObject} from './utils';
import {ConfigureReportingItem} from 'zigbee-herdsman/dist/controller/model/endpoint';
import {getFromLookupByValue, isString, getFromLookup, getEndpointName, assertNumber, postfixWithEndpointName, isObject, isEndpoint} from './utils';
import {repInterval} from './constants';

const DefaultReportingItemValues = {
minimumReportInterval: 0,
maximumReportInterval: repInterval.MAX,
reportableChange: 1,
};

function getEndpointsWithInputCluster(device: Zh.Device, cluster: string) {
if (!device.endpoints) {
throw new Error(device.ieeeAddr + ' ' + device.endpoints);
}
const endpoints = device.endpoints.filter((ep) => ep.getInputClusters().find((c) => c.name === cluster));
if (endpoints.length === 0) {
throw new Error(`Device ${device.ieeeAddr} has no input cluster ${cluster}`);
}
return endpoints;
}

async function setupAttributes(
entity: Zh.Device | Zh.Endpoint, coordinatorEndpoint: Zh.Endpoint, cluster: string, attributes: (string|ConfigureReportingItem)[], logger: Logger,
readOnly=false,
) {
const endpoints = isEndpoint(entity) ? [entity] : getEndpointsWithInputCluster(entity, cluster);
const ieeeAddr = isEndpoint(entity) ? entity.deviceIeeeAddress : entity.ieeeAddr;
for (const endpoint of endpoints) {
const msg = readOnly ? `Reading` : `Reading and setup reporting`;
logger.debug(`${msg} for ${ieeeAddr}/${endpoint.ID} ${cluster} ${JSON.stringify(attributes)}`);
const items = attributes.map((attribute) => ({...DefaultReportingItemValues, ...(isString(attribute) ? {attribute} : attribute)}));
if (!readOnly) {
await endpoint.bind(cluster, coordinatorEndpoint);
await endpoint.configureReporting(cluster, items);
}
await endpoint.read(cluster, attributes.map((a) => isString(a) ? a : (isObject(a.attribute) ? a.attribute.ID : a.attribute)));
}
}

interface SwitchArgs {powerOnBehavior?: boolean}
export interface SwitchArgs {powerOnBehavior?: boolean}
function switch_(args?: SwitchArgs): ModernExtend {
args = {powerOnBehavior: true, ...args};

const exposes: Expose[] = [exposePresets.switch()];
const fromZigbee: Fz.Converter[] = [fz.on_off, fz.ignore_basic_report];
const exposes: Expose[] = [e.switch()];
const fromZigbee: Fz.Converter[] = [fz.on_off];
const toZigbee: Tz.Converter[] = [tz.on_off];

const configure: Configure = async (device, coordinatorEndpoint, logger) => {
await setupAttributes(device, coordinatorEndpoint, 'genOnOff', ['onOff'], logger);
if (args.powerOnBehavior) {
await setupAttributes(device, coordinatorEndpoint, 'genOnOff', ['startUpOnOff'], logger, true);
}
};

if (args.powerOnBehavior) {
exposes.push(exposePresets.power_on_behavior(['off', 'on', 'toggle', 'previous']));
exposes.push(e.power_on_behavior(['off', 'on', 'toggle', 'previous']));
fromZigbee.push(fz.power_on_behavior);
toZigbee.push(tz.power_on_behavior);
}

return {exposes, fromZigbee, toZigbee, isModernExtend: true};
return {exposes, fromZigbee, toZigbee, configure, isModernExtend: true};
}
export {switch_ as switch};

type MultiplierDivisor = {multiplier?: number, divisor?: number}
interface ElectricalMeasurementArgs {
cluster?: 'both' | 'metering' | 'electrical',
current?: MultiplierDivisor,
power?: MultiplierDivisor,
voltage?: MultiplierDivisor,
energy?: MultiplierDivisor
}
export function electricalMeasurements(args?: ElectricalMeasurementArgs): ModernExtend {
args = {cluster: 'both', ...args};
if (args.cluster === 'metering' && (args.power?.divisor !== args.energy?.divisor || args.power?.multiplier !== args.energy?.multiplier)) {
throw new Error(`When cluster is metering, power and energy divisor/multiplier should be equal`);
}

let exposes: Expose[];
let fromZigbee: Fz.Converter[];
let toZigbee: Tz.Converter[];

const configureLookup = {
haElectricalMeasurement: {
// Report change with every 5W change
power: {attribute: 'activePower', divisor: 'acPowerDivisor', multiplier: 'acPowerMultiplier', forced: args.power, change: 5},
// Report change with every 0.05A change
current: {attribute: 'rmsCurrent', divisor: 'acCurrentDivisor', multiplier: 'acCurrentMultiplier', forced: args.current, change: 0.05},
// Report change with every 5V change
voltage: {attribute: 'rmsVoltage', divisor: 'acVoltageDivisor', multiplier: 'acVoltageMultiplier', forced: args.voltage, change: 5},
},
seMetering: {
// Report change with every 5W change
power: {attribute: 'instantaneousDemand', divisor: 'divisor', multiplier: 'multiplier', forced: args.power, change: 5},
// Report change with every 0.1kWh change
energy: {attribute: 'currentSummDelivered', divisor: 'divisor', multiplier: 'multiplier', forced: args.energy, change: 0.1},
},
};

if (args.cluster === 'both') {
exposes = [e.power().withAccess(ea.ALL), e.voltage().withAccess(ea.ALL), e.current().withAccess(ea.ALL), e.energy().withAccess(ea.ALL)];
fromZigbee = [fz.electrical_measurement, fz.metering];
toZigbee = [tz.electrical_measurement_power, tz.acvoltage, tz.accurrent, tz.currentsummdelivered];
delete configureLookup.seMetering.power;
} else if (args.cluster === 'metering') {
exposes = [e.power().withAccess(ea.ALL), e.energy().withAccess(ea.ALL)];
fromZigbee = [fz.metering];
toZigbee = [tz.metering_power, tz.currentsummdelivered];
delete configureLookup.haElectricalMeasurement;
} else if (args.cluster === 'electrical') {
exposes = [e.power().withAccess(ea.ALL), e.voltage().withAccess(ea.ALL), e.current().withAccess(ea.ALL)];
fromZigbee = [fz.electrical_measurement];
toZigbee = [tz.electrical_measurement_power, tz.acvoltage, tz.accurrent];
delete configureLookup.seMetering;
}

const configure: Configure = async (device, coordinatorEndpoint, logger) => {
for (const [cluster, properties] of Object.entries(configureLookup)) {
for (const endpoint of getEndpointsWithInputCluster(device, cluster)) {
const items: ConfigureReportingItem[] = [];
for (const property of Object.values(properties)) {
// In case multiplier or divisor was provided, use that instead of reading from device.
if (property.forced) {
endpoint.saveClusterAttributeKeyValue(cluster, {
[property.divisor]: property.forced.divisor ?? 1,
[property.multiplier]: property.forced.multiplier ?? 1,
});
endpoint.save();
} else {
await endpoint.read(cluster, [property.divisor, property.multiplier]);
}

const divisor = endpoint.getClusterAttributeValue(cluster, property.divisor);
assertNumber(divisor, property.divisor);
const multiplier = endpoint.getClusterAttributeValue(cluster, property.multiplier);
assertNumber(multiplier, property.multiplier);
let reportableChange: number | [number, number] = property.change * (divisor / multiplier);
// currentSummDelivered data type is uint48, so reportableChange also is uint48
if (property.attribute === 'currentSummDelivered') reportableChange = [0, reportableChange];
items.push({
attribute: property.attribute,
minimumReportInterval: repInterval.SECONDS_10,
maximumReportInterval: repInterval.MAX,
reportableChange,
});
}
await setupAttributes(endpoint, coordinatorEndpoint, cluster, items, logger);
}
}
};

return {exposes, fromZigbee, toZigbee, configure, isModernExtend: true};
}

export interface LightArgs {
effect?: boolean, powerOnBehaviour?: boolean, colorTemp?: {startup?: boolean, range: Range},
color?: boolean | {modes: ('xy' | 'hs')[]}
Expand Down Expand Up @@ -68,12 +202,12 @@ export function light(args?: LightArgs): ModernExtend {
const exposes: Expose[] = [lightExpose];

if (args.effect) {
exposes.push(exposePresets.effect());
exposes.push(e.effect());
toZigbee.push(tz.effect);
}

if (args.powerOnBehaviour) {
exposes.push(exposePresets.power_on_behavior(['off', 'on', 'toggle', 'previous']));
exposes.push(e.power_on_behavior(['off', 'on', 'toggle', 'previous']));
fromZigbee.push(fz.power_on_behavior);
toZigbee.push(tz.power_on_behavior);
}
Expand Down Expand Up @@ -115,7 +249,6 @@ export function enumLookup(args: EnumLookupArgs): ModernExtend {
return {state: {[key]: value}};
},
convertGet: async (entity, key, meta) => {
// @ts-expect-error TODO fix zh type
await entity.read(cluster, [attributeKey], zigbeeCommandOptions);
},
}];
Expand Down Expand Up @@ -162,7 +295,6 @@ export function numeric(args: NumericArgs): ModernExtend {
return {state: {[key]: value}};
},
convertGet: async (entity, key, meta) => {
// @ts-expect-error TODO fix zh type
await entity.read(cluster, [attributeKey], zigbeeCommandOptions);
},
}];
Expand Down Expand Up @@ -201,7 +333,6 @@ export function binary(args: BinaryArgs): ModernExtend {
return {state: {[key]: value}};
},
convertGet: async (entity, key, meta) => {
// @ts-expect-error TODO fix zh type
await entity.read(cluster, [attributeKey], zigbeeCommandOptions);
},
}];
Expand Down
1 change: 0 additions & 1 deletion src/lib/tuya.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,6 @@ export const skip = {
export const configureMagicPacket = async (device: Zh.Device, coordinatorEndpoint: Zh.Endpoint, logger: Logger) => {
try {
const endpoint = device.endpoints[0];
// @ts-expect-error
await endpoint.read('genBasic', ['manufacturerName', 'zclVersion', 'appVersion', 'modelId', 'powerSource', 0xfffe]);
} catch (e) {
// Fails for some TuYa devices with UNSUPPORTED_ATTRIBUTE, ignore that.
Expand Down
48 changes: 48 additions & 0 deletions test/modernExtend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {findByDevice} from '../src/index';
import * as utils from '../src/lib/utils';
import {Zh, Logger} from '../src/lib/types';
import * as reporting from '../src/lib/reporting';
import { repInterval } from '../src/lib/constants';
import tz from '../src/converters/toZigbee'
import fz from '../src/converters/fromZigbee'

export function reportingItem(attribute: string, min: number, max: number, change: number | [number, number]) {
return {attribute: attribute, minimumReportInterval: min, maximumReportInterval: max, reportableChange: change};
}

function mockDevice(args: {modelID: string, endpoints: {inputClusters: string[]}[]}): Zh.Device {
const ieeeAddr = '0x12345678';
return {
// @ts-expect-error
constructor: {name: 'Device'},
ieeeAddr,
...args,
endpoints: args.endpoints.map((endpoint) => mockEndpoint(endpoint))
};
}

function mockEndpoint(args?: {inputClusters: string[]}): Zh.Endpoint {
return {
// @ts-expect-error
constructor: {name: 'Endpoint'},
bind: jest.fn(),
configureReporting: jest.fn(),
read: jest.fn(),
getInputClusters: jest.fn().mockReturnValue(args?.inputClusters?.map((name) => ({name}))),
saveClusterAttributeKeyValue: jest.fn(),
save: jest.fn(),
getClusterAttributeValue: jest.fn(),
};
}

const MockLogger: Logger = {info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn()};

const DefaultTz = [
tz.scene_store, tz.scene_recall, tz.scene_add, tz.scene_remove, tz.scene_remove_all,
tz.scene_rename, tz.read, tz.write, tz.command, tz.factory_reset
];

describe('ModernExtend', () => {
test('dummy', () => {
})
});

0 comments on commit 66bfa64

Please sign in to comment.