diff --git a/central_backend/src/devices/devices_list.rs b/central_backend/src/devices/devices_list.rs index 39f5c11..b52b4b0 100644 --- a/central_backend/src/devices/devices_list.rs +++ b/central_backend/src/devices/devices_list.rs @@ -81,7 +81,7 @@ impl DevicesList { let dev = self .0 .get(id) - .ok_or_else(|| DevicesListError::PersistFailedDeviceNotFound)?; + .ok_or(DevicesListError::PersistFailedDeviceNotFound)?; std::fs::write( AppConfig::get().device_config_path(id), @@ -90,4 +90,9 @@ impl DevicesList { Ok(()) } + + /// Get a copy of the full list of devices + pub fn full_list(&self) -> Vec { + self.0.clone().into_values().collect() + } } diff --git a/central_backend/src/energy/energy_actor.rs b/central_backend/src/energy/energy_actor.rs index 50b1c38..822af25 100644 --- a/central_backend/src/energy/energy_actor.rs +++ b/central_backend/src/energy/energy_actor.rs @@ -1,5 +1,5 @@ use crate::constants; -use crate::devices::device::{DeviceId, DeviceInfo}; +use crate::devices::device::{Device, DeviceId, DeviceInfo}; use crate::devices::devices_list::DevicesList; use crate::energy::consumption; use crate::energy::consumption::EnergyConsumption; @@ -93,3 +93,16 @@ impl Handler for EnergyActor { self.devices.enroll(&msg.0, &msg.1, &msg.2) } } + +/// Get the list of devices +#[derive(Message)] +#[rtype(result = "Vec")] +pub struct GetDeviceLists; + +impl Handler for EnergyActor { + type Result = Vec; + + fn handle(&mut self, _msg: GetDeviceLists, _ctx: &mut Context) -> Self::Result { + self.devices.full_list() + } +} diff --git a/central_backend/src/server/servers.rs b/central_backend/src/server/servers.rs index 6cfe6b5..7a484bb 100644 --- a/central_backend/src/server/servers.rs +++ b/central_backend/src/server/servers.rs @@ -131,6 +131,14 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> "/web_api/energy/cached_consumption", web::get().to(energy_controller::cached_consumption), ) + .route( + "/web_api/devices/list_pending", + web::get().to(devices_controller::list_pending), + ) + .route( + "/web_api/devices/list_validated", + web::get().to(devices_controller::list_validated), + ) // Devices API .route( "/devices_api/utils/time", diff --git a/central_backend/src/server/web_api/devices_controller.rs b/central_backend/src/server/web_api/devices_controller.rs new file mode 100644 index 0000000..7c91340 --- /dev/null +++ b/central_backend/src/server/web_api/devices_controller.rs @@ -0,0 +1,28 @@ +use crate::energy::energy_actor; +use crate::server::custom_error::HttpResult; +use crate::server::WebEnergyActor; +use actix_web::HttpResponse; + +/// Get the list of pending (not accepted yet) devices +pub async fn list_pending(actor: WebEnergyActor) -> HttpResult { + let list = actor + .send(energy_actor::GetDeviceLists) + .await? + .into_iter() + .filter(|d| !d.validated) + .collect::>(); + + Ok(HttpResponse::Ok().json(list)) +} + +/// Get the list of validated (not accepted yet) devices +pub async fn list_validated(actor: WebEnergyActor) -> HttpResult { + let list = actor + .send(energy_actor::GetDeviceLists) + .await? + .into_iter() + .filter(|d| d.validated) + .collect::>(); + + Ok(HttpResponse::Ok().json(list)) +} diff --git a/central_backend/src/server/web_api/mod.rs b/central_backend/src/server/web_api/mod.rs index 57ed8ab..e4c75a5 100644 --- a/central_backend/src/server/web_api/mod.rs +++ b/central_backend/src/server/web_api/mod.rs @@ -1,3 +1,4 @@ pub mod auth_controller; +pub mod devices_controller; pub mod energy_controller; pub mod server_controller; diff --git a/central_frontend/package-lock.json b/central_frontend/package-lock.json index a973ea2..ddad6ed 100644 --- a/central_frontend/package-lock.json +++ b/central_frontend/package-lock.json @@ -15,6 +15,7 @@ "@mdi/react": "^1.6.1", "@mui/icons-material": "^5.15.21", "@mui/material": "^5.15.21", + "date-and-time": "^3.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0" @@ -2205,6 +2206,11 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/date-and-time": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.3.0.tgz", + "integrity": "sha512-UguWfh9LkUecVrGSE0B7SpAnGRMPATmpwSoSij24/lDnwET3A641abfDBD/TdL0T+E04f8NWlbMkD9BscVvIZg==" + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", diff --git a/central_frontend/package.json b/central_frontend/package.json index 71a6436..10295f0 100644 --- a/central_frontend/package.json +++ b/central_frontend/package.json @@ -17,6 +17,7 @@ "@mdi/react": "^1.6.1", "@mui/icons-material": "^5.15.21", "@mui/material": "^5.15.21", + "date-and-time": "^3.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0" diff --git a/central_frontend/src/App.tsx b/central_frontend/src/App.tsx index bdd9c77..c7e96d1 100644 --- a/central_frontend/src/App.tsx +++ b/central_frontend/src/App.tsx @@ -10,6 +10,7 @@ import { LoginRoute } from "./routes/LoginRoute"; import { NotFoundRoute } from "./routes/NotFoundRoute"; import { HomeRoute } from "./routes/HomeRoute"; import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; +import { PendingDevicesRoute } from "./routes/PendingDevicesRoute"; export function App() { if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled) @@ -19,7 +20,7 @@ export function App() { createRoutesFromElements( }> } /> - + } /> } /> ) diff --git a/central_frontend/src/api/DeviceApi.ts b/central_frontend/src/api/DeviceApi.ts new file mode 100644 index 0000000..772b0a4 --- /dev/null +++ b/central_frontend/src/api/DeviceApi.ts @@ -0,0 +1,52 @@ +import { APIClient } from "./ApiClient"; + +export interface DeviceInfo { + reference: string; + version: string; + max_relays: number; +} + +export interface DailyMinRuntime { + min_runtime: number; + reset_time: number; + catch_up_hours: number[]; +} + +export interface DeviceRelay { + id: string; + name: string; + enabled: boolean; + priority: number; + consumption: number; + minimal_uptime: number; + minimal_downtime: number; + daily_runtime?: DailyMinRuntime; + depends_on: DeviceRelay[]; + conflicts_with: DeviceRelay[]; +} + +export interface Device { + id: string; + info: DeviceInfo; + time_create: number; + time_update: number; + name: string; + description: string; + validated: boolean; + enabled: boolean; + relays: DeviceRelay[]; +} + +export class DeviceApi { + /** + * Get the list of pending devices + */ + static async PendingList(): Promise { + return ( + await APIClient.exec({ + uri: "/devices/list_pending", + method: "GET", + }) + ).data; + } +} diff --git a/central_frontend/src/routes/PendingDevicesRoute.tsx b/central_frontend/src/routes/PendingDevicesRoute.tsx new file mode 100644 index 0000000..44ed869 --- /dev/null +++ b/central_frontend/src/routes/PendingDevicesRoute.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { AsyncWidget } from "../widgets/AsyncWidget"; +import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer"; +import { Device, DeviceApi } from "../api/DeviceApi"; +import { + TableContainer, + Paper, + Table, + TableHead, + TableRow, + TableCell, + TableBody, +} from "@mui/material"; +import { TimeWidget } from "../widgets/TimeWidget"; + +export function PendingDevicesRoute(): React.ReactElement { + const loadKey = React.useRef(1); + + const [pending, setPending] = React.useState(); + + const load = async () => { + setPending(await DeviceApi.PendingList()); + }; + + const reload = () => { + loadKey.current += 1; + setPending(undefined); + }; + + return ( + + ( + + )} + /> + + ); +} + +function PendingDevicesList(p: { + pending: Device[]; + onReload: () => void; +}): React.ReactElement { + return ( + + + + + # + Model + Version + Maximum number of relays + Created + + + + {p.pending.map((dev) => ( + + + {dev.id} + + {dev.info.reference} + {dev.info.version} + {dev.info.max_relays} + + + + + ))} + +
+
+ ); +} diff --git a/central_frontend/src/utils/DateUtils.ts b/central_frontend/src/utils/DateUtils.ts new file mode 100644 index 0000000..1b74dea --- /dev/null +++ b/central_frontend/src/utils/DateUtils.ts @@ -0,0 +1,6 @@ +/** + * Get current UNIX time, in seconds + */ +export function time(): number { + return Math.floor(new Date().getTime() / 1000); +} diff --git a/central_frontend/src/widgets/SolarEnergyNavList.tsx b/central_frontend/src/widgets/SolarEnergyNavList.tsx index 355b07c..5b5a260 100644 --- a/central_frontend/src/widgets/SolarEnergyNavList.tsx +++ b/central_frontend/src/widgets/SolarEnergyNavList.tsx @@ -1,14 +1,4 @@ -import { - mdiAccountMultiple, - mdiAccountMusic, - mdiAlbum, - mdiApi, - mdiChartLine, - mdiCog, - mdiHome, - mdiInbox, - mdiMusic, -} from "@mdi/js"; +import { mdiHome, mdiNewBox } from "@mdi/js"; import Icon from "@mdi/react"; import { List, @@ -16,14 +6,11 @@ import { ListItemIcon, ListItemSecondaryAction, ListItemText, - ListSubheader, } from "@mui/material"; import { useLocation } from "react-router-dom"; -import { useAuthInfo } from "./BaseAuthenticatedPage"; import { RouterLink } from "./RouterLink"; export function SolarEnergyNavList(): React.ReactElement { - const user = useAuthInfo().info; return ( } /> + } + /> ); } diff --git a/central_frontend/src/widgets/SolarEnergyRouteContainer.tsx b/central_frontend/src/widgets/SolarEnergyRouteContainer.tsx new file mode 100644 index 0000000..9c7a706 --- /dev/null +++ b/central_frontend/src/widgets/SolarEnergyRouteContainer.tsx @@ -0,0 +1,27 @@ +import { Typography } from "@mui/material"; +import React, { PropsWithChildren } from "react"; + +export function SolarEnergyRouteContainer( + p: { + label: string; + actions?: React.ReactElement; + } & PropsWithChildren +): React.ReactElement { + return ( +
+
+ {p.label} + {p.actions ?? <>} +
+ + {p.children} +
+ ); +} diff --git a/central_frontend/src/widgets/TimeWidget.tsx b/central_frontend/src/widgets/TimeWidget.tsx new file mode 100644 index 0000000..135b521 --- /dev/null +++ b/central_frontend/src/widgets/TimeWidget.tsx @@ -0,0 +1,65 @@ +import { Tooltip } from "@mui/material"; +import date from "date-and-time"; +import { time } from "../utils/DateUtils"; + +export function formatDate(time: number): string { + const t = new Date(); + t.setTime(1000 * time); + return date.format(t, "DD/MM/YYYY HH:mm:ss"); +} + +export function timeDiff(a: number, b: number): string { + let diff = b - a; + + if (diff === 0) return "now"; + if (diff === 1) return "1 second"; + + if (diff < 60) { + return `${diff} seconds`; + } + + diff = Math.floor(diff / 60); + + if (diff === 1) return "1 minute"; + if (diff < 24) { + return `${diff} minutes`; + } + + diff = Math.floor(diff / 60); + + if (diff === 1) return "1 hour"; + if (diff < 24) { + return `${diff} hours`; + } + + const diffDays = Math.floor(diff / 24); + + if (diffDays === 1) return "1 day"; + if (diffDays < 31) { + return `${diffDays} days`; + } + + diff = Math.floor(diffDays / 31); + + if (diff < 12) { + return `${diff} month`; + } + + const diffYears = Math.floor(diffDays / 365); + + if (diffYears === 1) return "1 year"; + return `${diffYears} years`; +} + +export function timeDiffFromNow(t: number): string { + return timeDiff(t, time()); +} + +export function TimeWidget(p: { time?: number }): React.ReactElement { + if (!p.time) return <>; + return ( + + {timeDiffFromNow(p.time)} + + ); +}