Merge branch 'master' of http://10.0.0.10/pierre/SolarEnergy
This commit is contained in:
commit
d90d033e74
@ -31,6 +31,13 @@ pub fn get_ota_update(platform: OTAPlatform, version: &semver::Version) -> anyho
|
|||||||
Ok(std::fs::read(path)?)
|
Ok(std::fs::read(path)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete an OTA update
|
||||||
|
pub fn delete_update(platform: OTAPlatform, version: &semver::Version) -> anyhow::Result<()> {
|
||||||
|
let path = AppConfig::get().path_ota_update(platform, version);
|
||||||
|
std::fs::remove_file(path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the list of OTA software updates for a platform
|
/// Get the list of OTA software updates for a platform
|
||||||
pub fn get_ota_updates_for_platform(platform: OTAPlatform) -> anyhow::Result<Vec<OTAUpdate>> {
|
pub fn get_ota_updates_for_platform(platform: OTAPlatform) -> anyhow::Result<Vec<OTAUpdate>> {
|
||||||
let ota_path = AppConfig::get().ota_platform_dir(platform);
|
let ota_path = AppConfig::get().ota_platform_dir(platform);
|
||||||
@ -49,3 +56,14 @@ pub fn get_ota_updates_for_platform(platform: OTAPlatform) -> anyhow::Result<Vec
|
|||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get all the available OTA updates
|
||||||
|
pub fn get_all_ota_updates() -> anyhow::Result<Vec<OTAUpdate>> {
|
||||||
|
let mut out = vec![];
|
||||||
|
|
||||||
|
for p in OTAPlatform::supported_platforms() {
|
||||||
|
out.append(&mut get_ota_updates_for_platform(*p)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
@ -7,6 +7,13 @@ pub enum OTAPlatform {
|
|||||||
Wt32Eth01,
|
Wt32Eth01,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl OTAPlatform {
|
||||||
|
/// Get the list of supported platforms
|
||||||
|
pub fn supported_platforms() -> &'static [Self] {
|
||||||
|
&[OTAPlatform::Wt32Eth01]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Display for OTAPlatform {
|
impl Display for OTAPlatform {
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
let s = serde_json::to_string(&self).unwrap().replace('"', "");
|
let s = serde_json::to_string(&self).unwrap().replace('"', "");
|
||||||
|
@ -191,13 +191,19 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
|
|||||||
"/web_api/ota/{platform}/{version}",
|
"/web_api/ota/{platform}/{version}",
|
||||||
web::post().to(ota_controller::upload_firmware),
|
web::post().to(ota_controller::upload_firmware),
|
||||||
)
|
)
|
||||||
// TODO : list all ota software updates
|
.route(
|
||||||
|
"/web_api/ota/{platform}/{version}",
|
||||||
|
web::get().to(ota_controller::download_firmware),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/web_api/ota/{platform}/{version}",
|
||||||
|
web::delete().to(ota_controller::delete_update),
|
||||||
|
)
|
||||||
|
.route("/web_api/ota", web::get().to(ota_controller::list_all_ota))
|
||||||
.route(
|
.route(
|
||||||
"/web_api/ota/{platform}",
|
"/web_api/ota/{platform}",
|
||||||
web::get().to(ota_controller::list_updates_platform),
|
web::get().to(ota_controller::list_updates_platform),
|
||||||
)
|
)
|
||||||
// TODO : download a OTA file
|
|
||||||
// TODO : delete an OTA file
|
|
||||||
.route(
|
.route(
|
||||||
"/web_api/ota/set_desired_version",
|
"/web_api/ota/set_desired_version",
|
||||||
web::post().to(ota_controller::set_desired_version),
|
web::post().to(ota_controller::set_desired_version),
|
||||||
|
@ -10,7 +10,7 @@ use actix_multipart::form::MultipartForm;
|
|||||||
use actix_web::{web, HttpResponse};
|
use actix_web::{web, HttpResponse};
|
||||||
|
|
||||||
pub async fn supported_platforms() -> HttpResult {
|
pub async fn supported_platforms() -> HttpResult {
|
||||||
Ok(HttpResponse::Ok().json(vec![OTAPlatform::Wt32Eth01]))
|
Ok(HttpResponse::Ok().json(OTAPlatform::supported_platforms()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, MultipartForm)]
|
#[derive(Debug, MultipartForm)]
|
||||||
@ -20,14 +20,15 @@ pub struct UploadForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct UploadPath {
|
pub struct SpecificOTAVersionPath {
|
||||||
platform: OTAPlatform,
|
platform: OTAPlatform,
|
||||||
version: semver::Version,
|
version: semver::Version,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload a new firmware update
|
||||||
pub async fn upload_firmware(
|
pub async fn upload_firmware(
|
||||||
MultipartForm(form): MultipartForm<UploadForm>,
|
MultipartForm(form): MultipartForm<UploadForm>,
|
||||||
path: web::Path<UploadPath>,
|
path: web::Path<SpecificOTAVersionPath>,
|
||||||
) -> HttpResult {
|
) -> HttpResult {
|
||||||
if ota_manager::update_exists(path.platform, &path.version)? {
|
if ota_manager::update_exists(path.platform, &path.version)? {
|
||||||
return Ok(HttpResponse::Conflict()
|
return Ok(HttpResponse::Conflict()
|
||||||
@ -53,6 +54,42 @@ pub async fn upload_firmware(
|
|||||||
Ok(HttpResponse::Accepted().body("OTA update successfully saved."))
|
Ok(HttpResponse::Accepted().body("OTA update successfully saved."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download a firmware update
|
||||||
|
pub async fn download_firmware(path: web::Path<SpecificOTAVersionPath>) -> HttpResult {
|
||||||
|
if !ota_manager::update_exists(path.platform, &path.version)? {
|
||||||
|
return Ok(HttpResponse::NotFound().json("The requested firmware update was not found!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let firmware = ota_manager::get_ota_update(path.platform, &path.version)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok()
|
||||||
|
.content_type("application/octet-stream")
|
||||||
|
.append_header((
|
||||||
|
"content-disposition",
|
||||||
|
format!(
|
||||||
|
"attachment; filename=\"{}-{}.bin\"",
|
||||||
|
path.platform, path.version
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.body(firmware))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an uploaded firmware update
|
||||||
|
pub async fn delete_update(path: web::Path<SpecificOTAVersionPath>) -> HttpResult {
|
||||||
|
if !ota_manager::update_exists(path.platform, &path.version)? {
|
||||||
|
return Ok(HttpResponse::NotFound().json("The requested firmware update was not found!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
ota_manager::delete_update(path.platform, &path.version)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Accepted().finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the list of all OTA updates
|
||||||
|
pub async fn list_all_ota() -> HttpResult {
|
||||||
|
Ok(HttpResponse::Ok().json(ota_manager::get_all_ota_updates()?))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct ListOTAPath {
|
pub struct ListOTAPath {
|
||||||
platform: OTAPlatform,
|
platform: OTAPlatform,
|
||||||
|
628
central_frontend/package-lock.json
generated
628
central_frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -19,11 +19,14 @@
|
|||||||
"@mui/material": "^6.1.2",
|
"@mui/material": "^6.1.2",
|
||||||
"@mui/x-charts": "^7.19.0",
|
"@mui/x-charts": "^7.19.0",
|
||||||
"@mui/x-date-pickers": "^7.19.0",
|
"@mui/x-date-pickers": "^7.19.0",
|
||||||
|
"@types/semver": "^7.5.8",
|
||||||
"date-and-time": "^3.6.0",
|
"date-and-time": "^3.6.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"filesize": "^10.1.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.2"
|
"react-router-dom": "^6.26.2",
|
||||||
|
"semver": "^7.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
|
@ -15,6 +15,7 @@ import { NotFoundRoute } from "./routes/NotFoundRoute";
|
|||||||
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
|
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
|
||||||
import { RelaysListRoute } from "./routes/RelaysListRoute";
|
import { RelaysListRoute } from "./routes/RelaysListRoute";
|
||||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
||||||
|
import { OTARoute } from "./routes/OTARoute";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
|
if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled)
|
||||||
@ -28,6 +29,7 @@ export function App() {
|
|||||||
<Route path="devices" element={<DevicesRoute />} />
|
<Route path="devices" element={<DevicesRoute />} />
|
||||||
<Route path="dev/:id" element={<DeviceRoute />} />
|
<Route path="dev/:id" element={<DeviceRoute />} />
|
||||||
<Route path="relays" element={<RelaysListRoute />} />
|
<Route path="relays" element={<RelaysListRoute />} />
|
||||||
|
<Route path="ota" element={<OTARoute />} />
|
||||||
<Route path="logs" element={<LogsRoute />} />
|
<Route path="logs" element={<LogsRoute />} />
|
||||||
<Route path="*" element={<NotFoundRoute />} />
|
<Route path="*" element={<NotFoundRoute />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
87
central_frontend/src/api/OTAApi.ts
Normal file
87
central_frontend/src/api/OTAApi.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { APIClient } from "./ApiClient";
|
||||||
|
|
||||||
|
export interface OTAUpdate {
|
||||||
|
platform: string;
|
||||||
|
version: string;
|
||||||
|
file_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OTAAPI {
|
||||||
|
/**
|
||||||
|
* Get the list of supported OTA platforms
|
||||||
|
*/
|
||||||
|
static async SupportedPlatforms(): Promise<Array<string>> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: "/ota/supported_platforms",
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload new OTA firwmare
|
||||||
|
*/
|
||||||
|
static async UploadFirmware(
|
||||||
|
platform: string,
|
||||||
|
version: string,
|
||||||
|
firmware: File
|
||||||
|
): Promise<void> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("firmware", firmware);
|
||||||
|
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "POST",
|
||||||
|
uri: `/ota/${platform}/${version}`,
|
||||||
|
formData: fd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the link to download an OTA update
|
||||||
|
*/
|
||||||
|
static DownloadOTAUpdateURL(update: OTAUpdate): string {
|
||||||
|
return APIClient.backendURL() + `/ota/${update.platform}/${update.version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an update
|
||||||
|
*/
|
||||||
|
static async DeleteUpdate(update: OTAUpdate): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "DELETE",
|
||||||
|
uri: `/ota/${update.platform}/${update.version}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of OTA updates
|
||||||
|
*/
|
||||||
|
static async ListOTAUpdates(): Promise<OTAUpdate[]> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: "/ota",
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set desired version for one or mor devices
|
||||||
|
*/
|
||||||
|
static async SetDesiredVersion(
|
||||||
|
update: OTAUpdate,
|
||||||
|
all_devices: boolean,
|
||||||
|
devices?: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "POST",
|
||||||
|
uri: "/ota/set_desired_version",
|
||||||
|
jsonData: {
|
||||||
|
version: update.version,
|
||||||
|
platform: update.platform,
|
||||||
|
devices: all_devices ? undefined : devices!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
148
central_frontend/src/dialogs/DeployOTAUpdateDialogProvider.tsx
Normal file
148
central_frontend/src/dialogs/DeployOTAUpdateDialogProvider.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
FormGroup,
|
||||||
|
FormLabel,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
import { Device, DeviceApi } from "../api/DeviceApi";
|
||||||
|
import { OTAAPI, OTAUpdate } from "../api/OTAApi";
|
||||||
|
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||||
|
import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
|
||||||
|
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||||
|
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||||
|
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||||
|
|
||||||
|
export function DeployOTAUpdateDialogProvider(p: {
|
||||||
|
update: OTAUpdate;
|
||||||
|
onClose: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const loadingMessage = useLoadingMessage();
|
||||||
|
const alert = useAlert();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
|
||||||
|
const [devicesList, setDevicesList] = React.useState<Device[] | undefined>();
|
||||||
|
|
||||||
|
const loadDevicesList = async () => {
|
||||||
|
let list = await DeviceApi.ValidatedList();
|
||||||
|
list = list.filter((e) => e.info.reference == p.update.platform);
|
||||||
|
setDevicesList(list);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [allDevices, setAllDevices] = React.useState(false);
|
||||||
|
const [selectedDevices, setSelectedDevices] = React.useState<string[]>([]);
|
||||||
|
|
||||||
|
const startDeployment = async () => {
|
||||||
|
if (
|
||||||
|
allDevices &&
|
||||||
|
!(await confirm(
|
||||||
|
"Do you really want to deploy the update to all devices?"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
loadingMessage.show("Applying OTA update...");
|
||||||
|
|
||||||
|
await OTAAPI.SetDesiredVersion(p.update, allDevices, selectedDevices);
|
||||||
|
|
||||||
|
snackbar("The update was successfully applied!");
|
||||||
|
p.onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to deploy the udpate!", e);
|
||||||
|
alert(`Failed to deploy the udpate! ${e}`);
|
||||||
|
} finally {
|
||||||
|
loadingMessage.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onClose={p.onClose}>
|
||||||
|
<DialogTitle>
|
||||||
|
Deploy update <i>{p.update.version}</i> for platform{" "}
|
||||||
|
<i>{p.update.platform}</i>
|
||||||
|
</DialogTitle>
|
||||||
|
<AsyncWidget
|
||||||
|
loadKey={1}
|
||||||
|
load={loadDevicesList}
|
||||||
|
errMsg="Failed to load the list of devices!"
|
||||||
|
build={() => (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
You can choose to deploy update to all device or to target only a
|
||||||
|
part of devices:
|
||||||
|
</DialogContentText>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel>Gender</FormLabel>
|
||||||
|
<RadioGroup
|
||||||
|
name="radio-buttons-group"
|
||||||
|
value={allDevices}
|
||||||
|
onChange={(v) => setAllDevices(v.target.value == "true")}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
value={true}
|
||||||
|
control={<Radio />}
|
||||||
|
label="Deploy the update to all the devices of the platform"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value={false}
|
||||||
|
control={<Radio />}
|
||||||
|
label="Deploy the update to a limited range of devices"
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
{!allDevices && (
|
||||||
|
<Typography>
|
||||||
|
There are no devices to which the update can be deployed.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{!allDevices && (
|
||||||
|
<FormGroup>
|
||||||
|
{devicesList?.map((d) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedDevices.includes(d.id)}
|
||||||
|
onChange={(_e, v) => {
|
||||||
|
if (v) {
|
||||||
|
selectedDevices.push(d.id);
|
||||||
|
setSelectedDevices([...selectedDevices]);
|
||||||
|
} else
|
||||||
|
setSelectedDevices(
|
||||||
|
selectedDevices.filter((e) => e != d.id)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={d.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={p.onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={startDeployment}
|
||||||
|
autoFocus
|
||||||
|
disabled={!allDevices && selectedDevices.length == 0}
|
||||||
|
>
|
||||||
|
Start deployment
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
132
central_frontend/src/dialogs/UploadUpdateDialog.tsx
Normal file
132
central_frontend/src/dialogs/UploadUpdateDialog.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
styled,
|
||||||
|
} from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
import { OTAAPI } from "../api/OTAApi";
|
||||||
|
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||||
|
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||||
|
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||||
|
import { checkVersion } from "../utils/StringsUtils";
|
||||||
|
import { TextInput } from "../widgets/forms/TextInput";
|
||||||
|
|
||||||
|
const VisuallyHiddenInput = styled("input")({
|
||||||
|
clip: "rect(0 0 0 0)",
|
||||||
|
clipPath: "inset(50%)",
|
||||||
|
height: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
width: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function UploadUpdateDialog(p: {
|
||||||
|
platforms: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const alert = useAlert();
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
const loadingMessage = useLoadingMessage();
|
||||||
|
|
||||||
|
const [platform, setPlatform] = React.useState<string | undefined>();
|
||||||
|
const [version, setVersion] = React.useState<string | undefined>();
|
||||||
|
const [file, setFile] = React.useState<File | undefined>();
|
||||||
|
|
||||||
|
const canSubmit = platform && version && checkVersion(version) && file;
|
||||||
|
|
||||||
|
const upload = async () => {
|
||||||
|
try {
|
||||||
|
loadingMessage.show("Uploading firmware...");
|
||||||
|
await OTAAPI.UploadFirmware(platform!, version!, file!);
|
||||||
|
|
||||||
|
snackbar("Successfully uploaded new firmware!");
|
||||||
|
|
||||||
|
p.onCreated();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(`Failed to upload firmware: ${e}`);
|
||||||
|
} finally {
|
||||||
|
loadingMessage.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onClose={p.onClose}>
|
||||||
|
<DialogTitle>Submit a new update</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
You can upload a new firmware using this form.
|
||||||
|
</DialogContentText>
|
||||||
|
<br />
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Platform</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Platform"
|
||||||
|
value={platform}
|
||||||
|
onChange={(e) => setPlatform(e.target.value)}
|
||||||
|
variant="standard"
|
||||||
|
>
|
||||||
|
{p.platforms.map((p) => (
|
||||||
|
<MenuItem key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<TextInput
|
||||||
|
editable
|
||||||
|
label="Version"
|
||||||
|
helperText="The version shall follow semantics requirements"
|
||||||
|
value={version}
|
||||||
|
onValueChange={setVersion}
|
||||||
|
checkValue={checkVersion}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
component="label"
|
||||||
|
role={undefined}
|
||||||
|
variant={file ? "contained" : "outlined"}
|
||||||
|
tabIndex={-1}
|
||||||
|
startIcon={<CloudUploadIcon />}
|
||||||
|
>
|
||||||
|
Upload file
|
||||||
|
<VisuallyHiddenInput
|
||||||
|
type="file"
|
||||||
|
onChange={(event) =>
|
||||||
|
setFile(
|
||||||
|
(event.target.files?.length ?? 0) > 0
|
||||||
|
? event.target.files![0]
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={p.onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={!canSubmit} onClick={upload}>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
194
central_frontend/src/routes/OTARoute.tsx
Normal file
194
central_frontend/src/routes/OTARoute.tsx
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import { mdiFolderUploadOutline } from "@mdi/js";
|
||||||
|
import Icon from "@mdi/react";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import FileUploadIcon from "@mui/icons-material/FileUpload";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import {
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { filesize } from "filesize";
|
||||||
|
import React from "react";
|
||||||
|
import { OTAAPI, OTAUpdate } from "../api/OTAApi";
|
||||||
|
import { DeployOTAUpdateDialogProvider } from "../dialogs/DeployOTAUpdateDialogProvider";
|
||||||
|
import { UploadUpdateDialog } from "../dialogs/UploadUpdateDialog";
|
||||||
|
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||||
|
import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
|
||||||
|
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||||
|
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||||
|
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||||
|
import { RouterLink } from "../widgets/RouterLink";
|
||||||
|
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
|
||||||
|
|
||||||
|
export function OTARoute(): React.ReactElement {
|
||||||
|
const [list, setList] = React.useState<string[] | undefined>();
|
||||||
|
const load = async () => {
|
||||||
|
setList(await OTAAPI.SupportedPlatforms());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncWidget
|
||||||
|
loadKey={1}
|
||||||
|
ready={!!list}
|
||||||
|
load={load}
|
||||||
|
errMsg="Failed to load OTA screen!"
|
||||||
|
build={() => <_OTARoute platforms={list!} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _OTARoute(p: { platforms: Array<string> }): React.ReactElement {
|
||||||
|
const key = React.useRef(1);
|
||||||
|
const [showUploadDialog, setShowUploadDialog] = React.useState(false);
|
||||||
|
|
||||||
|
const [list, setList] = React.useState<undefined | OTAUpdate[]>();
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const list = await OTAAPI.ListOTAUpdates();
|
||||||
|
list.sort((a, b) =>
|
||||||
|
`${a.platform}#${a.version}`.localeCompare(`${b.platform}#${b.version}`)
|
||||||
|
);
|
||||||
|
list.reverse();
|
||||||
|
setList(list);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
key.current += 1;
|
||||||
|
setList(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SolarEnergyRouteContainer
|
||||||
|
label="OTA"
|
||||||
|
actions={
|
||||||
|
<span>
|
||||||
|
<Tooltip title="Refresh the list of updates">
|
||||||
|
<IconButton onClick={reload}>
|
||||||
|
<RefreshIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Upload a new update">
|
||||||
|
<IconButton onClick={() => setShowUploadDialog(true)}>
|
||||||
|
<FileUploadIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showUploadDialog && (
|
||||||
|
<UploadUpdateDialog
|
||||||
|
platforms={p.platforms}
|
||||||
|
onClose={() => setShowUploadDialog(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setShowUploadDialog(false);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AsyncWidget
|
||||||
|
loadKey={key.current}
|
||||||
|
ready={!!list}
|
||||||
|
errMsg="Failed to load the list of OTA updates!"
|
||||||
|
load={load}
|
||||||
|
build={() => <_OTAList list={list!} onReload={reload} />}
|
||||||
|
/>
|
||||||
|
</SolarEnergyRouteContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _OTAList(p: {
|
||||||
|
list: OTAUpdate[];
|
||||||
|
onReload: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const alert = useAlert();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const loadingMessage = useLoadingMessage();
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
|
||||||
|
const [deployUpdate, setDeployUpdate] = React.useState<
|
||||||
|
OTAUpdate | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
const deleteUpdate = async (update: OTAUpdate) => {
|
||||||
|
if (
|
||||||
|
!(await confirm(
|
||||||
|
`Do you really want to delete the update for platform ${update.platform} version ${update.version}?`
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingMessage.show("Deleting update...");
|
||||||
|
|
||||||
|
await OTAAPI.DeleteUpdate(update);
|
||||||
|
|
||||||
|
snackbar("The update was successfully deleted!");
|
||||||
|
|
||||||
|
p.onReload();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to delete update!", e);
|
||||||
|
alert(`Failed to delete the update! ${e}`);
|
||||||
|
} finally {
|
||||||
|
loadingMessage.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{deployUpdate && (
|
||||||
|
<DeployOTAUpdateDialogProvider
|
||||||
|
update={deployUpdate!}
|
||||||
|
onClose={() => setDeployUpdate(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell align="center">Platform</TableCell>
|
||||||
|
<TableCell align="center">Version</TableCell>
|
||||||
|
<TableCell align="center">File size</TableCell>
|
||||||
|
<TableCell align="center"></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{p.list.map((row, num) => (
|
||||||
|
<TableRow hover key={num}>
|
||||||
|
<TableCell align="center">{row.platform}</TableCell>
|
||||||
|
<TableCell align="center">{row.version}</TableCell>
|
||||||
|
<TableCell align="center">{filesize(row.file_size)}</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
|
<Tooltip title="Deploy the update to devices">
|
||||||
|
<IconButton onClick={() => setDeployUpdate(row)}>
|
||||||
|
<Icon path={mdiFolderUploadOutline} size={1} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Download a copy of the firmware">
|
||||||
|
<RouterLink to={OTAAPI.DownloadOTAUpdateURL(row)}>
|
||||||
|
<IconButton>
|
||||||
|
<DownloadIcon />
|
||||||
|
</IconButton>
|
||||||
|
</RouterLink>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete firmware update">
|
||||||
|
<IconButton onClick={() => deleteUpdate(row)}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
import { SemVer } from "semver";
|
||||||
import { LenConstraint } from "../api/ServerApi";
|
import { LenConstraint } from "../api/ServerApi";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -6,3 +7,16 @@ import { LenConstraint } from "../api/ServerApi";
|
|||||||
export function lenValid(s: string, c: LenConstraint): boolean {
|
export function lenValid(s: string, c: LenConstraint): boolean {
|
||||||
return s.length >= c.min && s.length <= c.max;
|
return s.length >= c.min && s.length <= c.max;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check out whether a given version number respect semantics requirements or not
|
||||||
|
*/
|
||||||
|
export function checkVersion(v: string): boolean {
|
||||||
|
try {
|
||||||
|
new SemVer(v, { loose: false });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -2,6 +2,7 @@ import {
|
|||||||
mdiChip,
|
mdiChip,
|
||||||
mdiElectricSwitch,
|
mdiElectricSwitch,
|
||||||
mdiHome,
|
mdiHome,
|
||||||
|
mdiMonitorArrowDown,
|
||||||
mdiNewBox,
|
mdiNewBox,
|
||||||
mdiNotebookMultiple,
|
mdiNotebookMultiple,
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
@ -41,6 +42,11 @@ export function SolarEnergyNavList(): React.ReactElement {
|
|||||||
uri="/relays"
|
uri="/relays"
|
||||||
icon={<Icon path={mdiElectricSwitch} size={1} />}
|
icon={<Icon path={mdiElectricSwitch} size={1} />}
|
||||||
/>
|
/>
|
||||||
|
<NavLink
|
||||||
|
label="OTA"
|
||||||
|
uri="/ota"
|
||||||
|
icon={<Icon path={mdiMonitorArrowDown} size={1} />}
|
||||||
|
/>
|
||||||
<NavLink
|
<NavLink
|
||||||
label="Logging"
|
label="Logging"
|
||||||
uri="/logs"
|
uri="/logs"
|
||||||
|
Loading…
Reference in New Issue
Block a user