This commit is contained in:
Pierre HUBERT 2024-10-12 15:39:05 +02:00
commit d90d033e74
13 changed files with 1042 additions and 254 deletions

View File

@ -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)
}

View File

@ -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('"', "");

View File

@ -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),

View File

@ -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,

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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>

View 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!,
},
});
}
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View File

@ -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;
}
}

View File

@ -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"