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
2 changes: 1 addition & 1 deletion mocks/bridgeDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1540,7 +1540,7 @@ export const BRIDGE_DEVICES: Message<Device[]> = {
configured_reportings: [
{
cluster: "genBasic",
attribute: "currentFileVersion",
attribute: "zclVersion",
maximum_report_interval: 35,
minimum_report_interval: 3000,
reportable_change: 3,
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

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

322 changes: 175 additions & 147 deletions src/components/device-page/ReportingRow.tsx

Large diffs are not rendered by default.

52 changes: 43 additions & 9 deletions src/components/device-page/tabs/Reporting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,20 @@ import { faClose, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Zigbee2MQTTAPI } from "zigbee2mqtt";
import { useShallow } from "zustand/react/shallow";
import { type AppState, useAppStore } from "../../../store.js";
import type { Device } from "../../../types.js";
import { sendMessage } from "../../../websocket/WebSocketManager.js";
import Button from "../../Button.js";
import { aggregateReporting, makeDefaultReporting, type ReportingEndpoint, type ReportingRule } from "../../reporting/index.js";
import {
aggregateReporting,
getClusterAttribute,
isAnalogDataType,
makeDefaultReporting,
type ReportingEndpoint,
type ReportingRule,
} from "../../reporting/index.js";
import ReportingRow from "../ReportingRow.js";

interface ReportingProps {
Expand All @@ -18,12 +28,13 @@ interface ReportingEndpointSectionProps extends ReportingEndpoint {
device: Device;
sourceIdx: number;
onApply(rule: ReportingRule): Promise<void>;
bridgeDefinitions: AppState["bridgeDefinitions"][number];
}

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

const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, onApply }: ReportingEndpointSectionProps) => {
const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, onApply, bridgeDefinitions }: ReportingEndpointSectionProps) => {
const { t } = useTranslation(["zigbee", "common"]);
const arrowRef = useRef(null);
const [isAddOpen, setIsAddOpen] = useState(false);
Expand Down Expand Up @@ -84,6 +95,7 @@ const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, o
key={getRuleKey(rule)}
sourceIdx={sourceIdx}
rule={rule}
bridgeDefinitions={bridgeDefinitions}
device={device}
onApply={handleApply}
onSync={onSync}
Expand Down Expand Up @@ -112,7 +124,14 @@ const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, o
<FontAwesomeIcon icon={faClose} />
</Button>
</div>
<ReportingRow sourceIdx={sourceIdx} rule={draftRule} device={device} onApply={handleApply} showDivider={false} />
<ReportingRow
sourceIdx={sourceIdx}
rule={draftRule}
bridgeDefinitions={bridgeDefinitions}
device={device}
onApply={handleApply}
showDivider={false}
/>
</div>
<FloatingArrow
ref={arrowRef}
Expand All @@ -128,30 +147,45 @@ const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, o
});

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

const onApply = useCallback(
async (rule: ReportingRule): Promise<void> => {
const { cluster, endpoint, attribute, minimum_report_interval, maximum_report_interval, reportable_change } = rule;

await sendMessage(sourceIdx, "bridge/request/device/reporting/configure", {
const attrDef = getClusterAttribute(bridgeDefinitions, device.ieee_address, cluster, attribute);
// default to consider analog if can't find attribute definition
const isAnalogAttribute = attrDef == null || isAnalogDataType(attrDef);
const payload: Zigbee2MQTTAPI["bridge/request/device/reporting/configure"] = {
id: device.ieee_address,
endpoint,
cluster,
attribute,
minimum_report_interval,
maximum_report_interval,
reportable_change,
option: {}, // TODO: check this
});
};

if (isAnalogAttribute) {
payload.reportable_change = reportable_change;
}

await sendMessage(sourceIdx, "bridge/request/device/reporting/configure", payload);
},
[sourceIdx, device.ieee_address],
[sourceIdx, device.ieee_address, bridgeDefinitions],
);

return (
<div className="flex flex-col w-full gap-3">
{reportingsByEndpoints.map((reportings) => (
<ReportingEndpointSection key={reportings.endpointId} {...reportings} device={device} sourceIdx={sourceIdx} onApply={onApply} />
<ReportingEndpointSection
key={reportings.endpointId}
{...reportings}
device={device}
sourceIdx={sourceIdx}
onApply={onApply}
bridgeDefinitions={bridgeDefinitions}
/>
))}
</div>
);
Expand Down
18 changes: 7 additions & 11 deletions src/components/modal/components/ReportingBatchEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ export const ReportingBatchEditModal = NiceModal.create(({ onApply }: ReportingB
const { t } = useTranslation(["zigbee", "common", "devicePage"]);
const [minRepInterval, setMinRepInterval] = useState<number | "">("");
const [maxRepInterval, setMaxRepInterval] = useState<number | "">("");
const [repChange, setRepChange] = useState<number | "">("");
const [repChange, setRepChange] = useState<number | null>(null);

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

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

const isValidRule = useMemo(() => {
if (minRepInterval === "" || maxRepInterval === "" || repChange === "") {
return false;
}

return isValidReportingRuleEdit(minRepInterval, maxRepInterval, repChange);
}, [minRepInterval, maxRepInterval, repChange]);
const isValidRule = useMemo(
() => isValidReportingRuleEdit(minRepInterval, maxRepInterval, repChange),
[minRepInterval, maxRepInterval, repChange],
);

return (
<Modal
Expand Down Expand Up @@ -90,8 +87,7 @@ export const ReportingBatchEditModal = NiceModal.create(({ onApply }: ReportingB
label={t(($) => $.min_rep_change)}
type="number"
defaultValue={repChange ?? ""}
onChange={(e) => setRepChange(e.target.value ? e.target.valueAsNumber : "")}
required
onChange={(e) => setRepChange(e.target.value ? e.target.valueAsNumber : null)}
className="input validator"
/>
</Modal>
Expand Down
83 changes: 48 additions & 35 deletions src/components/modal/components/ReportingRuleModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import NiceModal, { useModal } from "@ebay/nice-modal-react";
import { type JSX, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { AppState } from "../../../store.js";
import type { Device } from "../../../types.js";
import Button from "../../Button.js";
import ReportingRow from "../../device-page/ReportingRow.js";
Expand All @@ -11,45 +12,57 @@ type ReportingRuleModalProps = {
sourceIdx: number;
device: Device;
rule: ReportingRule;
onApply(rule: ReportingRule): Promise<void>;
onApply(sourceIdx: number, device: Device, rule: ReportingRule, isAnalogAttribute: boolean): Promise<void>;
bridgeDefinitions: AppState["bridgeDefinitions"][number];
isAnalogAttribute: boolean;
};

export const ReportingRuleModal = NiceModal.create(({ sourceIdx, device, rule, onApply }: ReportingRuleModalProps): JSX.Element => {
const modal = useModal();
const { t } = useTranslation(["common", "devicePage"]);
export const ReportingRuleModal = NiceModal.create(
({ sourceIdx, device, rule, onApply, bridgeDefinitions, isAnalogAttribute }: ReportingRuleModalProps): JSX.Element => {
const modal = useModal();
const { t } = useTranslation(["common", "devicePage"]);

const handleApply = useCallback(
async (updatedRule: ReportingRule): Promise<void> => {
await onApply(updatedRule);
modal.remove();
},
[onApply, modal],
);

useEffect(() => {
const close = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
const handleApply = useCallback(
async (updatedRule: ReportingRule): Promise<void> => {
await onApply(sourceIdx, device, updatedRule, isAnalogAttribute);
modal.remove();
}
};
},
[sourceIdx, device, isAnalogAttribute, onApply, modal],
);

useEffect(() => {
const close = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
modal.remove();
}
};

window.addEventListener("keydown", close);
window.addEventListener("keydown", close);

return () => window.removeEventListener("keydown", close);
}, [modal]);
return () => window.removeEventListener("keydown", close);
}, [modal]);

return (
<Modal
isOpen={modal.visible}
title={`${t(($) => $.reporting, { ns: "devicePage" })}: ${device.friendly_name} (${rule.endpoint})`}
footer={
<Button className="btn btn-neutral" onClick={modal.remove}>
{t(($) => $.cancel)}
</Button>
}
>
<ReportingRow sourceIdx={sourceIdx} device={device} rule={rule} onApply={handleApply} showDivider={false} hideUnbind />
</Modal>
);
});
return (
<Modal
isOpen={modal.visible}
title={`${t(($) => $.reporting, { ns: "devicePage" })}: ${device.friendly_name} (${rule.endpoint})`}
footer={
<Button className="btn btn-neutral" onClick={modal.remove}>
{t(($) => $.cancel)}
</Button>
}
>
<ReportingRow
sourceIdx={sourceIdx}
rule={rule}
bridgeDefinitions={bridgeDefinitions}
device={device}
onApply={handleApply}
showDivider={false}
showOnlyApply
/>
</Modal>
);
},
);
27 changes: 5 additions & 22 deletions src/components/pickers/AttributePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { type ChangeEvent, type InputHTMLAttributes, type JSX, memo, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { Zigbee2MQTTAPI } from "zigbee2mqtt";
import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "../../store.js";
import type { AttributeDefinition, Device } from "../../types.js";
import SelectField from "../form-fields/SelectField.js";

type BridgeDefinitions = Zigbee2MQTTAPI["bridge/definitions"];
import { getClusterAttributes } from "../reporting/index.js";

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

// retrieve cluster attributes, priority to device custom if any, then ZH
const clusterAttributes = useMemo(() => {
const deviceCustomClusters: BridgeDefinitions["custom_clusters"][string] | undefined = bridgeDefinitions.custom_clusters[device.ieee_address];

if (deviceCustomClusters) {
const customClusters = deviceCustomClusters[cluster];

if (customClusters) {
return customClusters.attributes;
}
}

const stdCluster: BridgeDefinitions["clusters"][keyof BridgeDefinitions["clusters"]] | undefined = bridgeDefinitions.clusters[cluster];

if (stdCluster) {
return stdCluster.attributes;
}

return [];
}, [bridgeDefinitions, device.ieee_address, cluster]);
const clusterAttributes = useMemo(
() => getClusterAttributes(bridgeDefinitions, device.ieee_address, cluster),
[bridgeDefinitions, device.ieee_address, cluster],
);

const options = useMemo(() => {
const attrs: JSX.Element[] = [];
Expand Down
Loading