diff --git a/mocks/bridgeDevices.ts b/mocks/bridgeDevices.ts index c56d05507..72d3a0ac4 100644 --- a/mocks/bridgeDevices.ts +++ b/mocks/bridgeDevices.ts @@ -1540,7 +1540,7 @@ export const BRIDGE_DEVICES: Message = { configured_reportings: [ { cluster: "genBasic", - attribute: "currentFileVersion", + attribute: "zclVersion", maximum_report_interval: 35, minimum_report_interval: 3000, reportable_change: 3, diff --git a/package-lock.json b/package-lock.json index 8fa8e7d21..ace8a527d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7306,9 +7306,9 @@ "license": "ISC" }, "node_modules/zigbee-herdsman": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/zigbee-herdsman/-/zigbee-herdsman-7.0.0.tgz", - "integrity": "sha512-2qB2/DxWRaUxTfoFNLa7pNV+FR0UJrDoqgbqMKjw9MGe1HjjSyq6lUXN1e9IGJ4Hezx4uKSkYVBr2zDUS+qxsA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/zigbee-herdsman/-/zigbee-herdsman-7.0.1.tgz", + "integrity": "sha512-XHfngXqc0G9cUN/YXivbhdNKX2NapxQ38F+ZWboF0VGJ6ZkiMNpGTO9Wnq6DpveBKHpcYJFlMbenDF5IxEW4qQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7325,9 +7325,9 @@ } }, "node_modules/zigbee-herdsman-converters": { - "version": "25.75.0", - "resolved": "https://registry.npmjs.org/zigbee-herdsman-converters/-/zigbee-herdsman-converters-25.75.0.tgz", - "integrity": "sha512-drhNsRCm16iAoFA4z2y68g4mylMqUbgI1CQGxjq2KMo/y6X6DdBz9VsVmSqbe9jUeLepGp1xtgbN7O/CHQRXfg==", + "version": "25.77.0", + "resolved": "https://registry.npmjs.org/zigbee-herdsman-converters/-/zigbee-herdsman-converters-25.77.0.tgz", + "integrity": "sha512-VgnlLv0hVZ/iIDHAOra1KuXr0ktjXDH+mlOVT6J/PgTeuDWr5KUXKD5PWlMBoKwKkrztJ/gSEEZyE/w6XKTUlA==", "dev": true, "license": "MIT", "dependencies": { @@ -7381,12 +7381,12 @@ }, "node_modules/zigbee2mqtt": { "version": "2.6.3-dev", - "resolved": "git+ssh://git@github.com/Koenkk/zigbee2mqtt.git#beb91a725435af2ad0798e643c8fb5d47dbc51ba", + "resolved": "git+ssh://git@github.com/Koenkk/zigbee2mqtt.git#6d0f686b96667035154a8be03706fae9e51e45a5", "dev": true, "license": "GPL-3.0", "dependencies": { - "zigbee-herdsman": "7.0.0", - "zigbee-herdsman-converters": "25.75.0" + "zigbee-herdsman": "7.0.1", + "zigbee-herdsman-converters": "25.77.0" }, "bin": { "zigbee2mqtt": "cli.js" diff --git a/src/components/device-page/ReportingRow.tsx b/src/components/device-page/ReportingRow.tsx index 0833447a5..1f8b2562c 100644 --- a/src/components/device-page/ReportingRow.tsx +++ b/src/components/device-page/ReportingRow.tsx @@ -2,185 +2,213 @@ import { faArrowsRotate, faBan, faCheck } from "@fortawesome/free-solid-svg-icon import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { type ChangeEvent, memo, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import type { Device } from "../../types.js"; +import type { AppState } from "../../store.js"; +import type { AttributeDefinition, Device } from "../../types.js"; import Button from "../Button.js"; import ConfirmButton from "../ConfirmButton.js"; import InputField from "../form-fields/InputField.js"; import AttributePicker from "../pickers/AttributePicker.js"; import ClusterSinglePicker from "../pickers/ClusterSinglePicker.js"; import type { ClusterGroup } from "../pickers/index.js"; -import { isValidReportingRule, type ReportingRule } from "../reporting/index.js"; +import { getClusterAttributes, isAnalogDataType, isValidReportingRule, type ReportingRule } from "../reporting/index.js"; interface ReportingRowProps { sourceIdx: number; rule: ReportingRule; + bridgeDefinitions: AppState["bridgeDefinitions"][number]; device: Device; - onApply(rule: ReportingRule): void; + onApply(rule: ReportingRule): Promise; onSync?: ([sourceIdx, id, endpoint, cluster, attribute]: [number, string, number, string, string]) => Promise; showDivider: boolean; - hideUnbind?: boolean; + showOnlyApply?: boolean; } -const ReportingRow = memo(({ sourceIdx, rule, device, onApply, onSync, showDivider, hideUnbind = false }: ReportingRowProps) => { - const [stateRule, setStateRule] = useState(rule); - const { t } = useTranslation(["zigbee", "common"]); +const ReportingRow = memo( + ({ sourceIdx, rule, bridgeDefinitions, device, onApply, onSync, showDivider, showOnlyApply = false }: ReportingRowProps) => { + const [stateRule, setStateRule] = useState(rule); + const [attrDefinition, setAttrDefinition] = useState(null); + const { t } = useTranslation(["zigbee", "common"]); - useEffect(() => { - setStateRule(rule); - }, [rule]); + useEffect(() => { + setStateRule(rule); - const onClusterChange = useCallback((cluster: string): void => { - setStateRule((prev) => ({ ...prev, cluster })); - }, []); + const clusterAttrs = getClusterAttributes(bridgeDefinitions, device.ieee_address, rule.cluster); - const onAttributeChange = useCallback((attribute: string): void => { - setStateRule((prev) => ({ ...prev, attribute })); - }, []); + setAttrDefinition(clusterAttrs[rule.attribute] ?? null); + }, [rule, bridgeDefinitions, device.ieee_address]); - const onReportNumberChange = useCallback((event: ChangeEvent): void => { - setStateRule((prev) => ({ - ...prev, - [event.target.name as "minimum_report_interval" | "maximum_report_interval" | "reportable_change"]: event.target.valueAsNumber, - })); - }, []); + const onClusterChange = useCallback((cluster: string): void => { + setStateRule((prev) => ({ ...prev, cluster })); + }, []); - const onDisableRuleClick = useCallback((): void => { - onApply({ ...stateRule, maximum_report_interval: 0xffff }); - }, [stateRule, onApply]); + const onAttributeChange = useCallback((attribute: string, definition: AttributeDefinition): void => { + setStateRule((prev) => ({ ...prev, attribute })); + setAttrDefinition(definition); + }, []); - const clusters = useMemo((): ClusterGroup[] => { - const possibleClusters = new Set(); - const availableClusters = new Set(); + const onReportNumberChange = useCallback((event: ChangeEvent): void => { + setStateRule((prev) => ({ + ...prev, + [event.target.name as "minimum_report_interval" | "maximum_report_interval" | "reportable_change"]: event.target.value + ? event.target.valueAsNumber + : "", + })); + }, []); - if (stateRule.cluster) { - availableClusters.add(stateRule.cluster); - } + const clusters = useMemo((): ClusterGroup[] => { + const possibleClusters = new Set(); + const availableClusters = new Set(); - const ep = device.endpoints[Number.parseInt(stateRule.endpoint, 10)]; - - if (ep) { - for (const outputCluster of ep.clusters.output) { - availableClusters.add(outputCluster); + if (stateRule.cluster) { + availableClusters.add(stateRule.cluster); } - for (const inputCluster of ep.clusters.input) { - if (!availableClusters.has(inputCluster)) { - possibleClusters.add(inputCluster); + const ep = device.endpoints[Number.parseInt(stateRule.endpoint, 10)]; + + if (ep) { + for (const outputCluster of ep.clusters.output) { + availableClusters.add(outputCluster); + } + + for (const inputCluster of ep.clusters.input) { + if (!availableClusters.has(inputCluster)) { + possibleClusters.add(inputCluster); + } } } - } - - return [ - { - name: "available", - clusters: availableClusters, - }, - { - name: "possible", - clusters: possibleClusters, - }, - ]; - }, [device.endpoints, stateRule.endpoint, stateRule.cluster]); - - const isValidRule = useMemo(() => isValidReportingRule(stateRule), [stateRule]); - - return ( - <> -
- $.cluster)} - disabled={!stateRule.endpoint} - clusters={clusters} - value={stateRule.cluster} - onChange={onClusterChange} - required - /> - $.attribute)} - disabled={!stateRule.cluster} - value={stateRule.attribute} - cluster={stateRule.cluster} - device={device} - onChange={onAttributeChange} - required - /> - $.min_rep_interval)} - type="number" - value={stateRule.minimum_report_interval ?? ""} - onChange={onReportNumberChange} - required - className="input validator w-48" - min={0} - max={0xffff} - /> - $.max_rep_interval)} - type="number" - value={stateRule.maximum_report_interval ?? ""} - onChange={onReportNumberChange} - required - className="input validator w-48" - min={0} - max={0xffff} - /> - $.min_rep_change)} - type="number" - value={stateRule.reportable_change ?? ""} - onChange={onReportNumberChange} - required - className="input validator w-48" - /> -
- {t(($) => $.actions)} -
- - title={t(($) => $.apply, { ns: "common" })} - className="btn btn-primary btn-outline join-item" - item={stateRule} - onClick={onApply} - disabled={!isValidRule} - > - - {t(($) => $.apply, { ns: "common" })} - - {onSync !== undefined && !stateRule.isNew ? ( - $.sync, { ns: "common" })} - modalDescription={t(($) => $.dialog_confirmation_prompt, { ns: "common" })} - modalCancelLabel={t(($) => $.cancel, { ns: "common" })} + + return [ + { + name: "available", + clusters: availableClusters, + }, + { + name: "possible", + clusters: possibleClusters, + }, + ]; + }, [device.endpoints, stateRule.endpoint, stateRule.cluster]); + + // default to consider analog if can't find attribute definition + const isAnalogAttribute = useMemo(() => attrDefinition == null || isAnalogDataType(attrDefinition), [attrDefinition]); + const isValidRule = useMemo(() => isValidReportingRule(stateRule), [stateRule]); + + return ( + <> +
+ $.cluster)} + disabled={!stateRule.endpoint || !stateRule.isNew} + clusters={clusters} + value={stateRule.cluster} + onChange={onClusterChange} + required + /> + $.attribute)} + disabled={!stateRule.cluster || !stateRule.isNew} + value={stateRule.attribute} + cluster={stateRule.cluster} + device={device} + onChange={onAttributeChange} + required + /> + $.min_rep_interval)} + type="number" + value={stateRule.minimum_report_interval ?? ""} + onChange={onReportNumberChange} + required + className="input validator w-48" + min={0} + max={0xffff} + /> + $.max_rep_interval)} + type="number" + value={stateRule.maximum_report_interval ?? ""} + onChange={onReportNumberChange} + required + className="input validator w-48" + min={0} + max={0xffff} + /> + {isAnalogAttribute ? ( + $.min_rep_change)} + type="number" + value={stateRule.reportable_change ?? ""} + onChange={onReportNumberChange} + className="input validator w-48" + required + /> + ) : ( +
+ {t(($) => $.min_rep_change)} +

N/A

+
+ )} +
+ {t(($) => $.actions)} +
+ + title={t(($) => $.apply, { ns: "common" })} + className="btn btn-square btn-primary btn-outline join-item" + item={stateRule} + onClick={onApply} + disabled={!isValidRule} > - - {t(($) => $.sync, { ns: "common" })} - - ) : null} - {!hideUnbind && !stateRule.isNew ? ( + + + {!showOnlyApply && onSync !== undefined && !stateRule.isNew ? ( + $.sync, { ns: "common" })} + modalDescription={t(($) => $.dialog_confirmation_prompt, { ns: "common" })} + modalCancelLabel={t(($) => $.cancel, { ns: "common" })} + > + + + ) : null} + {/* {!showOnlyApply && !stateRule.isNew ? ( - title={t(($) => $.disable, { ns: "common" })} - disabled={!isValidRule} - className="btn btn-error btn-outline join-item" - onClick={onDisableRuleClick} + title={t(($) => $.default, { ns: "common" })} + className="btn btn-square btn-warning btn-outline join-item" + onClick={async () => { + await onApply({ ...stateRule, minimum_report_interval: 0xffff, maximum_report_interval: 0x0000, reportable_change: 0 }); + }} modalDescription={t(($) => $.dialog_confirmation_prompt, { ns: "common" })} modalCancelLabel={t(($) => $.cancel, { ns: "common" })} > - - {t(($) => $.disable, { ns: "common" })} + - ) : null} -
-
-
- {showDivider ?
: null} - - ); -}); + ) : null} */} + {!showOnlyApply && !stateRule.isNew ? ( + + title={t(($) => $.disable, { ns: "common" })} + className="btn btn-square btn-error btn-outline join-item" + onClick={async () => { + await onApply({ ...stateRule, maximum_report_interval: 0xffff, reportable_change: 0 }); + }} + modalDescription={t(($) => $.dialog_confirmation_prompt, { ns: "common" })} + modalCancelLabel={t(($) => $.cancel, { ns: "common" })} + > + + + ) : null} +
+
+
+ {showDivider ?
: null} + + ); + }, +); export default ReportingRow; diff --git a/src/components/device-page/tabs/Reporting.tsx b/src/components/device-page/tabs/Reporting.tsx index fa187edb3..f281beee8 100644 --- a/src/components/device-page/tabs/Reporting.tsx +++ b/src/components/device-page/tabs/Reporting.tsx @@ -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 { @@ -18,12 +28,13 @@ interface ReportingEndpointSectionProps extends ReportingEndpoint { device: Device; sourceIdx: number; onApply(rule: ReportingRule): Promise; + 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); @@ -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} @@ -112,7 +124,14 @@ const ReportingEndpointSection = memo(({ endpointId, rules, device, sourceIdx, o
- + state.bridgeDefinitions[sourceIdx])); const reportingsByEndpoints = useMemo(() => aggregateReporting(device), [device]); const onApply = useCallback( async (rule: ReportingRule): Promise => { 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 (
{reportingsByEndpoints.map((reportings) => ( - + ))}
); diff --git a/src/components/modal/components/ReportingBatchEditModal.tsx b/src/components/modal/components/ReportingBatchEditModal.tsx index c1002e2ea..924ad261f 100644 --- a/src/components/modal/components/ReportingBatchEditModal.tsx +++ b/src/components/modal/components/ReportingBatchEditModal.tsx @@ -15,10 +15,10 @@ export const ReportingBatchEditModal = NiceModal.create(({ onApply }: ReportingB const { t } = useTranslation(["zigbee", "common", "devicePage"]); const [minRepInterval, setMinRepInterval] = useState(""); const [maxRepInterval, setMaxRepInterval] = useState(""); - const [repChange, setRepChange] = useState(""); + const [repChange, setRepChange] = useState(null); const handleApply = useCallback(async (): Promise => { - 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]); @@ -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 ( $.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" /> diff --git a/src/components/modal/components/ReportingRuleModal.tsx b/src/components/modal/components/ReportingRuleModal.tsx index 54d942237..bb69ee496 100644 --- a/src/components/modal/components/ReportingRuleModal.tsx +++ b/src/components/modal/components/ReportingRuleModal.tsx @@ -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"; @@ -11,45 +12,57 @@ type ReportingRuleModalProps = { sourceIdx: number; device: Device; rule: ReportingRule; - onApply(rule: ReportingRule): Promise; + onApply(sourceIdx: number, device: Device, rule: ReportingRule, isAnalogAttribute: boolean): Promise; + 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 => { - 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 => { + 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 ( - $.reporting, { ns: "devicePage" })}: ${device.friendly_name} (${rule.endpoint})`} - footer={ - - } - > - - - ); -}); + return ( + $.reporting, { ns: "devicePage" })}: ${device.friendly_name} (${rule.endpoint})`} + footer={ + + } + > + + + ); + }, +); diff --git a/src/components/pickers/AttributePicker.tsx b/src/components/pickers/AttributePicker.tsx index b89c834c1..bfe39b9bc 100644 --- a/src/components/pickers/AttributePicker.tsx +++ b/src/components/pickers/AttributePicker.tsx @@ -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, "onChange"> { sourceIdx: number; @@ -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[] = []; diff --git a/src/components/reporting/index.ts b/src/components/reporting/index.ts index bd47a7693..caac5c1cd 100644 --- a/src/components/reporting/index.ts +++ b/src/components/reporting/index.ts @@ -1,4 +1,6 @@ -import type { Device } from "../../types.js"; +import type { Zigbee2MQTTAPI } from "zigbee2mqtt"; +import type { AppState } from "../../store.js"; +import type { AttributeDefinition, ClusterDefinition, Device } from "../../types.js"; export type ReportingRule = { isNew?: string; @@ -10,6 +12,53 @@ export interface ReportingEndpoint { rules: ReportingRule[]; } +type BridgeDefinitions = Zigbee2MQTTAPI["bridge/definitions"]; + +export const isDiscreteOrCompositeDataType = (attrDefinition: AttributeDefinition): boolean => + (attrDefinition.type >= 0x08 && attrDefinition.type <= 0x1f) || + attrDefinition.type === 0x30 || + attrDefinition.type === 0x31 || + (attrDefinition.type >= 0x41 && attrDefinition.type <= 0x51) || + (attrDefinition.type >= 0xe8 && attrDefinition.type <= 0xf1); + +export const isAnalogDataType = (attrDefinition: AttributeDefinition): boolean => + (attrDefinition.type >= 0x20 && attrDefinition.type <= 0x2f) || + (attrDefinition.type >= 0x38 && attrDefinition.type <= 0x3a) || + (attrDefinition.type >= 0xe0 && attrDefinition.type <= 0xe2); + +export const getClusterAttributes = ( + bridgeDefinitions: AppState["bridgeDefinitions"][number], + deviceIeeeAddress: string, + clusterName: string, +): ClusterDefinition["attributes"] => { + const deviceCustomClusters: BridgeDefinitions["custom_clusters"][string] | undefined = bridgeDefinitions.custom_clusters[deviceIeeeAddress]; + + if (deviceCustomClusters) { + const customClusters = deviceCustomClusters[clusterName]; + + if (customClusters) { + return customClusters.attributes; + } + } + + const stdCluster: BridgeDefinitions["clusters"][keyof BridgeDefinitions["clusters"]] | undefined = bridgeDefinitions.clusters[clusterName]; + + if (stdCluster) { + return stdCluster.attributes; + } + + return {}; +}; + +export const getClusterAttribute = ( + bridgeDefinitions: AppState["bridgeDefinitions"][number], + deviceIeeeAddress: string, + clusterName: string, + attribute: string | number, +): ClusterDefinition["attributes"][string] | undefined => { + return getClusterAttributes(bridgeDefinitions, deviceIeeeAddress, clusterName)[attribute]; +}; + export const makeDefaultReporting = (ieeeAddress: string, endpoint: string): ReportingRule => ({ isNew: ieeeAddress, reportable_change: 0, @@ -37,23 +86,24 @@ export const aggregateReporting = (device: Device): ReportingEndpoint[] => { }; export const isValidReportingRuleEdit = ( - minRepInterval: number | undefined, - maxRepInterval: number | undefined, - repChange: number | undefined, + minRepInterval: number | undefined | null | "", + maxRepInterval: number | undefined | null | "", + repChange: number | undefined | null | "", ): boolean => { - if (minRepInterval === undefined || Number.isNaN(minRepInterval)) { + if (minRepInterval == null || minRepInterval === "" || Number.isNaN(minRepInterval)) { return false; } - if (maxRepInterval === undefined || Number.isNaN(maxRepInterval)) { + if (maxRepInterval == null || maxRepInterval === "" || Number.isNaN(maxRepInterval)) { return false; } - if (repChange === undefined || Number.isNaN(repChange)) { + if (repChange === "" || Number.isNaN(repChange)) { return false; } - if (minRepInterval > maxRepInterval) { + // can't be greater unless used to signal "default reporting configuration" + if (minRepInterval > maxRepInterval && !(maxRepInterval === 0x0000 && minRepInterval === 0xffff && repChange === 0)) { return false; } diff --git a/src/pages/ReportingPage.tsx b/src/pages/ReportingPage.tsx index 7d8953e49..a4945ba41 100644 --- a/src/pages/ReportingPage.tsx +++ b/src/pages/ReportingPage.tsx @@ -5,6 +5,7 @@ import type { ColumnDef, RowSelectionState } from "@tanstack/react-table"; import { type JSX, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import type { Zigbee2MQTTAPI } from "zigbee2mqtt"; import Button from "../components/Button.js"; import ConfirmButton from "../components/ConfirmButton.js"; import DeviceImage from "../components/device/DeviceImage.js"; @@ -15,24 +16,27 @@ import { ReportingRuleModal } from "../components/modal/components/ReportingRule import IndeterminateCheckbox from "../components/ota-page/IndeterminateCheckbox.js"; import DevicePicker from "../components/pickers/DevicePicker.js"; import EndpointPicker from "../components/pickers/EndpointPicker.js"; -import { makeDefaultReporting, type ReportingRule } from "../components/reporting/index.js"; +import { getClusterAttribute, isAnalogDataType, makeDefaultReporting, type ReportingRule } from "../components/reporting/index.js"; import SourceDot from "../components/SourceDot.js"; import Table from "../components/table/Table.js"; import TableSearch from "../components/table/TableSearch.js"; import { useTable } from "../hooks/useTable.js"; import { NavBarContent } from "../layout/NavBarContext.js"; -import { API_NAMES, API_URLS, MULTI_INSTANCE, useAppStore } from "../store.js"; +import { API_NAMES, API_URLS, type AppState, MULTI_INSTANCE, useAppStore } from "../store.js"; import type { Device } from "../types.js"; import { getEndpoints } from "../utils.js"; import { sendMessage } from "../websocket/WebSocketManager.js"; -type ReportingTableData = { +export type ReportingTableData = { sourceIdx: number; device: Device; rule: ReportingRule; + bridgeDefinitions: AppState["bridgeDefinitions"][number]; + isAnalogAttribute: boolean; }; export default function ReportingPage(): JSX.Element { + const allBridgeDefinitions = useAppStore((state) => state.bridgeDefinitions); const devices = useAppStore((state) => state.devices); const { t } = useTranslation(["zigbee", "common"]); const [newRuleSourceIdx, setNewRuleSourceIdx] = useState(0); @@ -50,15 +54,22 @@ export default function ReportingPage(): JSX.Element { const rows: ReportingTableData[] = []; for (let sourceIdx = 0; sourceIdx < API_URLS.length; sourceIdx++) { + const bridgeDefinitions = allBridgeDefinitions[sourceIdx]; + for (const device of devices[sourceIdx]) { for (const endpointId in device.endpoints) { const endpoint = device.endpoints[endpointId]; for (const reporting of endpoint.configured_reportings) { + const attrDef = getClusterAttribute(bridgeDefinitions, device.ieee_address, reporting.cluster, reporting.attribute); + const isAnalogAttribute = attrDef == null || isAnalogDataType(attrDef); + rows.push({ sourceIdx, device, rule: { ...reporting, endpoint: endpointId }, + bridgeDefinitions, + isAnalogAttribute, }); } } @@ -66,7 +77,7 @@ export default function ReportingPage(): JSX.Element { } return rows; - }, [devices]); + }, [devices, allBridgeDefinitions]); const rowSelectionCount = useMemo(() => Object.keys(rowSelection).length, [rowSelection]); @@ -82,20 +93,25 @@ export default function ReportingPage(): JSX.Element { for (const row of table.table.getFilteredRowModel().rows) { if (row.getIsSelected()) { - const { sourceIdx, device, rule } = row.original; + const { sourceIdx, device, rule, bridgeDefinitions } = row.original; + const attrDef = getClusterAttribute(bridgeDefinitions, device.ieee_address, rule.cluster, rule.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: rule.endpoint, + cluster: rule.cluster, + attribute: rule.attribute, + minimum_report_interval: minRepInterval === undefined ? rule.minimum_report_interval : minRepInterval, + maximum_report_interval: disable ? 0xffff : maxRepInterval === undefined ? rule.maximum_report_interval : maxRepInterval, + option: {}, // TODO: check this + }; + + if (isAnalogAttribute) { + payload.reportable_change = disable ? 0 : repChange === undefined ? rule.reportable_change : repChange; + } - promises.push( - sendMessage(sourceIdx, "bridge/request/device/reporting/configure", { - id: device.ieee_address, - endpoint: rule.endpoint, - cluster: rule.cluster, - attribute: rule.attribute, - minimum_report_interval: minRepInterval ?? rule.minimum_report_interval, - maximum_report_interval: disable ? 0xffff : (maxRepInterval ?? rule.maximum_report_interval), - reportable_change: repChange ?? rule.reportable_change, - option: {}, - }), - ); + promises.push(sendMessage(sourceIdx, "bridge/request/device/reporting/configure", payload)); } } @@ -118,12 +134,23 @@ export default function ReportingPage(): JSX.Element { const availableEndpoints = useMemo(() => getEndpoints(newRuleDevice), [newRuleDevice]); - const applyRule = useCallback(async (sourceIdx: number, device: Device, rule: ReportingRule): Promise => { - await sendMessage(sourceIdx, "bridge/request/device/reporting/configure", { + const applyRule = useCallback(async (sourceIdx: number, device: Device, rule: ReportingRule, isAnalogAttribute: boolean): Promise => { + const { cluster, endpoint, attribute, minimum_report_interval, maximum_report_interval, reportable_change } = rule; + const payload: Zigbee2MQTTAPI["bridge/request/device/reporting/configure"] = { id: device.ieee_address, - ...rule, - option: {}, - }); + endpoint, + cluster, + attribute, + minimum_report_interval, + maximum_report_interval, + option: {}, // TODO: check this + }; + + if (isAnalogAttribute) { + payload.reportable_change = reportable_change; + } + + await sendMessage(sourceIdx, "bridge/request/device/reporting/configure", payload); }, []); const applyNewRule = useCallback( @@ -132,12 +159,16 @@ export default function ReportingPage(): JSX.Element { return; } - await applyRule(newRuleSourceIdx, newRuleDevice, rule); + const attrDef = getClusterAttribute(allBridgeDefinitions[newRuleSourceIdx], newRuleDevice.ieee_address, rule.cluster, rule.attribute); + // default to consider analog if can't find attribute definition + const isAnalogAttribute = attrDef == null || isAnalogDataType(attrDef); + + await applyRule(newRuleSourceIdx, newRuleDevice, rule, isAnalogAttribute); setNewRuleSourceIdx(0); setNewRuleDevice(null); setNewRuleEndpoint(""); }, - [applyRule, newRuleSourceIdx, newRuleDevice], + [applyRule, newRuleSourceIdx, newRuleDevice, allBridgeDefinitions], ); const onRowSync = useCallback(async ([sourceIdx, id, endpoint, cluster, attribute]: [number, string, number, string, string]) => { @@ -279,7 +310,7 @@ export default function ReportingPage(): JSX.Element { id: "min_rep_change", size: 120, header: t(($) => $.min_rep_change), - accessorFn: ({ rule }) => rule.reportable_change, + accessorFn: ({ rule, isAnalogAttribute }) => (isAnalogAttribute ? rule.reportable_change : "N/A"), filterFn: "inNumberRange", meta: { filterVariant: "range" }, }, @@ -288,7 +319,7 @@ export default function ReportingPage(): JSX.Element { size: 110, cell: ({ row: { - original: { sourceIdx, device, rule }, + original: { sourceIdx, device, rule, bridgeDefinitions, isAnalogAttribute }, }, }) => (
@@ -300,7 +331,9 @@ export default function ReportingPage(): JSX.Element { sourceIdx, device, rule, - onApply: async (updatedRule) => await applyRule(sourceIdx, device, updatedRule), + bridgeDefinitions, + isAnalogAttribute, + onApply: applyRule, }) } > @@ -316,10 +349,33 @@ export default function ReportingPage(): JSX.Element { > + {/* + title={t(($) => $.default, { ns: "common" })} + className="btn btn-sm btn-square btn-warning btn-outline join-item" + onClick={async () => + await applyRule(sourceIdx, device, { + ...rule, + minimum_report_interval: 0xffff, + maximum_report_interval: 0x0000, + reportable_change: 0, + }) + } + modalDescription={t(($) => $.dialog_confirmation_prompt, { ns: "common" })} + modalCancelLabel={t(($) => $.cancel, { ns: "common" })} + > + + */} title={t(($) => $.disable, { ns: "common" })} className="btn btn-sm btn-square btn-error btn-outline join-item" - onClick={async () => await applyRule(sourceIdx, device, { ...rule, maximum_report_interval: 0xffff })} + onClick={async () => + await applyRule( + sourceIdx, + device, + { ...rule, maximum_report_interval: 0xffff, reportable_change: 0 }, + isAnalogAttribute, + ) + } modalDescription={t(($) => $.dialog_confirmation_prompt, { ns: "common" })} modalCancelLabel={t(($) => $.cancel, { ns: "common" })} > @@ -400,8 +456,9 @@ export default function ReportingPage(): JSX.Element { {newRuleDevice && newRuleDraft ? ( @@ -422,6 +479,17 @@ export default function ReportingPage(): JSX.Element { > {`${t(($) => $.edit_selected, { ns: "common" })} (${rowSelectionCount})`} + {/* $.reset_selected, { ns: "common" })} + disabled={rowSelectionCount === 0} + modalDescription={t(($) => $.dialog_confirmation_prompt, { ns: "common" })} + modalCancelLabel={t(($) => $.cancel, { ns: "common" })} + > + {`${t(($) => $.reset_selected, { ns: "common" })} (${rowSelectionCount})`} + */}