Can set relay forced state from UI
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-10-29 17:49:03 +01:00
parent 88a24565b4
commit abdca20a66
6 changed files with 178 additions and 10 deletions

View File

@@ -1,10 +1,19 @@
import { APIClient } from "./ApiClient"; import { APIClient } from "./ApiClient";
import { Device, DeviceRelay } from "./DeviceApi"; 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 { export interface RelayStatus {
id: string; id: string;
on: boolean; on: boolean;
for: number; for: number;
forced_state: RelayForcedState;
} }
export type RelaysStatus = Map<string, RelayStatus>; export type RelaysStatus = Map<string, RelayStatus>;
@@ -48,6 +57,20 @@ export class RelayApi {
}); });
} }
/**
* Set relay forced state
*/
static async SetForcedState(
relay: DeviceRelay,
forced: SetRelayForcedState
): Promise<void> {
await APIClient.exec({
method: "PUT",
uri: `/relay/${relay.id}/forced_state`,
jsonData: forced,
});
}
/** /**
* Delete a relay configuration * Delete a relay configuration
*/ */

View File

@@ -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 (
<Dialog open onClose={p.onCancel}>
<DialogTitle>Set forced relay state</DialogTitle>
<DialogContent>
<DialogContentText>
Please specify the number of minutes the relay <i>{p.relay.name}</i>{" "}
will remain in forced state <i>{p.forcedState}</i>:
</DialogContentText>
<TextField
label="Duration (min)"
variant="standard"
value={Math.floor(duration / 60)}
onChange={(e) => {
const val = Number.parseInt(e.target.value);
setDuration((Number.isNaN(val) ? 1 : val) * 60);
}}
fullWidth
style={{ marginTop: "5px" }}
/>
<p>Equivalent in seconds: {duration} secs</p>
<p>Equivalent in hours: {duration / 3600} hours</p>
</DialogContent>
<DialogActions>
<Button onClick={p.onCancel}>Cancel</Button>
<Button onClick={() => p.onSubmit(duration)} autoFocus>
Start timer
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -10,16 +10,16 @@ import {
} from "@mui/material"; } from "@mui/material";
import React from "react"; import React from "react";
import { Device, DeviceRelay } from "../../api/DeviceApi"; import { Device, DeviceRelay } from "../../api/DeviceApi";
import { RelayApi, RelayStatus } from "../../api/RelayApi";
import { EditDeviceRelaysDialog } from "../../dialogs/EditDeviceRelaysDialog"; import { EditDeviceRelaysDialog } from "../../dialogs/EditDeviceRelaysDialog";
import { DeviceRouteCard } from "./DeviceRouteCard"; import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider"; import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider";
import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider"; import { useLoadingMessage } from "../../hooks/context_providers/LoadingMessageProvider";
import { RelayApi, RelayStatus } from "../../api/RelayApi";
import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider"; import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider";
import { useAlert } from "../../hooks/context_providers/AlertDialogProvider";
import { AsyncWidget } from "../../widgets/AsyncWidget"; import { AsyncWidget } from "../../widgets/AsyncWidget";
import { TimeWidget } from "../../widgets/TimeWidget";
import { BoolText } from "../../widgets/BoolText"; import { BoolText } from "../../widgets/BoolText";
import { TimeWidget } from "../../widgets/TimeWidget";
import { DeviceRouteCard } from "./DeviceRouteCard";
export function DeviceRelays(p: { export function DeviceRelays(p: {
device: Device; device: Device;
@@ -145,7 +145,8 @@ function RelayEntryStatus(
errMsg="Failed to load relay status!" errMsg="Failed to load relay status!"
build={() => ( build={() => (
<> <>
<BoolText val={state!.on} positive="ON" negative="OFF" /> for{" "} <BoolText val={state!.on} positive="ON" negative="OFF" />{" "}
{state?.forced_state.type !== "None" && <b>Forced</b>} for{" "}
<TimeWidget diff time={state!.for} /> <TimeWidget diff time={state!.for} />
</> </>
)} )}

View File

@@ -16,12 +16,13 @@ import React from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Device, DeviceApi, DeviceRelay, DeviceURL } from "../api/DeviceApi"; import { Device, DeviceApi, DeviceRelay, DeviceURL } from "../api/DeviceApi";
import { RelayApi, RelaysStatus } from "../api/RelayApi"; import { RelayApi, RelaysStatus } from "../api/RelayApi";
import { ServerApi } from "../api/ServerApi";
import { AsyncWidget } from "../widgets/AsyncWidget"; import { AsyncWidget } from "../widgets/AsyncWidget";
import { BoolText } from "../widgets/BoolText"; import { BoolText } from "../widgets/BoolText";
import { CopyToClipboard } from "../widgets/CopyToClipboard";
import { RelayForcedState } from "../widgets/RelayForcedState";
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer"; import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
import { TimeWidget } from "../widgets/TimeWidget"; import { TimeWidget } from "../widgets/TimeWidget";
import { CopyToClipboard } from "../widgets/CopyToClipboard";
import { ServerApi } from "../api/ServerApi";
export function RelaysListRoute(p: { export function RelaysListRoute(p: {
homeWidget?: boolean; homeWidget?: boolean;
@@ -104,6 +105,7 @@ function RelaysList(p: {
<TableCell>Priority</TableCell> <TableCell>Priority</TableCell>
<TableCell>Consumption</TableCell> <TableCell>Consumption</TableCell>
<TableCell>Status</TableCell> <TableCell>Status</TableCell>
<TableCell>Forced state</TableCell>
<TableCell></TableCell> <TableCell></TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
@@ -129,6 +131,13 @@ function RelaysList(p: {
/>{" "} />{" "}
for <TimeWidget diff time={p.status.get(row.id)!.for} /> for <TimeWidget diff time={p.status.get(row.id)!.for} />
</TableCell> </TableCell>
<TableCell>
<RelayForcedState
relay={row}
state={p.status.get(row.id)!}
onUpdated={p.onReload}
/>
</TableCell>
<TableCell> <TableCell>
<Tooltip title="Copy legacy api status"> <Tooltip title="Copy legacy api status">
<CopyToClipboard <CopyToClipboard

View File

@@ -0,0 +1,79 @@
import { MenuItem, Select, SelectChangeEvent } from "@mui/material";
import { DeviceRelay } from "../api/DeviceApi";
import { RelayApi, RelayStatus, SetRelayForcedState } from "../api/RelayApi";
import { TimeWidget } from "./TimeWidget";
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
import React from "react";
import { SelectForcedStateDurationDialog } from "../dialogs/SelectForcedStateDurationDialog";
export function RelayForcedState(p: {
relay: DeviceRelay;
state: RelayStatus;
onUpdated: () => 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 (
<>
<Select
value={p.state.forced_state.type}
onChange={handleChange}
size="small"
variant="standard"
>
<MenuItem value={"None"}>None</MenuItem>
<MenuItem value={"Off"}>Off</MenuItem>
<MenuItem value={"On"}>On</MenuItem>
</Select>
{p.state.forced_state.type !== "None" && (
<>
<TimeWidget future time={p.state.forced_state.until} /> left
</>
)}
{futureStateType !== undefined && (
<SelectForcedStateDurationDialog
{...p}
forcedState={futureStateType}
onCancel={() => setFutureStateType(undefined)}
onSubmit={(d) =>
submitChange({
type: futureStateType as any,
for_secs: d,
})
}
/>
)}
</>
);
}

View File

@@ -51,13 +51,14 @@ export function timeDiff(a: number, b: number): string {
return `${diffYears} years`; return `${diffYears} years`;
} }
export function timeDiffFromNow(t: number): string { export function timeDiffFromNow(t: number, future?: boolean): string {
return timeDiff(t, time()); return future ? timeDiff(time(), t) : timeDiff(t, time());
} }
export function TimeWidget(p: { export function TimeWidget(p: {
time?: number; time?: number;
diff?: boolean; diff?: boolean;
future?: boolean;
}): React.ReactElement { }): React.ReactElement {
if (!p.time) return <></>; if (!p.time) return <></>;
return ( return (
@@ -65,7 +66,9 @@ export function TimeWidget(p: {
title={formatDate(p.diff ? new Date().getTime() / 1000 - p.time : p.time)} title={formatDate(p.diff ? new Date().getTime() / 1000 - p.time : p.time)}
arrow arrow
> >
<span>{p.diff ? timeDiff(0, p.time) : timeDiffFromNow(p.time)}</span> <span>
{p.diff ? timeDiff(0, p.time) : timeDiffFromNow(p.time, p.future)}
</span>
</Tooltip> </Tooltip>
); );
} }