Skip to content

Commit dc89f8b

Browse files
committed
fix: reporting fields per spec
1 parent 32e536e commit dc89f8b

File tree

8 files changed

+399
-249
lines changed

8 files changed

+399
-249
lines changed

mocks/bridgeDevices.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1540,7 +1540,7 @@ export const BRIDGE_DEVICES: Message<Device[]> = {
15401540
configured_reportings: [
15411541
{
15421542
cluster: "genBasic",
1543-
attribute: "currentFileVersion",
1543+
attribute: "zclVersion",
15441544
maximum_report_interval: 35,
15451545
minimum_report_interval: 3000,
15461546
reportable_change: 3,

src/components/device-page/ReportingRow.tsx

Lines changed: 169 additions & 147 deletions
Large diffs are not rendered by default.

src/components/device-page/tabs/Reporting.tsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,19 @@ import { faClose, faPlus } from "@fortawesome/free-solid-svg-icons";
33
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
44
import { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
55
import { useTranslation } from "react-i18next";
6+
import { useShallow } from "zustand/react/shallow";
7+
import { type AppState, useAppStore } from "../../../store.js";
68
import type { Device } from "../../../types.js";
79
import { sendMessage } from "../../../websocket/WebSocketManager.js";
810
import Button from "../../Button.js";
9-
import { aggregateReporting, makeDefaultReporting, type ReportingEndpoint, type ReportingRule } from "../../reporting/index.js";
11+
import {
12+
aggregateReporting,
13+
getClusterAttributes,
14+
isAnalogDataType,
15+
makeDefaultReporting,
16+
type ReportingEndpoint,
17+
type ReportingRule,
18+
} from "../../reporting/index.js";
1019
import ReportingRow from "../ReportingRow.js";
1120

1221
interface ReportingProps {
@@ -18,12 +27,13 @@ interface ReportingEndpointSectionProps extends ReportingEndpoint {
1827
device: Device;
1928
sourceIdx: number;
2029
onApply(rule: ReportingRule): Promise<void>;
30+
bridgeDefinitions: AppState["bridgeDefinitions"][number];
2131
}
2232

2333
const getRuleKey = (rule: ReportingRule): string =>
2434
`${rule.endpoint}-${rule.cluster}-${rule.attribute}-${rule.minimum_report_interval}-${rule.maximum_report_interval}`;
2535

26-
const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, onApply }: ReportingEndpointSectionProps) => {
36+
const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, onApply, bridgeDefinitions }: ReportingEndpointSectionProps) => {
2737
const { t } = useTranslation(["zigbee", "common"]);
2838
const arrowRef = useRef(null);
2939
const [isAddOpen, setIsAddOpen] = useState(false);
@@ -84,6 +94,7 @@ const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, o
8494
key={getRuleKey(rule)}
8595
sourceIdx={sourceIdx}
8696
rule={rule}
97+
bridgeDefinitions={bridgeDefinitions}
8798
device={device}
8899
onApply={handleApply}
89100
onSync={onSync}
@@ -112,7 +123,14 @@ const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, o
112123
<FontAwesomeIcon icon={faClose} />
113124
</Button>
114125
</div>
115-
<ReportingRow sourceIdx={sourceIdx} rule={draftRule} device={device} onApply={handleApply} showDivider={false} />
126+
<ReportingRow
127+
sourceIdx={sourceIdx}
128+
rule={draftRule}
129+
bridgeDefinitions={bridgeDefinitions}
130+
device={device}
131+
onApply={handleApply}
132+
showDivider={false}
133+
/>
116134
</div>
117135
<FloatingArrow
118136
ref={arrowRef}
@@ -128,11 +146,14 @@ const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, o
128146
});
129147

130148
export default function Reporting({ sourceIdx, device }: ReportingProps): JSX.Element {
149+
const bridgeDefinitions = useAppStore(useShallow((state) => state.bridgeDefinitions[sourceIdx]));
131150
const reportingsByEndpoints = useMemo(() => aggregateReporting(device), [device]);
132151

133152
const onApply = useCallback(
134153
async (rule: ReportingRule): Promise<void> => {
135154
const { cluster, endpoint, attribute, minimum_report_interval, maximum_report_interval, reportable_change } = rule;
155+
const clusters = getClusterAttributes(bridgeDefinitions, device.ieee_address, cluster);
156+
const isAnalogAttribute = isAnalogDataType(clusters[attribute]);
136157

137158
await sendMessage(sourceIdx, "bridge/request/device/reporting/configure", {
138159
id: device.ieee_address,
@@ -141,17 +162,25 @@ export default function Reporting({ sourceIdx, device }: ReportingProps): JSX.El
141162
attribute,
142163
minimum_report_interval,
143164
maximum_report_interval,
144-
reportable_change,
165+
// @ts-expect-error TODO: bad Z2M API, change to optional param and don't pass at all
166+
reportable_change: isAnalogAttribute ? reportable_change : undefined,
145167
option: {}, // TODO: check this
146168
});
147169
},
148-
[sourceIdx, device.ieee_address],
170+
[sourceIdx, device.ieee_address, bridgeDefinitions],
149171
);
150172

151173
return (
152174
<div className="flex flex-col w-full gap-3">
153175
{reportingsByEndpoints.map((reportings) => (
154-
<ReportingEndpointSection key={reportings.endpointId} {...reportings} device={device} sourceIdx={sourceIdx} onApply={onApply} />
176+
<ReportingEndpointSection
177+
key={reportings.endpointId}
178+
{...reportings}
179+
device={device}
180+
sourceIdx={sourceIdx}
181+
onApply={onApply}
182+
bridgeDefinitions={bridgeDefinitions}
183+
/>
155184
))}
156185
</div>
157186
);

src/components/modal/components/ReportingBatchEditModal.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ export const ReportingBatchEditModal = NiceModal.create(({ onApply }: ReportingB
1515
const { t } = useTranslation(["zigbee", "common", "devicePage"]);
1616
const [minRepInterval, setMinRepInterval] = useState<number | "">("");
1717
const [maxRepInterval, setMaxRepInterval] = useState<number | "">("");
18-
const [repChange, setRepChange] = useState<number | "">("");
18+
const [repChange, setRepChange] = useState<number | null>(null);
1919

2020
const handleApply = useCallback(async (): Promise<void> => {
21-
await onApply([minRepInterval as number, maxRepInterval as number, repChange as number, false]);
21+
await onApply([minRepInterval as number, maxRepInterval as number, repChange ?? 0, false]);
2222
modal.remove();
2323
}, [onApply, modal, minRepInterval, maxRepInterval, repChange]);
2424

@@ -35,13 +35,10 @@ export const ReportingBatchEditModal = NiceModal.create(({ onApply }: ReportingB
3535
return () => window.removeEventListener("keydown", close);
3636
}, [modal]);
3737

38-
const isValidRule = useMemo(() => {
39-
if (minRepInterval === "" || maxRepInterval === "" || repChange === "") {
40-
return false;
41-
}
42-
43-
return isValidReportingRuleEdit(minRepInterval, maxRepInterval, repChange);
44-
}, [minRepInterval, maxRepInterval, repChange]);
38+
const isValidRule = useMemo(
39+
() => isValidReportingRuleEdit(minRepInterval, maxRepInterval, repChange),
40+
[minRepInterval, maxRepInterval, repChange],
41+
);
4542

4643
return (
4744
<Modal
@@ -90,8 +87,7 @@ export const ReportingBatchEditModal = NiceModal.create(({ onApply }: ReportingB
9087
label={t(($) => $.min_rep_change)}
9188
type="number"
9289
defaultValue={repChange ?? ""}
93-
onChange={(e) => setRepChange(e.target.value ? e.target.valueAsNumber : "")}
94-
required
90+
onChange={(e) => setRepChange(e.target.value ? e.target.valueAsNumber : null)}
9591
className="input validator"
9692
/>
9793
</Modal>
Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import NiceModal, { useModal } from "@ebay/nice-modal-react";
22
import { type JSX, useCallback, useEffect } from "react";
33
import { useTranslation } from "react-i18next";
4+
import type { AppState } from "../../../store.js";
45
import type { Device } from "../../../types.js";
56
import Button from "../../Button.js";
67
import ReportingRow from "../../device-page/ReportingRow.js";
@@ -11,45 +12,56 @@ type ReportingRuleModalProps = {
1112
sourceIdx: number;
1213
device: Device;
1314
rule: ReportingRule;
14-
onApply(rule: ReportingRule): Promise<void>;
15+
onApply(sourceIdx: number, device: Device, rule: ReportingRule): Promise<void>;
16+
bridgeDefinitions: AppState["bridgeDefinitions"][number];
1517
};
1618

17-
export const ReportingRuleModal = NiceModal.create(({ sourceIdx, device, rule, onApply }: ReportingRuleModalProps): JSX.Element => {
18-
const modal = useModal();
19-
const { t } = useTranslation(["common", "devicePage"]);
19+
export const ReportingRuleModal = NiceModal.create(
20+
({ sourceIdx, device, rule, onApply, bridgeDefinitions }: ReportingRuleModalProps): JSX.Element => {
21+
const modal = useModal();
22+
const { t } = useTranslation(["common", "devicePage"]);
2023

21-
const handleApply = useCallback(
22-
async (updatedRule: ReportingRule): Promise<void> => {
23-
await onApply(updatedRule);
24-
modal.remove();
25-
},
26-
[onApply, modal],
27-
);
28-
29-
useEffect(() => {
30-
const close = (e: KeyboardEvent) => {
31-
if (e.key === "Escape") {
32-
e.preventDefault();
24+
const handleApply = useCallback(
25+
async (updatedRule: ReportingRule): Promise<void> => {
26+
await onApply(sourceIdx, device, updatedRule);
3327
modal.remove();
34-
}
35-
};
28+
},
29+
[sourceIdx, device, onApply, modal],
30+
);
31+
32+
useEffect(() => {
33+
const close = (e: KeyboardEvent) => {
34+
if (e.key === "Escape") {
35+
e.preventDefault();
36+
modal.remove();
37+
}
38+
};
3639

37-
window.addEventListener("keydown", close);
40+
window.addEventListener("keydown", close);
3841

39-
return () => window.removeEventListener("keydown", close);
40-
}, [modal]);
42+
return () => window.removeEventListener("keydown", close);
43+
}, [modal]);
4144

42-
return (
43-
<Modal
44-
isOpen={modal.visible}
45-
title={`${t(($) => $.reporting, { ns: "devicePage" })}: ${device.friendly_name} (${rule.endpoint})`}
46-
footer={
47-
<Button className="btn btn-neutral" onClick={modal.remove}>
48-
{t(($) => $.cancel)}
49-
</Button>
50-
}
51-
>
52-
<ReportingRow sourceIdx={sourceIdx} device={device} rule={rule} onApply={handleApply} showDivider={false} hideUnbind />
53-
</Modal>
54-
);
55-
});
45+
return (
46+
<Modal
47+
isOpen={modal.visible}
48+
title={`${t(($) => $.reporting, { ns: "devicePage" })}: ${device.friendly_name} (${rule.endpoint})`}
49+
footer={
50+
<Button className="btn btn-neutral" onClick={modal.remove}>
51+
{t(($) => $.cancel)}
52+
</Button>
53+
}
54+
>
55+
<ReportingRow
56+
sourceIdx={sourceIdx}
57+
rule={rule}
58+
bridgeDefinitions={bridgeDefinitions}
59+
device={device}
60+
onApply={handleApply}
61+
showDivider={false}
62+
showOnlyApply
63+
/>
64+
</Modal>
65+
);
66+
},
67+
);

src/components/pickers/AttributePicker.tsx

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { type ChangeEvent, type InputHTMLAttributes, type JSX, memo, useMemo } from "react";
22
import { useTranslation } from "react-i18next";
3-
import type { Zigbee2MQTTAPI } from "zigbee2mqtt";
43
import { useShallow } from "zustand/react/shallow";
54
import { useAppStore } from "../../store.js";
65
import type { AttributeDefinition, Device } from "../../types.js";
76
import SelectField from "../form-fields/SelectField.js";
8-
9-
type BridgeDefinitions = Zigbee2MQTTAPI["bridge/definitions"];
7+
import { getClusterAttributes } from "../reporting/index.js";
108

119
interface AttributePickerProps extends Omit<InputHTMLAttributes<HTMLSelectElement>, "onChange"> {
1210
sourceIdx: number;
@@ -21,25 +19,10 @@ const AttributePicker = memo(({ sourceIdx, cluster, device, onChange, label, ...
2119
const { t } = useTranslation("zigbee");
2220

2321
// retrieve cluster attributes, priority to device custom if any, then ZH
24-
const clusterAttributes = useMemo(() => {
25-
const deviceCustomClusters: BridgeDefinitions["custom_clusters"][string] | undefined = bridgeDefinitions.custom_clusters[device.ieee_address];
26-
27-
if (deviceCustomClusters) {
28-
const customClusters = deviceCustomClusters[cluster];
29-
30-
if (customClusters) {
31-
return customClusters.attributes;
32-
}
33-
}
34-
35-
const stdCluster: BridgeDefinitions["clusters"][keyof BridgeDefinitions["clusters"]] | undefined = bridgeDefinitions.clusters[cluster];
36-
37-
if (stdCluster) {
38-
return stdCluster.attributes;
39-
}
40-
41-
return [];
42-
}, [bridgeDefinitions, device.ieee_address, cluster]);
22+
const clusterAttributes = useMemo(
23+
() => getClusterAttributes(bridgeDefinitions, device.ieee_address, cluster),
24+
[bridgeDefinitions, device.ieee_address, cluster],
25+
);
4326

4427
const options = useMemo(() => {
4528
const attrs: JSX.Element[] = [];

src/components/reporting/index.ts

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import type { Device } from "../../types.js";
1+
import type { Zigbee2MQTTAPI } from "zigbee2mqtt";
2+
import type { AppState } from "../../store.js";
3+
import type { AttributeDefinition, ClusterDefinition, Device } from "../../types.js";
24

35
export type ReportingRule = {
46
isNew?: string;
@@ -10,6 +12,53 @@ export interface ReportingEndpoint {
1012
rules: ReportingRule[];
1113
}
1214

15+
type BridgeDefinitions = Zigbee2MQTTAPI["bridge/definitions"];
16+
17+
export const isDiscreteOrCompositeDataType = (attrDefinition: AttributeDefinition): boolean =>
18+
(attrDefinition.type >= 0x08 && attrDefinition.type <= 0x1f) ||
19+
attrDefinition.type === 0x30 ||
20+
attrDefinition.type === 0x31 ||
21+
(attrDefinition.type >= 0x41 && attrDefinition.type <= 0x51) ||
22+
(attrDefinition.type >= 0xe8 && attrDefinition.type <= 0xf1);
23+
24+
export const isAnalogDataType = (attrDefinition: AttributeDefinition): boolean =>
25+
(attrDefinition.type >= 0x20 && attrDefinition.type <= 0x2f) ||
26+
(attrDefinition.type >= 0x38 && attrDefinition.type <= 0x3a) ||
27+
(attrDefinition.type >= 0xe0 && attrDefinition.type <= 0xe2);
28+
29+
export const getClusterAttributes = (
30+
bridgeDefinitions: AppState["bridgeDefinitions"][number],
31+
deviceIeeeAddress: string,
32+
clusterName: string,
33+
): ClusterDefinition["attributes"] => {
34+
const deviceCustomClusters: BridgeDefinitions["custom_clusters"][string] | undefined = bridgeDefinitions.custom_clusters[deviceIeeeAddress];
35+
36+
if (deviceCustomClusters) {
37+
const customClusters = deviceCustomClusters[clusterName];
38+
39+
if (customClusters) {
40+
return customClusters.attributes;
41+
}
42+
}
43+
44+
const stdCluster: BridgeDefinitions["clusters"][keyof BridgeDefinitions["clusters"]] | undefined = bridgeDefinitions.clusters[clusterName];
45+
46+
if (stdCluster) {
47+
return stdCluster.attributes;
48+
}
49+
50+
return {};
51+
};
52+
53+
export const getClusterAttribute = (
54+
bridgeDefinitions: AppState["bridgeDefinitions"][number],
55+
deviceIeeeAddress: string,
56+
clusterName: string,
57+
attribute: string | number,
58+
): ClusterDefinition["attributes"][string] | undefined => {
59+
return getClusterAttributes(bridgeDefinitions, deviceIeeeAddress, clusterName)[attribute];
60+
};
61+
1362
export const makeDefaultReporting = (ieeeAddress: string, endpoint: string): ReportingRule => ({
1463
isNew: ieeeAddress,
1564
reportable_change: 0,
@@ -37,23 +86,24 @@ export const aggregateReporting = (device: Device): ReportingEndpoint[] => {
3786
};
3887

3988
export const isValidReportingRuleEdit = (
40-
minRepInterval: number | undefined,
41-
maxRepInterval: number | undefined,
42-
repChange: number | undefined,
89+
minRepInterval: number | undefined | null | "",
90+
maxRepInterval: number | undefined | null | "",
91+
repChange: number | undefined | null | "",
4392
): boolean => {
44-
if (minRepInterval === undefined || Number.isNaN(minRepInterval)) {
93+
if (minRepInterval == null || minRepInterval === "" || Number.isNaN(minRepInterval)) {
4594
return false;
4695
}
4796

48-
if (maxRepInterval === undefined || Number.isNaN(maxRepInterval)) {
97+
if (maxRepInterval == null || maxRepInterval === "" || Number.isNaN(maxRepInterval)) {
4998
return false;
5099
}
51100

52-
if (repChange === undefined || Number.isNaN(repChange)) {
101+
if (repChange === "" || Number.isNaN(repChange)) {
53102
return false;
54103
}
55104

56-
if (minRepInterval > maxRepInterval) {
105+
// can't be greater unless used to signal "default reporting configuration"
106+
if (minRepInterval > maxRepInterval && !(maxRepInterval === 0x0000 && minRepInterval === 0xffff && repChange === 0)) {
57107
return false;
58108
}
59109

0 commit comments

Comments
 (0)