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
3 changes: 2 additions & 1 deletion src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
selectTheme,
} from "./store/slices/settingsSlice";
import { loadDeviceOrder, pollAllDeviceStatuses, selectAllDevices } from "./store/slices/deviceSlice"; // Added pollAllDeviceStatuses
import { loadSceneOrder } from "./store/slices/sceneSlice";
import { loadNightLightSceneAssignments, loadSceneOrder } from "./store/slices/sceneSlice";
import { useTranslation } from "./useTranslation";

type View = "list" | "detail" | "settings" | "scenes";
Expand Down Expand Up @@ -58,6 +58,7 @@ function App() {
dispatch(loadApiCredentials());
dispatch(loadDeviceOrder());
dispatch(loadSceneOrder());
dispatch(loadNightLightSceneAssignments());
}, [dispatch]);

// Restore the last opened top-level view on startup.
Expand Down
142 changes: 141 additions & 1 deletion src/renderer/src/components/DeviceControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ import {
sendDeviceCommand
} from "../store/slices/deviceSlice";
import { selectIsTokenValidated } from "../store/slices/settingsSlice";
import {
executeScene,
fetchScenes,
selectNightLightSceneForDevice,
selectSceneExecutionError,
selectSceneIsExecuting,
selectScenes,
selectScenesError,
selectScenesLoading,
setNightLightSceneForDevice
} from "../store/slices/sceneSlice";
import { DeviceCommandDefinition, ParameterSpec, findDeviceDefinition } from "../deviceDefinitions";
import { useTranslation } from "../useTranslation";

Expand Down Expand Up @@ -67,6 +78,20 @@ export const DeviceControls: React.FC<DeviceControlsProps> = ({ device, status,
const [acFanSpeed, setAcFanSpeed] = useState("3"); // 3: medium
const [acPower, setAcPower] = useState<"on" | "off">("on");
const [tvChannel, setTvChannel] = useState("");
const scenes = useSelector(selectScenes);
const scenesLoading = useSelector(selectScenesLoading);
const scenesError = useSelector(selectScenesError);
const assignedNightLightSceneId = useSelector((state: RootState) =>
selectNightLightSceneForDevice(state, device.deviceId)
);
const nightLightExecuting = useSelector((state: RootState) =>
assignedNightLightSceneId ? selectSceneIsExecuting(state, assignedNightLightSceneId) : false
);
const nightLightExecutionError = useSelector((state: RootState) =>
assignedNightLightSceneId ? selectSceneExecutionError(state, assignedNightLightSceneId) : null
);
Comment on lines +84 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These consecutive useSelector hooks, where some depend on the output of others from the previous render, can lead to stale data and unnecessary re-renders. It's a best practice to derive all related state within a single useSelector hook. This ensures data consistency for a single render and can be more performant, especially when providing a custom equality check to prevent re-renders if the derived data hasn't functionally changed.

Suggested change
const assignedNightLightSceneId = useSelector((state: RootState) =>
selectNightLightSceneForDevice(state, device.deviceId)
);
const nightLightExecuting = useSelector((state: RootState) =>
assignedNightLightSceneId ? selectSceneIsExecuting(state, assignedNightLightSceneId) : false
);
const nightLightExecutionError = useSelector((state: RootState) =>
assignedNightLightSceneId ? selectSceneExecutionError(state, assignedNightLightSceneId) : null
);
const { assignedNightLightSceneId, nightLightExecuting, nightLightExecutionError } = useSelector((state: RootState) => {
const sceneId = selectNightLightSceneForDevice(state, device.deviceId);
return {
assignedNightLightSceneId: sceneId,
nightLightExecuting: sceneId ? selectSceneIsExecuting(state, sceneId) : false,
nightLightExecutionError: sceneId ? selectSceneExecutionError(state, sceneId) : null
};
}, (left, right) =>
left.assignedNightLightSceneId === right.assignedNightLightSceneId &&
left.nightLightExecuting === right.nightLightExecuting &&
left.nightLightExecutionError === right.nightLightExecutionError
);

const [nightLightSceneSelection, setNightLightSceneSelection] = useState<string>(assignedNightLightSceneId || "");
const [requestedScenes, setRequestedScenes] = useState(false);

const definition = useMemo(() => findDeviceDefinition(device.deviceType), [device.deviceType]);
const isHiddenByDefinition = definition?.hide;
Expand Down Expand Up @@ -100,6 +125,19 @@ export const DeviceControls: React.FC<DeviceControlsProps> = ({ device, status,
const supportsLightColorTemperature = isColorBulb || isStripLight3 || isFloorLamp || isCeilingLight;
const supportsLightColor = isColorBulb || isStripLight || isStripLight3 || isFloorLamp;
const supportsLockDeadbolt = (isLock && !isLockLite) || isLockPro || isLockUltra;
const controlsDisabled = isSending || !isTokenValid;
const nightLightScene = useMemo(
() => scenes.find((scene) => scene.sceneId === assignedNightLightSceneId) || null,
[assignedNightLightSceneId, scenes]
);
const hasScenes = scenes.length > 0;
const nightLightSelectionChanged = nightLightSceneSelection !== (assignedNightLightSceneId || "");
const nightLightButtonDisabled = controlsDisabled || !isTokenValid || !nightLightScene || nightLightExecuting;
const nightLightSaveDisabled =
!isTokenValid ||
scenesLoading ||
(!hasScenes && !assignedNightLightSceneId && nightLightSceneSelection === "") ||
!nightLightSelectionChanged;
const remoteTypeLabel = (device as any).remoteType || device.deviceType;
const remoteTypeLower = (remoteTypeLabel || "").toLowerCase();
const remoteSupportsDefaultCommands = remoteTypeLower !== "others";
Expand All @@ -110,7 +148,6 @@ export const DeviceControls: React.FC<DeviceControlsProps> = ({ device, status,
const isRemoteLight = isInfraredRemote && remoteTypeLower.includes("light");

const hasPredefinedControls = isInfraredRemote || isBot || isPlug || isCurtain || isLock || isLight || isHumidifier || isFan || isVacuum;
const controlsDisabled = isSending || !isTokenValid;
const botModeRaw = isBot ? status?.deviceMode || (device as any)?.deviceMode : "";
const botModeNormalized = typeof botModeRaw === "string" ? botModeRaw.toLowerCase() : "";
const botModeType: "switch" | "press" | "customize" | "unknown" = botModeNormalized.includes("switch")
Expand Down Expand Up @@ -159,6 +196,24 @@ export const DeviceControls: React.FC<DeviceControlsProps> = ({ device, status,
sendCommand("setMode", modeMap[mode]);
};

const handleExecuteNightLightScene = () => {
if (nightLightScene) {
dispatch(executeScene(nightLightScene.sceneId));
}
};

const handleSaveNightLightScene = () => {
dispatch(setNightLightSceneForDevice({
deviceId: device.deviceId,
sceneId: nightLightSceneSelection || null
}));
};

const handleRefreshScenes = () => {
setRequestedScenes(true);
dispatch(fetchScenes());
};

const sliderLabel = (value: number) => `${value}`;

const sectionLabelSx = { fontWeight: 700, color: "text.secondary", mt: dense ? 0.25 : 1 };
Expand Down Expand Up @@ -209,6 +264,17 @@ export const DeviceControls: React.FC<DeviceControlsProps> = ({ device, status,
setDynamicParams(defaults);
}, [definition, device.deviceId]);

useEffect(() => {
setNightLightSceneSelection(assignedNightLightSceneId || "");
}, [assignedNightLightSceneId]);

useEffect(() => {
if (!isCeilingLight || !isTokenValid) return;
if (hasScenes || scenesLoading || requestedScenes) return;
setRequestedScenes(true);
dispatch(fetchScenes());
}, [dispatch, hasScenes, isCeilingLight, isTokenValid, requestedScenes, scenesLoading]);

const resolveParameterValue = (cmd: DeviceCommandDefinition) => {
const spec = cmd.parameter;
if (!spec || spec.type === "none") return "default";
Expand Down Expand Up @@ -673,7 +739,37 @@ export const DeviceControls: React.FC<DeviceControlsProps> = ({ device, status,
Toggle
</Button>
)}
{isCeilingLight && (
<Button
size="small"
variant="contained"
onClick={handleExecuteNightLightScene}
disabled={nightLightButtonDisabled}
fullWidth
>
{nightLightExecuting ? t("Executing...") : t("Night Light")}
</Button>
)}
</Box>
{isCeilingLight && (
<Stack spacing={0.25} sx={{ width: "100%", maxWidth: maxControlWidth }}>
<Typography variant="caption" color={nightLightScene ? "text.secondary" : "warning.main"}>
{nightLightScene
? `${t("Assigned scene")}: ${nightLightScene.sceneName || t("Unnamed Scene")}`
: t("Assign a scene to enable the night light button.")}
</Typography>
{assignedNightLightSceneId && !nightLightScene && (
<Typography variant="caption" color="error">
{t("The assigned scene is not available. Refresh scenes or reassign.")}
</Typography>
)}
{nightLightExecutionError && (
<Typography variant="caption" color="error">
{nightLightExecutionError}
</Typography>
)}
</Stack>
)}
{supportsLightBrightness && (
<Box sx={sliderContainerSx}>
<Typography variant="caption" color="text.secondary">Brightness</Typography>
Expand Down Expand Up @@ -729,6 +825,50 @@ export const DeviceControls: React.FC<DeviceControlsProps> = ({ device, status,
</Button>
</Stack>
)}
{isCeilingLight && !dense && (
<Stack spacing={0.75} sx={{ width: "100%", maxWidth: maxControlWidth }}>
<Typography variant="subtitle2" sx={sectionLabelSx}>{t("Night light button settings")}</Typography>
<TextField
select
size="small"
label={t("Assign scene")}
value={nightLightSceneSelection}
onChange={(e) => setNightLightSceneSelection(e.target.value)}
disabled={!isTokenValid || scenesLoading || (!hasScenes && !assignedNightLightSceneId)}
helperText={hasScenes ? t("Select a scene to run when pressing the night light button.") : t("No scenes found. Create a manual scene in the SwitchBot app.")}
>
<MenuItem value="">{t("No scene assigned")}</MenuItem>
{scenes.map((scene) => (
<MenuItem key={scene.sceneId} value={scene.sceneId}>
{scene.sceneName || t("Unnamed Scene")}
</MenuItem>
))}
</TextField>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
size="small"
onClick={handleSaveNightLightScene}
disabled={nightLightSaveDisabled}
>
{t("Save assignment")}
</Button>
<Button
variant="outlined"
size="small"
onClick={handleRefreshScenes}
disabled={scenesLoading || !isTokenValid}
>
{scenesLoading ? t("Loading scenes...") : t("Reload scenes")}
</Button>
</Stack>
{scenesError && (
<Typography variant="caption" color="error">
{scenesError}
</Typography>
)}
</Stack>
)}
</>
)}

Expand Down
23 changes: 23 additions & 0 deletions src/renderer/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ const en: Dictionary = {
"Custom Mode": "Custom Mode",
"Mode Unknown": "Mode Unknown",
"Send setAll": "Send",
"Night Light": "Night Light",
"Assigned scene": "Assigned scene",
"Assign a scene to enable the night light button.": "Assign a scene to enable the night light button.",
"The assigned scene is not available. Refresh scenes or reassign.":
"The assigned scene is not available. Refresh scenes or reassign.",
"Night light button settings": "Night light button settings",
"Assign scene": "Assign scene",
"Select a scene to run when pressing the night light button.":
"Select a scene to run when pressing the night light button.",
"No scene assigned": "No scene assigned",
"Save assignment": "Save assignment",
"Reload scenes": "Reload scenes",

// Misc
"No quick controls available for this device type.":
Expand Down Expand Up @@ -235,6 +247,17 @@ const ja: Dictionary = {
"Custom Mode": "カスタムモード",
"Mode Unknown": "モード不明",
"Send setAll": "送信",
"Night Light": "常夜灯",
"Assigned scene": "割り当てシーン",
"Assign a scene to enable the night light button.": "常夜灯ボタンを有効にするにはシーンを割り当ててください。",
"The assigned scene is not available. Refresh scenes or reassign.":
"割り当てられたシーンが見つかりません。再読み込みするか再割り当てしてください。",
"Night light button settings": "常夜灯ボタンの設定",
"Assign scene": "シーンを割り当て",
"Select a scene to run when pressing the night light button.": "常夜灯ボタンを押したときに実行するシーンを選択してください。",
"No scene assigned": "シーンは未割り当てです",
"Save assignment": "割り当てを保存",
"Reload scenes": "シーンを再読み込み",

// Misc
"No quick controls available for this device type.":
Expand Down
24 changes: 0 additions & 24 deletions src/renderer/src/store/slices/deviceSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,30 +88,6 @@ const persistDeviceOrder = (order: string[]) => {
}
};

const applyDeviceOrder = (devices: AnyDevice[], desiredOrder: string[]) => {
const idToDevice = new Map(devices.map((device) => [device.deviceId, device]));
const seen = new Set<string>();
const orderedDevices: AnyDevice[] = [];

desiredOrder.forEach((id) => {
const device = idToDevice.get(id);
if (device && !seen.has(id)) {
orderedDevices.push(device);
seen.add(id);
}
});

devices.forEach((device) => {
if (!seen.has(device.deviceId)) {
orderedDevices.push(device);
seen.add(device.deviceId);
}
});

const normalizedOrder = orderedDevices.map((device) => device.deviceId);
return { orderedDevices, normalizedOrder };
};

export const loadDeviceOrder = createAsyncThunk("devices/loadDeviceOrder", async () => {
try {
const stored = await window.electronStore.get(DEVICE_ORDER_STORAGE_KEY);
Expand Down
61 changes: 60 additions & 1 deletion src/renderer/src/store/slices/sceneSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ export interface ScenesState {
lastExecutedSceneId: string | null;
sceneOrder: string[];
sceneOrderLoaded: boolean;
nightLightSceneMap: Record<string, string | undefined>;
nightLightScenesLoaded: boolean;
}

const SCENE_ORDER_STORAGE_KEY = "sceneOrder";
const NIGHT_LIGHT_SCENE_STORAGE_KEY = "nightLightSceneAssignments";

const persistSceneOrder = (order: string[]) => {
try {
Expand All @@ -25,6 +28,14 @@ const persistSceneOrder = (order: string[]) => {
}
};

const persistNightLightScenes = (map: Record<string, string | undefined>) => {
try {
void window.electronStore.set(NIGHT_LIGHT_SCENE_STORAGE_KEY, map);
} catch (error) {
console.error("Failed to persist night-light scene assignments:", error);
}
};

const initialState: ScenesState = {
scenes: [],
isLoading: false,
Expand All @@ -35,6 +46,8 @@ const initialState: ScenesState = {
lastExecutedSceneId: null,
sceneOrder: [],
sceneOrderLoaded: false,
nightLightSceneMap: {},
nightLightScenesLoaded: false,
};

export const loadSceneOrder = createAsyncThunk("scenes/loadSceneOrder", async () => {
Expand All @@ -49,6 +62,27 @@ export const loadSceneOrder = createAsyncThunk("scenes/loadSceneOrder", async ()
return [];
});

export const loadNightLightSceneAssignments = createAsyncThunk(
"scenes/loadNightLightSceneAssignments",
async () => {
try {
const stored = await window.electronStore.get(NIGHT_LIGHT_SCENE_STORAGE_KEY);
if (stored && typeof stored === "object") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The check typeof stored === "object" is also true for arrays. If an array is somehow stored in NIGHT_LIGHT_SCENE_STORAGE_KEY (e.g., due to a bug or a previous data format), Object.entries will iterate over its indices, which is not the intended behavior for a key-value map. To make this data loading more robust, you should add a check to ensure the stored value is not an array.

Suggested change
if (stored && typeof stored === "object") {
if (stored && typeof stored === "object" && !Array.isArray(stored)) {

const map: Record<string, string> = {};
Object.entries(stored as Record<string, unknown>).forEach(([deviceId, sceneId]) => {
if (typeof deviceId === "string" && typeof sceneId === "string") {
map[deviceId] = sceneId;
}
});
return map;
}
} catch (error) {
console.error("Failed to load night-light scene assignments:", error);
}
return {};
}
);

export const fetchScenes = createAsyncThunk("scenes/fetchScenes", async (_, { getState, rejectWithValue }) => {
const { settings } = getState() as RootState;
if (!settings.apiToken || !settings.isTokenValidated) {
Expand Down Expand Up @@ -109,6 +143,15 @@ const scenesSlice = createSlice({
state.sceneOrderLoaded = true;
persistSceneOrder(state.sceneOrder);
},
setNightLightSceneForDevice: (state, action: PayloadAction<{ deviceId: string; sceneId: string | null }>) => {
const { deviceId, sceneId } = action.payload;
if (sceneId) {
state.nightLightSceneMap[deviceId] = sceneId;
} else {
delete state.nightLightSceneMap[deviceId];
}
persistNightLightScenes(state.nightLightSceneMap);
},
},
extraReducers: (builder) => {
builder
Expand Down Expand Up @@ -159,11 +202,24 @@ const scenesSlice = createSlice({
})
.addCase(loadSceneOrder.rejected, (state) => {
state.sceneOrderLoaded = true;
})
.addCase(loadNightLightSceneAssignments.fulfilled, (state, action: PayloadAction<Record<string, string>>) => {
state.nightLightScenesLoaded = true;
state.nightLightSceneMap = action.payload;
})
.addCase(loadNightLightSceneAssignments.rejected, (state) => {
state.nightLightScenesLoaded = true;
});
},
});

export const { clearSceneErrors, clearSceneExecutionError, clearScenesState, setSceneOrder } = scenesSlice.actions;
export const {
clearSceneErrors,
clearSceneExecutionError,
clearScenesState,
setSceneOrder,
setNightLightSceneForDevice
} = scenesSlice.actions;

export const selectScenes = (state: RootState) => state.scenes.scenes;
export const selectScenesLoading = (state: RootState) => state.scenes.isLoading;
Expand All @@ -174,5 +230,8 @@ export const selectSceneExecutionError = (state: RootState, sceneId: string) =>
export const selectLastExecutedSceneId = (state: RootState) => state.scenes.lastExecutedSceneId;
export const selectSceneOrderLoaded = (state: RootState) => state.scenes.sceneOrderLoaded;
export const selectSceneOrder = (state: RootState) => state.scenes.sceneOrder;
export const selectNightLightSceneForDevice = (state: RootState, deviceId: string) =>
state.scenes.nightLightSceneMap[deviceId];
export const selectNightLightScenesLoaded = (state: RootState) => state.scenes.nightLightScenesLoaded;

export default scenesSlice.reducer;