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
917 changes: 765 additions & 152 deletions package-lock.json

Large diffs are not rendered by default.

19 changes: 10 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zigbee2mqtt-windfront",
"version": "2.4.3",
"version": "2.5.0",
"license": "GPL-3.0-or-later",
"type": "module",
"main": "./index.js",
Expand Down Expand Up @@ -43,10 +43,10 @@
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.1",
"@storybook/addon-a11y": "^10.1.5",
"@storybook/addon-docs": "^10.1.5",
"@storybook/react-vite": "^10.1.5",
"@tailwindcss/vite": "^4.1.17",
"@storybook/addon-a11y": "^10.1.7",
"@storybook/addon-docs": "^10.1.7",
"@storybook/react-vite": "^10.1.7",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-table": "^8.21.3",
"@types/file-saver": "^2.0.7",
"@types/json-schema": "^7.0.15",
Expand All @@ -57,7 +57,7 @@
"@virtuoso.dev/masonry": "^1.3.5",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.15",
"daisyui": "^5.5.8",
"daisyui": "^5.5.11",
"file-saver": "^2.0.5",
"i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0",
Expand All @@ -67,19 +67,20 @@
"react": "^19.2.1",
"react-app-polyfill": "^3.0.0",
"react-dom": "^19.2.1",
"react-i18next": "^16.4.0",
"react-i18next": "^16.4.1",
"react-image": "^4.1.0",
"react-router": "^7.10.1",
"react-select": "^5.10.2",
"react-virtuoso": "^4.17.0",
"reagraph": "^4.30.7",
"store2": "^2.14.4",
"storybook": "^10.1.5",
"storybook": "^10.1.7",
"tailwindcss": "^4.1.4",
"timeago.js": "^4.0.2",
"typescript": "^5.9.3",
"vite": "^7.2.7",
"vite-plugin-compression2": "^2.4.0",
"vitest": "^4.0.14",
"vitest": "^4.0.15",
"ws": "^8.18.3",
"zigbee2mqtt": "github:Koenkk/zigbee2mqtt#dev-types",
"zustand": "^5.0.9"
Expand Down
4 changes: 2 additions & 2 deletions src/components/device-page/AddToGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ const AddToGroup = memo(({ sourceIdx, device, nonMemberGroups }: AddToGroupProps
return (
<>
<h2 className="text-lg font-semibold">{t(($) => $.add_to_group)}</h2>
<div className="mb-3">
<GroupPicker label={t(($) => $.group, { ns: "zigbee" })} value={groupId} groups={nonMemberGroups} onChange={onGroupChange} required />
<div className="flex flex-row flex-wrap mb-3 gap-3">
<GroupPicker label={t(($) => $.group, { ns: "zigbee" })} groups={nonMemberGroups} onChange={onGroupChange} required />
<EndpointPicker
label={t(($) => $.endpoint, { ns: "zigbee" })}
values={endpoints}
Expand Down
50 changes: 45 additions & 5 deletions src/components/device-page/AttributeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type ChangeEvent, type JSX, memo, useCallback, useMemo, useState } 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, LogMessage } from "../../types.js";
import { getEndpoints, getObjectFirstKey } from "../../utils.js";
import Button from "../Button.js";
Expand All @@ -11,6 +13,7 @@ import InputField from "../form-fields/InputField.js";
import AttributePicker from "../pickers/AttributePicker.js";
import ClusterSinglePicker from "../pickers/ClusterSinglePicker.js";
import EndpointPicker from "../pickers/EndpointPicker.js";
import type { ClusterGroup } from "../pickers/index.js";
import LastLogResult from "./LastLogResult.js";

export interface AttributeEditorProps {
Expand Down Expand Up @@ -55,6 +58,7 @@ function AttributeValueInput({ value, onChange, attribute, definition, ...rest }
}

const AttributeEditor = memo(({ sourceIdx, device, read, write, readReporting, lastLog }: AttributeEditorProps) => {
const bridgeDefinitions = useAppStore(useShallow((state) => state.bridgeDefinitions[sourceIdx]));
const [endpoint, setEndpoint] = useState(getObjectFirstKey(device.endpoints) ?? "");
const [cluster, setCluster] = useState("");
const [attributes, setAttributes] = useState<AttributeInfo[]>([]);
Expand Down Expand Up @@ -149,21 +153,57 @@ const AttributeEditor = memo(({ sourceIdx, device, read, write, readReporting, l
),
[attributes],
);
const availableClusters = useMemo(() => {
const clusters = new Set<string>();
const availableClusters = useMemo((): ClusterGroup[] => {
const deviceInputs = new Set<string>();
const deviceCustoms = new Set<string>();
const otherZcls = new Set<string>();

if (endpoint) {
const deviceEndpoint = device.endpoints[Number.parseInt(endpoint, 10)];
const uniqueClusters = new Set<string>();

const customClusters = bridgeDefinitions.custom_clusters[device.ieee_address];

if (customClusters) {
for (const key in bridgeDefinitions.custom_clusters[device.ieee_address]) {
if (!uniqueClusters.has(key)) {
uniqueClusters.add(key);
deviceCustoms.add(key);
}
}
}

if (deviceEndpoint) {
for (const inCluster of deviceEndpoint.clusters.input) {
clusters.add(inCluster);
if (!uniqueClusters.has(inCluster)) {
uniqueClusters.add(inCluster);
deviceInputs.add(inCluster);
}
}
}

for (const key in bridgeDefinitions.clusters) {
if (!uniqueClusters.has(key)) {
otherZcls.add(key);
}
}
}

return clusters;
}, [device, endpoint]);
return [
{
name: "custom_clusters",
clusters: deviceCustoms,
},
{
name: "input_clusters",
clusters: deviceInputs,
},
{
name: "other_zcl_clusters",
clusters: otherZcls,
},
];
}, [device, endpoint, bridgeDefinitions]);

const disableButtons = attributes.length === 0 || cluster === "";
const endpoints = useMemo(() => getEndpoints(device), [device]);
Expand Down
42 changes: 23 additions & 19 deletions src/components/device-page/CommandExecutor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,36 +55,44 @@ const CommandExecutor = memo(({ sourceIdx, device, lastLog }: CommandExecutorPro
const deviceCustoms = new Set<string>();
const otherZcls = new Set<string>();

if (deviceEndpoint) {
for (const inCluster of deviceEndpoint.clusters.input) {
uniqueClusters.add(inCluster);
deviceInputs.add(inCluster);
}

for (const outCluster of deviceEndpoint.clusters.output) {
uniqueClusters.add(outCluster);
deviceOutputs.add(outCluster);
}
}

const customClusters = bridgeDefinitions.custom_clusters[device.friendly_name];
const customClusters = bridgeDefinitions.custom_clusters[device.ieee_address];

if (customClusters) {
for (const key in bridgeDefinitions.custom_clusters[device.friendly_name]) {
for (const key in bridgeDefinitions.custom_clusters[device.ieee_address]) {
if (!uniqueClusters.has(key)) {
uniqueClusters.add(key);
deviceCustoms.add(key);
}
}
}

if (deviceEndpoint) {
for (const inCluster of deviceEndpoint.clusters.input) {
if (!uniqueClusters.has(inCluster)) {
uniqueClusters.add(inCluster);
deviceInputs.add(inCluster);
}
}

for (const outCluster of deviceEndpoint.clusters.output) {
if (!uniqueClusters.has(outCluster)) {
uniqueClusters.add(outCluster);
deviceOutputs.add(outCluster);
}
}
}

for (const key in bridgeDefinitions.clusters) {
if (!uniqueClusters.has(key)) {
otherZcls.add(key);
}
}

return [
{
name: "custom_clusters",
clusters: deviceCustoms,
},
{
name: "input_clusters",
clusters: deviceInputs,
Expand All @@ -93,16 +101,12 @@ const CommandExecutor = memo(({ sourceIdx, device, lastLog }: CommandExecutorPro
name: "output_clusters",
clusters: deviceOutputs,
},
{
name: "custom_clusters",
clusters: deviceCustoms,
},
{
name: "other_zcl_clusters",
clusters: otherZcls,
},
];
}, [device.friendly_name, device.endpoints, endpoint, bridgeDefinitions]);
}, [device.ieee_address, device.endpoints, endpoint, bridgeDefinitions]);

const onExecute = useCallback(async () => {
let commandKey: string | number = Number.parseInt(command, 10);
Expand Down
86 changes: 50 additions & 36 deletions src/components/device-page/HeaderDeviceSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type JSX, memo, useMemo } from "react";
import { memo, type ReactNode, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { useSearch } from "../../hooks/useSearch.js";
import { useNavigate } from "react-router";
import Select, { type SingleValue } from "react-select";
import { REACT_SELECT_DEFAULT_CLASSNAMES } from "../../consts.js";
import type { TabName } from "../../pages/DevicePage.js";
import { API_URLS, useAppStore } from "../../store.js";
import type { Device } from "../../types.js";
import DialogDropdown from "../DialogDropdown.js";
import DebouncedInput from "../form-fields/DebouncedInput.js";
import type { BaseSelectOption, Device } from "../../types.js";
import SourceDot from "../SourceDot.js";

interface HeaderDeviceSelectorProps {
Expand All @@ -17,54 +14,71 @@ interface HeaderDeviceSelectorProps {
tab?: TabName;
}

interface SelectOption extends BaseSelectOption<ReactNode> {
name: string;
link: string;
}

const HeaderDeviceSelector = memo(({ currentSourceIdx, currentDevice, tab = "info" }: HeaderDeviceSelectorProps) => {
const [searchTerm, normalizedSearchTerm, setSearchTerm] = useSearch();
const { t } = useTranslation("common");
const devices = useAppStore((state) => state.devices);
const navigate = useNavigate();

const onSelectHandler = useCallback(
(option: SingleValue<SelectOption>) => {
if (option) {
navigate(option.link);
}
},
[navigate],
);

const items = useMemo(() => {
const elements: JSX.Element[] = [];
const options = useMemo(() => {
const elements: SelectOption[] = [];

for (let sourceIdx = 0; sourceIdx < API_URLS.length; sourceIdx++) {
for (const device of devices[sourceIdx]) {
if (device.type === "Coordinator" || (sourceIdx === currentSourceIdx && device.ieee_address === currentDevice?.ieee_address)) {
continue;
}

if (normalizedSearchTerm.length > 0 && !device.friendly_name.toLowerCase().includes(normalizedSearchTerm)) {
continue;
}

elements.push(
<li key={`${device.friendly_name}-${device.ieee_address}-${sourceIdx}`}>
<Link to={`/device/${sourceIdx}/${device.ieee_address}/${tab}`} onClick={() => setSearchTerm("")} className="dropdown-item">
elements.push({
value: device.friendly_name,
label: (
<>
<SourceDot idx={sourceIdx} autoHide namePostfix=" – " /> {device.friendly_name}
</Link>
</li>,
);
</>
),
name: `${sourceIdx} ${device.friendly_name}`,
link: `/device/${sourceIdx}/${device.ieee_address}/${tab}`,
});
}
}

elements.sort((elA, elB) => elA.key!.localeCompare(elB.key!));
elements.sort((elA, elB) => elA.name.localeCompare(elB.name));

return elements;
}, [devices, normalizedSearchTerm, currentSourceIdx, currentDevice, tab, setSearchTerm]);
}, [devices, currentSourceIdx, currentDevice, tab]);

return (
<DialogDropdown
buttonChildren={
<>
{currentSourceIdx !== undefined && <SourceDot idx={currentSourceIdx} autoHide />}
{currentDevice ? currentDevice.friendly_name : t(($) => $.unknown_device)}
</>
<Select
unstyled
placeholder={
currentDevice && currentSourceIdx !== undefined ? (
<>
<SourceDot idx={currentSourceIdx} autoHide namePostfix=" – " /> {currentDevice.friendly_name}
</>
) : (
t(($) => $.select_device)
)
}
>
<label className="input min-h-10" key="search">
<FontAwesomeIcon icon={faMagnifyingGlass} />
<DebouncedInput onChange={setSearchTerm} placeholder={t(($) => $.type_to_filter)} value={searchTerm} />
</label>
{items}
</DialogDropdown>
aria-label={t(($) => $.select_device)}
options={options}
isSearchable
onChange={onSelectHandler}
className="min-w-48 sm:w-auto me-2"
classNames={REACT_SELECT_DEFAULT_CLASSNAMES}
/>
);
});

Expand Down
9 changes: 3 additions & 6 deletions src/components/device-page/RecallRemove.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,10 @@ interface RecallRemoveProps {
const RecallRemove = memo(({ sourceIdx, target }: RecallRemoveProps) => {
const { t } = useTranslation(["scene", "common"]);
const [scene, setScene] = useState<Scene>({ id: 0, name: "Scene 0" });
const [sceneIsNotSelected, setsceneIsNotSelected] = useState<boolean>(true);
const scenes = useMemo(() => getScenes(target), [target]);

const onSceneSelected = useCallback(
(sceneId: number) => {
setsceneIsNotSelected(false);

const foundScene = scenes.find((s) => s.id === sceneId);

if (foundScene !== undefined) {
Expand Down Expand Up @@ -70,14 +67,14 @@ const RecallRemove = memo(({ sourceIdx, target }: RecallRemoveProps) => {
<>
<h2 className="text-lg font-semibold">{t(($) => $.manage_scenes_header)}</h2>
<div className="mb-3">
<ScenePicker onSceneSelected={onSceneSelected} value={sceneIsNotSelected ? undefined : scene} scenes={scenes} />
<ScenePicker onSceneSelected={onSceneSelected} scenes={scenes} />
</div>
<div className="join join-horizontal w-full">
<Button disabled={sceneIsNotSelected} onClick={onRecallClick} className="btn btn-success join-item flex-1">
<Button disabled={!scene} onClick={onRecallClick} className="btn btn-success join-item flex-1">
{t(($) => $.recall)}
</Button>
<ConfirmButton
disabled={sceneIsNotSelected}
disabled={!scene}
onClick={onRemoveClick}
className="btn btn-error join-item flex-1"
title={t(($) => $.remove, { ns: "common" })}
Expand Down
Loading