Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 30 additions & 51 deletions lib/extension/bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import bind from "bind-decorator";
import debounce from "debounce";
import stringify from "json-stable-stringify-without-jsonify";
import {Zcl} from "zigbee-herdsman";
import type {TClusterAttributeKeys} from "zigbee-herdsman/dist/zspec/zcl/definition/clusters-types";
import type {ClusterName} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype";
import Device from "../model/device";
import Group from "../model/group";
Expand Down Expand Up @@ -43,53 +44,33 @@ const getColorCapabilities = async (endpoint: zh.Endpoint): Promise<{colorTemper
};
};

const REPORT_CLUSTERS: Readonly<
Partial<
Record<
ClusterName,
Readonly<{
attribute: string;
minimumReportInterval: number;
maximumReportInterval: number;
reportableChange: number;
condition?: (endpoint: zh.Endpoint) => Promise<boolean>;
}>[]
>
>
> = {
genOnOff: [{attribute: "onOff", ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
genLevelCtrl: [{attribute: "currentLevel", ...DEFAULT_REPORT_CONFIG}],
const REPORT_CLUSTERS = {
genOnOff: [{attribute: "onOff" as const, ...DEFAULT_REPORT_CONFIG, minimumReportInterval: 0, reportableChange: 0}],
genLevelCtrl: [{attribute: "currentLevel" as const, ...DEFAULT_REPORT_CONFIG}],
lightingColorCtrl: [
{
attribute: "colorTemperature",
attribute: "colorTemperature" as const,
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorTemperature,
},
{
attribute: "currentX",
attribute: "currentX" as const,
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
{
attribute: "currentY",
attribute: "currentY" as const,
...DEFAULT_REPORT_CONFIG,
condition: async (endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
condition: async (endpoint: zh.Endpoint): Promise<boolean> => (await getColorCapabilities(endpoint)).colorXY,
},
],
closuresWindowCovering: [
{attribute: "currentPositionLiftPercentage", ...DEFAULT_REPORT_CONFIG},
{attribute: "currentPositionTiltPercentage", ...DEFAULT_REPORT_CONFIG},
{attribute: "currentPositionLiftPercentage" as const, ...DEFAULT_REPORT_CONFIG},
{attribute: "currentPositionTiltPercentage" as const, ...DEFAULT_REPORT_CONFIG},
],
};

type PollOnMessage = {
cluster: Readonly<Partial<Record<ClusterName, {type: string; data: KeyValue}[]>>>;
read: Readonly<{cluster: string; attributes: string[]; attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}>;
manufacturerIDs: readonly Zcl.ManufacturerCode[];
manufacturerNames: readonly string[];
}[];

const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
const POLL_ON_MESSAGE = [
{
// On messages that have the cluster and type of below
cluster: {
Expand All @@ -109,7 +90,7 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
genScenes: [{type: "commandRecall", data: {}}],
},
// Read the following attributes
read: {cluster: "genLevelCtrl", attributes: ["currentLevel"]},
read: {cluster: "genLevelCtrl" as const, attributes: ["currentLevel"] as TClusterAttributeKeys<"genLevelCtrl">},
// When the bound devices/members of group have the following manufacturerIDs
manufacturerIDs: [
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
Expand Down Expand Up @@ -141,7 +122,7 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
{type: "commandHueNotification", data: {button: 4}},
],
},
read: {cluster: "genOnOff", attributes: ["onOff"]},
read: {cluster: "genOnOff" as const, attributes: ["onOff"] as TClusterAttributeKeys<"genOnOff">},
manufacturerIDs: [
Zcl.ManufacturerCode.SIGNIFY_NETHERLANDS_B_V,
Zcl.ManufacturerCode.ATMEL,
Expand All @@ -157,13 +138,13 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [
genScenes: [{type: "commandRecall", data: {}}],
},
read: {
cluster: "lightingColorCtrl",
attributes: [] as string[],
cluster: "lightingColorCtrl" as const,
attributes: [] as TClusterAttributeKeys<"lightingColorCtrl">,
// Since not all devices support the same attributes they need to be calculated dynamically
// depending on the capabilities of the endpoint.
attributesForEndpoint: async (endpoint): Promise<string[]> => {
attributesForEndpoint: async (endpoint: zh.Endpoint): Promise<TClusterAttributeKeys<"lightingColorCtrl">> => {
const supportedAttrs = await getColorCapabilities(endpoint);
const readAttrs: string[] = [];
const readAttrs: TClusterAttributeKeys<"lightingColorCtrl"> = [];

if (supportedAttrs.colorXY) {
readAttrs.push("currentX", "currentY");
Expand Down Expand Up @@ -480,16 +461,15 @@ export default class Bind extends Extension {
const items = [];

// biome-ignore lint/style/noNonNullAssertion: valid from outer `if`
for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]!) {
if (!c.condition || (await c.condition(endpoint))) {
const i = {...c};
delete i.condition;
for (const c of REPORT_CLUSTERS[bind.cluster.name as keyof typeof REPORT_CLUSTERS]!) {
if (!("condition" in c) || !c.condition || (await c.condition(endpoint))) {
const {attribute, minimumReportInterval, maximumReportInterval, reportableChange} = c;

items.push(i);
items.push({attribute, minimumReportInterval, maximumReportInterval, reportableChange});
}
}

await endpoint.configureReporting(bind.cluster.name, items);
await endpoint.configureReporting(bind.cluster.name as keyof typeof REPORT_CLUSTERS, 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 as Error).message})`);
Expand Down Expand Up @@ -539,16 +519,15 @@ export default class Bind extends Extension {
const items = [];

// biome-ignore lint/style/noNonNullAssertion: valid from loop (pushed to array only if in)
for (const item of REPORT_CLUSTERS[cluster as ClusterName]!) {
if (!item.condition || (await item.condition(endpoint))) {
const i = {...item};
delete i.condition;
for (const item of REPORT_CLUSTERS[cluster as keyof typeof REPORT_CLUSTERS]!) {
if (!("condition" in item) || !item.condition || (await item.condition(endpoint))) {
const {attribute, minimumReportInterval, reportableChange} = item;

items.push({...i, maximumReportInterval: 0xffff});
items.push({attribute, minimumReportInterval, maximumReportInterval: 0xffff, reportableChange});
}
}

await endpoint.configureReporting(cluster, items);
await endpoint.configureReporting(cluster as keyof typeof REPORT_CLUSTERS, items);
logger.info(`Successfully disabled reporting for '${entity}' cluster '${cluster}'`);
} catch (error) {
logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${(error as Error).message})`);
Expand All @@ -569,7 +548,7 @@ export default class Bind extends Extension {
* When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound).
*/
const polls = POLL_ON_MESSAGE.filter((p) =>
p.cluster[data.cluster as ClusterName]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)),
p.cluster[data.cluster as keyof (typeof p)["cluster"]]?.some((c) => c.type === data.type && utils.equalsPartial(data.data, c.data)),
);

if (polls.length) {
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@
"winston-syslog": "^2.7.1",
"winston-transport": "^4.9.0",
"ws": "^8.18.1",
"zigbee-herdsman": "6.0.0",
"zigbee-herdsman-converters": "25.2.0",
"zigbee-herdsman": "6.0.1",
"zigbee-herdsman-converters": "25.4.0",
"zigbee2mqtt-frontend": "0.9.20",
"zigbee2mqtt-windfront": "^1.8.1"
},
Expand Down
24 changes: 12 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions test/extensions/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ describe("Extension: Publish", () => {
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [1], datatype: 1, dp: 2}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([1]), datatype: 1, dp: 2}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/TS0601_switch", stringify({state_l2: "ON"}), {retain: false, qos: 0});
Expand All @@ -256,19 +256,19 @@ describe("Extension: Publish", () => {
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [0], datatype: 4, dp: 1}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([0]), datatype: 4, dp: 1}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [1], datatype: 1, dp: 102}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([1]), datatype: 1, dp: 102}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [0], datatype: 1, dp: 101}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([0]), datatype: 1, dp: 101}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
Expand Down Expand Up @@ -425,7 +425,7 @@ describe("Extension: Publish", () => {
expect(group.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [1], datatype: 1, dp: 7}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([1]), datatype: 1, dp: 7}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
});
Expand Down Expand Up @@ -1074,7 +1074,7 @@ describe("Extension: Publish", () => {
expect(endpoint.command.mock.calls[0]).toEqual([
"lightingColorCtrl",
"enhancedMoveToHueAndSaturation",
{direction: 0, enhancehue: 45510, saturation: 127, transtime: 0},
{enhancehue: 45510, saturation: 127, transtime: 0},
{},
]);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -1442,7 +1442,7 @@ describe("Extension: Publish", () => {
await mockMQTTEvents.message("zigbee2mqtt/roller_shutter/set", stringify({state: "OPEN"}));
await flushPromises();
expect(endpoint.command).toHaveBeenCalledTimes(1);
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: "255", transtime: 0}, {});
expect(endpoint.command).toHaveBeenCalledWith("genLevelCtrl", "moveToLevelWithOnOff", {level: 255, transtime: 0}, {});
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/roller_shutter");
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({position: 100});
Expand All @@ -1458,7 +1458,7 @@ describe("Extension: Publish", () => {
expect(endpoint.command).toHaveBeenCalledWith(
"manuSpecificTuya",
"dataRequest",
{dpValues: [{data: [1], datatype: 1, dp: 3}], seq: expect.any(Number)},
{dpValues: [{data: Buffer.from([1]), datatype: 1, dp: 3}], seq: expect.any(Number)},
{disableDefaultResponse: true},
);
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
Expand Down
Loading