Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,6 @@ dist

*storybook.log
storybook-static

# JetBrains IDEs
.idea
107 changes: 0 additions & 107 deletions src/components/device-page/AddScene.tsx

This file was deleted.

170 changes: 170 additions & 0 deletions src/components/device-page/AddUpdateScene.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { type ChangeEvent, useCallback, useEffect, useEffectEvent, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Zigbee2MQTTAPI } from "zigbee2mqtt";
import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "../../store";
import type { Device, DeviceState, Group, Scene } from "../../types";
import { isDevice } from "../../utils";
import { sendMessage } from "../../websocket/WebSocketManager";
import Button from "../Button";
import ConfirmButton from "../ConfirmButton";
import DashboardFeatureWrapper from "../dashboard-page/DashboardFeatureWrapper";
import { getFeatureKey } from "../features";
import Feature from "../features/Feature";
import InputField from "../form-fields/InputField";
import { getScenes } from "./index";

type AddUpdateSceneAction = "add" | "update";

type SceneInput = {
action: AddUpdateSceneAction;
} & Scene;

const DEFAULT_SCENE_ID = 0;
const DEFAULT_SCENE_INPUT: SceneInput = { id: DEFAULT_SCENE_ID, name: "", action: "add" };

const createSceneInput = (scenes: Scene[], sceneId: number): SceneInput => {
const existingScene = scenes.find((scene) => scene.id === sceneId);

if (existingScene) {
return {
id: sceneId,
name: existingScene.name,
action: "update",
};
}
return {
...DEFAULT_SCENE_INPUT,
id: sceneId,
};
};

type AddSceneProps = {
sourceIdx: number;
target: Device | Group;
deviceState: DeviceState;
};

const AddUpdateScene = ({ sourceIdx, target, deviceState }: AddSceneProps) => {
const { t } = useTranslation("scene");

const scenes = useMemo(() => getScenes(target), [target]);
const [sceneInput, setSceneInput] = useState<SceneInput>(() => createSceneInput(scenes, DEFAULT_SCENE_ID));

const onScenesChange = useEffectEvent((scenes: Scene[]) => {
setSceneInput(createSceneInput(scenes, sceneInput.id));
});

useEffect(() => {
onScenesChange(scenes);
}, [scenes]);

useEffect(() => {
if (scenes.length === 0) {
setSceneInput(DEFAULT_SCENE_INPUT);
}
}, [scenes.length]);

const scenesFeatures = useAppStore(
useShallow((state) => (isDevice(target) ? (state.deviceScenesFeatures[sourceIdx]?.[target.ieee_address] ?? []) : [])),
);

const onCompositeChange = useCallback(
async (value: Record<string, unknown> | unknown) => {
await sendMessage<"{friendlyNameOrId}/set">(
sourceIdx,
// @ts-expect-error templated API endpoint
`${target.friendly_name}/set`, // TODO: swap to ID/ieee_address
value,
);
},
[sourceIdx, target],
);

const onStoreClick = useCallback(async () => {
setSceneInput({ ...sceneInput, action: "update" });
const payload: Zigbee2MQTTAPI["{friendlyNameOrId}/set"][string] = {
ID: sceneInput.id,
name: sceneInput.name || `Scene ${sceneInput.id}`,
};

await sendMessage<"{friendlyNameOrId}/set">(
sourceIdx,
// @ts-expect-error templated API endpoint
`${target.friendly_name}/set`, // TODO: swap to ID/ieee_address
{ scene_store: payload },
);
}, [sourceIdx, target, sceneInput]);

const handleOnSceneIdInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const sceneId = e.target.valueAsNumber || DEFAULT_SCENE_ID;
setSceneInput(createSceneInput(scenes, sceneId));
};

const isValidSceneId = useMemo(() => {
return sceneInput.id >= 0 && sceneInput.id <= 255;
}, [sceneInput.id]);

return (
<>
<h2 className="text-lg font-semibold">{t(($) => $.add_update_header)}</h2>
<div className="mb-3">
<InputField
name="scene_id"
label={t(($) => $.scene_id)}
type="number"
value={sceneInput.id}
onChange={handleOnSceneIdInputChange}
min={0}
max={255}
required
/>
<InputField
name="scene_name"
label={t(($) => $.scene_name)}
type="text"
value={sceneInput.name}
placeholder={`Scene ${sceneInput.id}`}
onChange={(e) => setSceneInput({ ...sceneInput, name: e.target.value })}
required
/>
{scenesFeatures.length > 0 && (
<div className="card card-border bg-base-100 shadow my-2">
<div className="card-body p-4">
{scenesFeatures.map((feature) => (
<Feature
key={getFeatureKey(feature)}
feature={feature}
device={target as Device /* no feature for groups */}
deviceState={deviceState}
onChange={onCompositeChange}
featureWrapperClass={DashboardFeatureWrapper}
minimal={true}
parentFeatures={[]}
/>
))}
</div>
</div>
)}
</div>
{sceneInput.action === "add" ? (
<Button disabled={!isValidSceneId} onClick={onStoreClick} className="btn btn-primary tooltip" data-tip={t(($) => $.add_scene)}>
{t(($) => $.add, { ns: "common" })}
</Button>
) : (
<ConfirmButton
disabled={!isValidSceneId}
onClick={onStoreClick}
className="btn btn-primary"
title={t(($) => $.update_scene)}
modalDescription={t(($) => $.dialog_confirmation_prompt, { ns: "common" })}
modalCancelLabel={t(($) => $.cancel, { ns: "common" })}
>
{t(($) => $.update)}
</ConfirmButton>
)}
</>
);
};

export default AddUpdateScene;
4 changes: 2 additions & 2 deletions src/components/device-page/tabs/Scene.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "../../../store.js";
import type { Device } from "../../../types.js";
import AddScene from "../AddScene.js";
import AddUpdateScene from "../AddUpdateScene";
import RecallRemove from "../RecallRemove.js";

type SceneProps = {
Expand All @@ -16,7 +16,7 @@ export default function Scene({ sourceIdx, device }: SceneProps) {
<div className="flex flex-row flex-wrap gap-4 w-full">
<div className="card card-border bg-base-200 border-base-300 rounded-box shadow-md flex-1">
<div className="card-body p-4">
<AddScene sourceIdx={sourceIdx} target={device} deviceState={deviceState ?? {}} />
<AddUpdateScene sourceIdx={sourceIdx} target={device} deviceState={deviceState ?? {}} />
</div>
</div>
<div className="card card-border bg-base-200 border-base-300 rounded-box shadow-md flex-1">
Expand Down
4 changes: 2 additions & 2 deletions src/components/group-page/tabs/Devices.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "../../../store.js";
import type { Group } from "../../../types.js";
import AddScene from "../../device-page/AddScene.js";
import AddUpdateScene from "../../device-page/AddUpdateScene";
import RecallRemove from "../../device-page/RecallRemove.js";
import AddDeviceToGroup from "../AddDeviceToGroup.js";
import GroupMembers from "../GroupMembers.js";
Expand Down Expand Up @@ -29,7 +29,7 @@ export default function Devices({ sourceIdx, group }: DevicesProps) {
</div>
<div className="card card-border bg-base-200 border-base-300 rounded-box shadow-md flex-1">
<div className="card-body p-4">
<AddScene sourceIdx={sourceIdx} target={group} deviceState={{}} />
<AddUpdateScene sourceIdx={sourceIdx} target={group} deviceState={{}} />
</div>
</div>
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,12 @@
"scene_id": "Scene ID",
"recall": "Recall",
"store": "Store",
"update": "Update",
"remove_all": "Remove all",
"select_scene": "Select Scene",
"scene_name": "Scene Name"
"scene_name": "Scene Name",
"update_scene": "Update Scene",
"add_scene": "Add Scene"
},
"stats": {
"byType": "By device type",
Expand Down