Skip to content

Commit 66ceb1f

Browse files
authored
fix: reporting fields per spec (#349)
* fix: reporting fields per spec * fix: update from API change
1 parent 32e536e commit 66ceb1f

File tree

9 files changed

+443
-271
lines changed

9 files changed

+443
-271
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,

package-lock.json

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/device-page/ReportingRow.tsx

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

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

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,20 @@ 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 type { Zigbee2MQTTAPI } from "zigbee2mqtt";
7+
import { useShallow } from "zustand/react/shallow";
8+
import { type AppState, useAppStore } from "../../../store.js";
69
import type { Device } from "../../../types.js";
710
import { sendMessage } from "../../../websocket/WebSocketManager.js";
811
import Button from "../../Button.js";
9-
import { aggregateReporting, makeDefaultReporting, type ReportingEndpoint, type ReportingRule } from "../../reporting/index.js";
12+
import {
13+
aggregateReporting,
14+
getClusterAttribute,
15+
isAnalogDataType,
16+
makeDefaultReporting,
17+
type ReportingEndpoint,
18+
type ReportingRule,
19+
} from "../../reporting/index.js";
1020
import ReportingRow from "../ReportingRow.js";
1121

1222
interface ReportingProps {
@@ -18,12 +28,13 @@ interface ReportingEndpointSectionProps extends ReportingEndpoint {
1828
device: Device;
1929
sourceIdx: number;
2030
onApply(rule: ReportingRule): Promise<void>;
31+
bridgeDefinitions: AppState["bridgeDefinitions"][number];
2132
}
2233

2334
const getRuleKey = (rule: ReportingRule): string =>
2435
`${rule.endpoint}-${rule.cluster}-${rule.attribute}-${rule.minimum_report_interval}-${rule.maximum_report_interval}`;
2536

26-
const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, onApply }: ReportingEndpointSectionProps) => {
37+
const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, onApply, bridgeDefinitions }: ReportingEndpointSectionProps) => {
2738
const { t } = useTranslation(["zigbee", "common"]);
2839
const arrowRef = useRef(null);
2940
const [isAddOpen, setIsAddOpen] = useState(false);
@@ -84,6 +95,7 @@ const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, o
8495
key={getRuleKey(rule)}
8596
sourceIdx={sourceIdx}
8697
rule={rule}
98+
bridgeDefinitions={bridgeDefinitions}
8799
device={device}
88100
onApply={handleApply}
89101
onSync={onSync}
@@ -112,7 +124,14 @@ const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, o
112124
<FontAwesomeIcon icon={faClose} />
113125
</Button>
114126
</div>
115-
<ReportingRow sourceIdx={sourceIdx} rule={draftRule} device={device} onApply={handleApply} showDivider={false} />
127+
<ReportingRow
128+
sourceIdx={sourceIdx}
129+
rule={draftRule}
130+
bridgeDefinitions={bridgeDefinitions}
131+
device={device}
132+
onApply={handleApply}
133+
showDivider={false}
134+
/>
116135
</div>
117136
<FloatingArrow
118137
ref={arrowRef}
@@ -128,30 +147,45 @@ const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, o
128147
});
129148

130149
export default function Reporting({ sourceIdx, device }: ReportingProps): JSX.Element {
150+
const bridgeDefinitions = useAppStore(useShallow((state) => state.bridgeDefinitions[sourceIdx]));
131151
const reportingsByEndpoints = useMemo(() => aggregateReporting(device), [device]);
132152

133153
const onApply = useCallback(
134154
async (rule: ReportingRule): Promise<void> => {
135155
const { cluster, endpoint, attribute, minimum_report_interval, maximum_report_interval, reportable_change } = rule;
136-
137-
await sendMessage(sourceIdx, "bridge/request/device/reporting/configure", {
156+
const attrDef = getClusterAttribute(bridgeDefinitions, device.ieee_address, cluster, attribute);
157+
// default to consider analog if can't find attribute definition
158+
const isAnalogAttribute = attrDef == null || isAnalogDataType(attrDef);
159+
const payload: Zigbee2MQTTAPI["bridge/request/device/reporting/configure"] = {
138160
id: device.ieee_address,
139161
endpoint,
140162
cluster,
141163
attribute,
142164
minimum_report_interval,
143165
maximum_report_interval,
144-
reportable_change,
145166
option: {}, // TODO: check this
146-
});
167+
};
168+
169+
if (isAnalogAttribute) {
170+
payload.reportable_change = reportable_change;
171+
}
172+
173+
await sendMessage(sourceIdx, "bridge/request/device/reporting/configure", payload);
147174
},
148-
[sourceIdx, device.ieee_address],
175+
[sourceIdx, device.ieee_address, bridgeDefinitions],
149176
);
150177

151178
return (
152179
<div className="flex flex-col w-full gap-3">
153180
{reportingsByEndpoints.map((reportings) => (
154-
<ReportingEndpointSection key={reportings.endpointId} {...reportings} device={device} sourceIdx={sourceIdx} onApply={onApply} />
181+
<ReportingEndpointSection
182+
key={reportings.endpointId}
183+
{...reportings}
184+
device={device}
185+
sourceIdx={sourceIdx}
186+
onApply={onApply}
187+
bridgeDefinitions={bridgeDefinitions}
188+
/>
155189
))}
156190
</div>
157191
);

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: 48 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,57 @@ 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, isAnalogAttribute: boolean): Promise<void>;
16+
bridgeDefinitions: AppState["bridgeDefinitions"][number];
17+
isAnalogAttribute: boolean;
1518
};
1619

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

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();
25+
const handleApply = useCallback(
26+
async (updatedRule: ReportingRule): Promise<void> => {
27+
await onApply(sourceIdx, device, updatedRule, isAnalogAttribute);
3328
modal.remove();
34-
}
35-
};
29+
},
30+
[sourceIdx, device, isAnalogAttribute, onApply, modal],
31+
);
32+
33+
useEffect(() => {
34+
const close = (e: KeyboardEvent) => {
35+
if (e.key === "Escape") {
36+
e.preventDefault();
37+
modal.remove();
38+
}
39+
};
3640

37-
window.addEventListener("keydown", close);
41+
window.addEventListener("keydown", close);
3842

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

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

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[] = [];

0 commit comments

Comments
 (0)