From 01f2739fa238d6a258a7579787a95dcb92a3c9ee Mon Sep 17 00:00:00 2001 From: suu Date: Sun, 28 Dec 2025 18:47:36 +0900 Subject: [PATCH 1/2] a --- src/renderer/src/store/slices/deviceSlice.ts | 24 -------------------- 1 file changed, 24 deletions(-) diff --git a/src/renderer/src/store/slices/deviceSlice.ts b/src/renderer/src/store/slices/deviceSlice.ts index e3a7720..b9bb4ef 100644 --- a/src/renderer/src/store/slices/deviceSlice.ts +++ b/src/renderer/src/store/slices/deviceSlice.ts @@ -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(); - 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); From 9bf08ea3dccaa99dd6f9bca3661aa6af456fb2db Mon Sep 17 00:00:00 2001 From: suu Date: Sun, 28 Dec 2025 19:03:17 +0900 Subject: [PATCH 2/2] Add NightLightButton --- src/renderer/src/App.tsx | 3 +- .../src/components/DeviceControls.tsx | 142 +++++++++++++++++- src/renderer/src/i18n.ts | 23 +++ src/renderer/src/store/slices/sceneSlice.ts | 61 +++++++- 4 files changed, 226 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9834423..fc57827 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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"; @@ -58,6 +58,7 @@ function App() { dispatch(loadApiCredentials()); dispatch(loadDeviceOrder()); dispatch(loadSceneOrder()); + dispatch(loadNightLightSceneAssignments()); }, [dispatch]); // Restore the last opened top-level view on startup. diff --git a/src/renderer/src/components/DeviceControls.tsx b/src/renderer/src/components/DeviceControls.tsx index 1ca21e0..e02a226 100644 --- a/src/renderer/src/components/DeviceControls.tsx +++ b/src/renderer/src/components/DeviceControls.tsx @@ -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"; @@ -67,6 +78,20 @@ export const DeviceControls: React.FC = ({ 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 + ); + const [nightLightSceneSelection, setNightLightSceneSelection] = useState(assignedNightLightSceneId || ""); + const [requestedScenes, setRequestedScenes] = useState(false); const definition = useMemo(() => findDeviceDefinition(device.deviceType), [device.deviceType]); const isHiddenByDefinition = definition?.hide; @@ -100,6 +125,19 @@ export const DeviceControls: React.FC = ({ 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"; @@ -110,7 +148,6 @@ export const DeviceControls: React.FC = ({ 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") @@ -159,6 +196,24 @@ export const DeviceControls: React.FC = ({ 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 }; @@ -209,6 +264,17 @@ export const DeviceControls: React.FC = ({ 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"; @@ -673,7 +739,37 @@ export const DeviceControls: React.FC = ({ device, status, Toggle )} + {isCeilingLight && ( + + )} + {isCeilingLight && ( + + + {nightLightScene + ? `${t("Assigned scene")}: ${nightLightScene.sceneName || t("Unnamed Scene")}` + : t("Assign a scene to enable the night light button.")} + + {assignedNightLightSceneId && !nightLightScene && ( + + {t("The assigned scene is not available. Refresh scenes or reassign.")} + + )} + {nightLightExecutionError && ( + + {nightLightExecutionError} + + )} + + )} {supportsLightBrightness && ( Brightness @@ -729,6 +825,50 @@ export const DeviceControls: React.FC = ({ device, status, )} + {isCeilingLight && !dense && ( + + {t("Night light button settings")} + 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.")} + > + {t("No scene assigned")} + {scenes.map((scene) => ( + + {scene.sceneName || t("Unnamed Scene")} + + ))} + + + + + + {scenesError && ( + + {scenesError} + + )} + + )} )} diff --git a/src/renderer/src/i18n.ts b/src/renderer/src/i18n.ts index 2b5a6e0..7f55335 100644 --- a/src/renderer/src/i18n.ts +++ b/src/renderer/src/i18n.ts @@ -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.": @@ -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.": diff --git a/src/renderer/src/store/slices/sceneSlice.ts b/src/renderer/src/store/slices/sceneSlice.ts index 6e76d4d..22d9062 100644 --- a/src/renderer/src/store/slices/sceneSlice.ts +++ b/src/renderer/src/store/slices/sceneSlice.ts @@ -13,9 +13,12 @@ export interface ScenesState { lastExecutedSceneId: string | null; sceneOrder: string[]; sceneOrderLoaded: boolean; + nightLightSceneMap: Record; + nightLightScenesLoaded: boolean; } const SCENE_ORDER_STORAGE_KEY = "sceneOrder"; +const NIGHT_LIGHT_SCENE_STORAGE_KEY = "nightLightSceneAssignments"; const persistSceneOrder = (order: string[]) => { try { @@ -25,6 +28,14 @@ const persistSceneOrder = (order: string[]) => { } }; +const persistNightLightScenes = (map: Record) => { + 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, @@ -35,6 +46,8 @@ const initialState: ScenesState = { lastExecutedSceneId: null, sceneOrder: [], sceneOrderLoaded: false, + nightLightSceneMap: {}, + nightLightScenesLoaded: false, }; export const loadSceneOrder = createAsyncThunk("scenes/loadSceneOrder", async () => { @@ -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") { + const map: Record = {}; + Object.entries(stored as Record).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) { @@ -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 @@ -159,11 +202,24 @@ const scenesSlice = createSlice({ }) .addCase(loadSceneOrder.rejected, (state) => { state.sceneOrderLoaded = true; + }) + .addCase(loadNightLightSceneAssignments.fulfilled, (state, action: PayloadAction>) => { + 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; @@ -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;