Skip to content

Commit

Permalink
feat: Add missing clusters to Xioami VOCKQJK11LM (#6840)
Browse files Browse the repository at this point in the history
* feat: add quirkAddEndpointCluster and quirkSendWhenActive modernExtend

quirkAddEndpointCluster allows to add input and output clustersto endpoints.
We can use this quirk on devices what don't have all endpoints discoverable (e.g. LUMI manufacturer devices from Aqara, Xiaomi, Maji, ...)

quirkSendWhenActive sets device.defaultSendRequestWhen to active, some devices are battery powered and sleepy but do not have the genPollCtrl cluster.
e.g. VOCKQJK11LM will timeout nearly all actions as it defaults to immediate without this quirk.

* feat: VOCKQJK11LM add inputCluster quirks

* fix: drop now undeeded endpointID override

* fix: aqaraAirQuality can briefly be 0 after startup

Imediatly after startup the airQuality attribute seems to be 0, this is displayed as `---` on the device when showing ppb value or as the complete absense of the leaves when displaying the 5 leaf mode.

* feat: want device.save() mock for tests

* tests

* fix tests

---------

Co-authored-by: koenkk <koenkanters94@gmail.com>
  • Loading branch information
sjorge and Koenkk committed Jan 8, 2024
1 parent 22c4673 commit 148e14e
Show file tree
Hide file tree
Showing 4 changed files with 84 additions and 10 deletions.
17 changes: 14 additions & 3 deletions src/devices/xiaomi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import * as reporting from '../lib/reporting';
import extend from '../lib/extend';
import {
light, numeric, binary, enumLookup, forceDeviceType,
temperature, humidity, forcePowerSource,
temperature, humidity, forcePowerSource, quirkAddEndpointCluster,
quirkSendWhenActive,
} from '../lib/modernExtend';
const e = exposes.presets;
const ea = exposes.access;
Expand Down Expand Up @@ -3118,10 +3119,20 @@ const definitions: Definition[] = [
meta: {battery: {voltageToPercentage: '3V_2850_3000'}},
exposes: [e.device_temperature(), e.battery(), e.battery_voltage()],
extend: [
quirkSendWhenActive(),
quirkAddEndpointCluster({
endpointID: 1,
inputClusters: [
'msTemperatureMeasurement',
'msRelativeHumidity',
'genAnalogInput',
'aqaraOpple',
],
}),
aqaraAirQuality(),
aqaraVoc(),
temperature({endpointID: 1}),
humidity({endpointID: 1}),
temperature(),
humidity(),
aqaraDisplayUnit(),
],
configure: async (device, coordinatorEndpoint, logger) => {
Expand Down
52 changes: 52 additions & 0 deletions src/lib/modernExtend.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {Zcl} from 'zigbee-herdsman';
import tz from '../converters/toZigbee';
import fz from '../converters/fromZigbee';
import {Fz, Tz, ModernExtend, Range, Zh, Logger, DefinitionOta, OnEvent} from './types';
Expand Down Expand Up @@ -526,6 +527,57 @@ export function forcePowerSource(args: {powerSource: 'Mains (single phase)' | 'B
return {configure, isModernExtend: true};
}

export interface QuirkAddEndpointClusterArgs {
endpointID: number, inputClusters?: string[] | number[], outputClusters?: string[] | number[],
}
export function quirkAddEndpointCluster(args: QuirkAddEndpointClusterArgs): ModernExtend {
const {endpointID, inputClusters, outputClusters} = args;

const configure: Configure = async (device, coordinatorEndpoint, logger) => {
const endpoint = device.getEndpoint(endpointID);

if (endpoint == undefined) {
logger.error(`Quirk: cannot add clusters to endpoint ${endpointID}, endpoint does not exist!`);
return;
}

inputClusters?.forEach((cluster: number | string) => {
const clusterID = isString(cluster) ?
Zcl.Utils.getCluster(cluster, device.manufacturerID).ID :
cluster;

if (!endpoint.inputClusters.includes(clusterID)) {
logger.debug(`Quirk: adding input cluster ${clusterID} to endpoint ${endpointID}.`);
endpoint.inputClusters.push(clusterID);
}
});

outputClusters?.forEach((cluster: number | string) => {
const clusterID = isString(cluster) ?
Zcl.Utils.getCluster(cluster, device.manufacturerID).ID :
cluster;

if (!endpoint.outputClusters.includes(clusterID)) {
logger.debug(`Quirk: adding output cluster ${clusterID} to endpoint ${endpointID}.`);
endpoint.outputClusters.push(clusterID);
}
});

device.save();
};

return {configure, isModernExtend: true};
}

export function quirkSendWhenActive(): ModernExtend {
const configure: Configure = async (device, coordinatorEndpoint, logger) => {
device.defaultSendRequestWhen = 'active';
device.save();
};

return {configure, isModernExtend: true};
}

export function reconfigureReportingsOnDeviceAnnounce(): ModernExtend {
const onEvent: OnEvent = async (type, data, device, options, state: KeyValue) => {
if (type === 'deviceAnnounce') {
Expand Down
5 changes: 1 addition & 4 deletions src/lib/xiaomi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1355,18 +1355,16 @@ export const xiaomiModernExtend = {
cluster: 'genAnalogInput',
attribute: 'presentValue',
reporting: {min: '10_SECONDS', max: '1_HOUR', change: 5},
endpointID: 1,
description: 'Measured VOC value',
unit: 'ppb',
readOnly: true,
...args,
}),
aqaraAirQuality: (args?: Partial<modernExtend.EnumLookupArgs>) => modernExtend.enumLookup({
name: 'air_quality',
lookup: {'excellent': 1, 'good': 2, 'moderate': 3, 'poor': 4, 'unhealthy': 5},
lookup: {'excellent': 1, 'good': 2, 'moderate': 3, 'poor': 4, 'unhealthy': 5, 'unknown': 0},
cluster: 'aqaraOpple',
attribute: 'airQuality',
endpointID: 1,
zigbeeCommandOptions: {disableDefaultResponse: true},
description: 'Measured air quality',
readOnly: true,
Expand All @@ -1382,7 +1380,6 @@ export const xiaomiModernExtend = {
},
cluster: 'aqaraOpple',
attribute: 'displayUnit',
endpointID: 1,
zigbeeCommandOptions: {disableDefaultResponse: true},
description: 'Units to show on the display',
...args,
Expand Down
20 changes: 17 additions & 3 deletions test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as utils from '../src/lib/utils';
import {Zh, Logger, DefinitionMeta, Fz, Definition} from '../src/lib/types';
import tz from '../src/converters/toZigbee';
import { Device } from 'zigbee-herdsman/dist/controller/model';
import {Cluster} from 'zigbee-herdsman/dist/zcl'

interface MockEndpointArgs {ID?: number, inputClusters?: string[], outputClusters?: string[], attributes?: {[s: string]: {[s: string]: unknown}}}

Expand All @@ -16,6 +17,7 @@ export function mockDevice(args: {modelID: string, manufacturerID?: number, endp
// @ts-expect-error
constructor: {name: 'Device'},
ieeeAddr,
save: jest.fn(),
...args,
};

Expand All @@ -30,8 +32,16 @@ export function mockDevice(args: {modelID: string, manufacturerID?: number, endp
return device;
}

function getCluster(ID: string | number) {
const cluster = Object.entries(Cluster).find((c) => typeof ID === 'number' ? c[1].ID === ID : c[0] === ID);
if (!cluster) throw new Error(`Cluster '${ID}' does not exist`);
return {name: cluster[0], ID: cluster[1].ID};
}

function mockEndpoint(args: MockEndpointArgs, device: Zh.Device | undefined): Zh.Endpoint {
const attributes = args.attributes ?? {};
const inputClusters = (args.inputClusters ?? []).map((c) => getCluster(c).ID);
const outputClusters = (args.outputClusters ?? []).map((c) => getCluster(c).ID);
return {
ID: args?.ID ?? 1,
// @ts-expect-error
Expand All @@ -40,9 +50,13 @@ function mockEndpoint(args: MockEndpointArgs, device: Zh.Device | undefined): Zh
configureReporting: jest.fn(),
read: jest.fn(),
getDevice: () => device,
getInputClusters: jest.fn().mockReturnValue(args?.inputClusters?.map((name) => ({name}))),
getOutputClusters: jest.fn().mockReturnValue(args?.outputClusters?.map((name) => ({name}))),
supportsInputCluster: jest.fn().mockImplementation((cluster) => args?.inputClusters?.includes(cluster)),
inputClusters,
outputClusters,
// @ts-expect-error
getInputClusters: () => inputClusters.map((c) => getCluster(c)),
// @ts-expect-error
getOutputClusters: () => outputClusters.map((c) => getCluster(c)),
supportsInputCluster: (key) => !!inputClusters.find((ID) => ID === getCluster(key).ID),
saveClusterAttributeKeyValue: jest.fn().mockImplementation((cluster, values) => attributes[cluster] = {...attributes[cluster], ...values}),
save: jest.fn(),
getClusterAttributeValue: jest.fn().mockImplementation((cluster, attribute) => attributes?.[cluster]?.[attribute]),
Expand Down

0 comments on commit 148e14e

Please sign in to comment.