From abdca20a66afb88eefe867c25b0edd22b460da78 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Wed, 29 Oct 2025 17:49:03 +0100 Subject: [PATCH] Can set relay forced state from UI --- central_frontend/src/api/RelayApi.ts | 23 ++++++ .../SelectForcedStateDurationDialog.tsx | 53 +++++++++++++ .../src/routes/DeviceRoute/DeviceRelays.tsx | 11 +-- .../src/routes/RelaysListRoute.tsx | 13 ++- .../src/widgets/RelayForcedState.tsx | 79 +++++++++++++++++++ central_frontend/src/widgets/TimeWidget.tsx | 9 ++- 6 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 central_frontend/src/dialogs/SelectForcedStateDurationDialog.tsx create mode 100644 central_frontend/src/widgets/RelayForcedState.tsx diff --git a/central_frontend/src/api/RelayApi.ts b/central_frontend/src/api/RelayApi.ts index 0620541..7f39e95 100644 --- a/central_frontend/src/api/RelayApi.ts +++ b/central_frontend/src/api/RelayApi.ts @@ -1,10 +1,19 @@ import { APIClient } from "./ApiClient"; import { Device, DeviceRelay } from "./DeviceApi"; +export type RelayForcedState = + | { type: "None" } + | { type: "Off" | "On"; until: number }; + +export type SetRelayForcedState = + | { type: "None" } + | { type: "Off" | "On"; for_secs: number }; + export interface RelayStatus { id: string; on: boolean; for: number; + forced_state: RelayForcedState; } export type RelaysStatus = Map; @@ -48,6 +57,20 @@ export class RelayApi { }); } + /** + * Set relay forced state + */ + static async SetForcedState( + relay: DeviceRelay, + forced: SetRelayForcedState + ): Promise { + await APIClient.exec({ + method: "PUT", + uri: `/relay/${relay.id}/forced_state`, + jsonData: forced, + }); + } + /** * Delete a relay configuration */ diff --git a/central_frontend/src/dialogs/SelectForcedStateDurationDialog.tsx b/central_frontend/src/dialogs/SelectForcedStateDurationDialog.tsx new file mode 100644 index 0000000..73cf17e --- /dev/null +++ b/central_frontend/src/dialogs/SelectForcedStateDurationDialog.tsx @@ -0,0 +1,53 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, +} from "@mui/material"; +import { DeviceRelay } from "../api/DeviceApi"; +import React from "react"; + +export function SelectForcedStateDurationDialog(p: { + relay: DeviceRelay; + forcedState: string; + onCancel: () => void; + onSubmit: (duration: number) => void; +}): React.ReactElement { + const [duration, setDuration] = React.useState(60); + + return ( + + Set forced relay state + + + Please specify the number of minutes the relay {p.relay.name}{" "} + will remain in forced state {p.forcedState}: + + + { + const val = Number.parseInt(e.target.value); + setDuration((Number.isNaN(val) ? 1 : val) * 60); + }} + fullWidth + style={{ marginTop: "5px" }} + /> + +

Equivalent in seconds: {duration} secs

+

Equivalent in hours: {duration / 3600} hours

+
+ + + + +
+ ); +} diff --git a/central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx b/central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx index 6018173..bccda75 100644 --- a/central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx +++ b/central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx @@ -10,16 +10,16 @@ import { } from "@mui/material"; import React from "react"; import { Device, DeviceRelay } from "../../api/DeviceApi"; +import { RelayApi, RelayStatus } from "../../api/RelayApi"; import { EditDeviceRelaysDialog } from "../../dialogs/EditDeviceRelaysDialog"; -import { DeviceRouteCard } from "./DeviceRouteCard"; +import { useAlert } from "../../hooks/context_providers/AlertDialogProvider"; import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider"; import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider"; -import { RelayApi, RelayStatus } from "../../api/RelayApi"; import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider"; -import { useAlert } from "../../hooks/context_providers/AlertDialogProvider"; import { AsyncWidget } from "../../widgets/AsyncWidget"; -import { TimeWidget } from "../../widgets/TimeWidget"; import { BoolText } from "../../widgets/BoolText"; +import { TimeWidget } from "../../widgets/TimeWidget"; +import { DeviceRouteCard } from "./DeviceRouteCard"; export function DeviceRelays(p: { device: Device; @@ -145,7 +145,8 @@ function RelayEntryStatus( errMsg="Failed to load relay status!" build={() => ( <> - for{" "} + {" "} + {state?.forced_state.type !== "None" && Forced} for{" "} )} diff --git a/central_frontend/src/routes/RelaysListRoute.tsx b/central_frontend/src/routes/RelaysListRoute.tsx index 6437e8d..b23a342 100644 --- a/central_frontend/src/routes/RelaysListRoute.tsx +++ b/central_frontend/src/routes/RelaysListRoute.tsx @@ -16,12 +16,13 @@ import React from "react"; import { useNavigate } from "react-router-dom"; import { Device, DeviceApi, DeviceRelay, DeviceURL } from "../api/DeviceApi"; import { RelayApi, RelaysStatus } from "../api/RelayApi"; +import { ServerApi } from "../api/ServerApi"; import { AsyncWidget } from "../widgets/AsyncWidget"; import { BoolText } from "../widgets/BoolText"; +import { CopyToClipboard } from "../widgets/CopyToClipboard"; +import { RelayForcedState } from "../widgets/RelayForcedState"; import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer"; import { TimeWidget } from "../widgets/TimeWidget"; -import { CopyToClipboard } from "../widgets/CopyToClipboard"; -import { ServerApi } from "../api/ServerApi"; export function RelaysListRoute(p: { homeWidget?: boolean; @@ -104,6 +105,7 @@ function RelaysList(p: { Priority Consumption Status + Forced state @@ -129,6 +131,13 @@ function RelaysList(p: { />{" "} for + + + void; +}): React.ReactElement { + const loadingMessage = useLoadingMessage(); + const alert = useAlert(); + const snackbar = useSnackbar(); + + const [futureStateType, setFutureStateType] = React.useState< + string | undefined + >(); + + const handleChange = (event: SelectChangeEvent) => { + if (event.target.value == "None") { + submitChange({ type: "None" }); + } else { + setFutureStateType(event.target.value); + } + }; + + const submitChange = async (state: SetRelayForcedState) => { + try { + loadingMessage.show("Setting forced state..."); + await RelayApi.SetForcedState(p.relay, state); + p.onUpdated(); + snackbar("Forced state successfully updated!"); + } catch (e) { + console.error(`Failed to set relay forced state! ${e}`); + alert(`Failed to set loading state for relay! ${e}`); + } finally { + loadingMessage.hide(); + } + }; + + return ( + <> + + {p.state.forced_state.type !== "None" && ( + <> + left + + )} + + {futureStateType !== undefined && ( + setFutureStateType(undefined)} + onSubmit={(d) => + submitChange({ + type: futureStateType as any, + for_secs: d, + }) + } + /> + )} + + ); +} diff --git a/central_frontend/src/widgets/TimeWidget.tsx b/central_frontend/src/widgets/TimeWidget.tsx index 23f0263..82aafea 100644 --- a/central_frontend/src/widgets/TimeWidget.tsx +++ b/central_frontend/src/widgets/TimeWidget.tsx @@ -51,13 +51,14 @@ export function timeDiff(a: number, b: number): string { return `${diffYears} years`; } -export function timeDiffFromNow(t: number): string { - return timeDiff(t, time()); +export function timeDiffFromNow(t: number, future?: boolean): string { + return future ? timeDiff(time(), t) : timeDiff(t, time()); } export function TimeWidget(p: { time?: number; diff?: boolean; + future?: boolean; }): React.ReactElement { if (!p.time) return <>; return ( @@ -65,7 +66,9 @@ export function TimeWidget(p: { title={formatDate(p.diff ? new Date().getTime() / 1000 - p.time : p.time)} arrow > - {p.diff ? timeDiff(0, p.time) : timeDiffFromNow(p.time)} + + {p.diff ? timeDiff(0, p.time) : timeDiffFromNow(p.time, p.future)} + ); }