Skip to content

Commit 1f07dcd

Browse files
authored
OTA Upload Feature (#162)
* Improve restart behaviour under esp8266 * Backend to support firmware update over HTTP * UI for uploading new firmware * Documentation changes
1 parent e86607b commit 1f07dcd

25 files changed

+437
-16
lines changed

README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Provides many of the features required for IoT projects:
1616
* Configurable Access Point - Can be continuous or automatically enabled when WiFi connection fails
1717
* Network Time - Synchronization with NTP
1818
* MQTT - Connection to an MQTT broker for automation and monitoring
19-
* Remote Firmware Updates - Enable secured OTA updates
19+
* Remote Firmware Updates - Firmware replacement using OTA update or upload via UI
2020
* Security - Protected RESTful endpoints and a secured user interface
2121

2222
Features may be [enabled or disabled](#selecting-features) as required at compile time.
@@ -174,15 +174,17 @@ Customize the settings as you see fit. A value of 0 will disable the specified f
174174
-D FT_MQTT=1
175175
-D FT_NTP=1
176176
-D FT_OTA=1
177+
-D FT_UPLOAD_FIRMWARE=1
177178
```
178179

179-
Flag | Description
180-
------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------
181-
FT_PROJECT | Controls whether the "project" section of the UI is enabled. Disable this if you don't intend to have your own screens in the UI.
182-
FT_SECURITY | Controls whether the [security features](#security-features) are enabled. Disabling this means you won't need to authenticate to access the device and all authentication predicates will be bypassed.
183-
FT_MQTT | Controls whether the MQTT features are enabled. Disable this if your project does not require MQTT support.
184-
FT_NTP | Controls whether network time protocol synchronization features are enabled. Disable this if your project does not require accurate time.
185-
FT_OTA | Controls whether OTA update support is enabled. Disable this if you won't be using the remote update feature.
180+
Flag | Description
181+
------------------ | ----------------------------------------------
182+
FT_PROJECT | Controls whether the "project" section of the UI is enabled. Disable this if you don't intend to have your own screens in the UI.
183+
FT_SECURITY | Controls whether the [security features](#security-features) are enabled. Disabling this means you won't need to authenticate to access the device and all authentication predicates will be bypassed.
184+
FT_MQTT | Controls whether the MQTT features are enabled. Disable this if your project does not require MQTT support.
185+
FT_NTP | Controls whether network time protocol synchronization features are enabled. Disable this if your project does not require accurate time.
186+
FT_OTA | Controls whether OTA update support is enabled. Disable this if you won't be using the remote update feature.
187+
FT_UPLOAD_FIRMWARE | Controls the whether the manual upload firmware feature is enabled. Disable this if you won't be manually uploading firmware.
186188

187189
## Factory settings
188190

features.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ build_flags =
55
-D FT_MQTT=1
66
-D FT_NTP=1
77
-D FT_OTA=1
8+
-D FT_UPLOAD_FIRMWARE=1

interface/package-lock.json

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

interface/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"notistack": "^0.9.16",
2222
"react": "^16.13.1",
2323
"react-dom": "^16.13.1",
24+
"react-dropzone": "^11.0.1",
2425
"react-form-validator-core": "^0.6.4",
2526
"react-material-ui-form-validator": "^2.0.10",
2627
"react-router": "^5.1.2",

interface/src/ap/APSettingsForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class APSettingsForm extends React.Component<APSettingsFormProps> {
1818
return (
1919
<ValidatorForm onSubmit={saveData} ref="APSettingsForm">
2020
<SelectValidator name="provision_mode"
21-
label="Provide Access Point..."
21+
label="Provide Access Point&hellip;"
2222
value={data.provision_mode}
2323
fullWidth
2424
variant="outlined"

interface/src/api/Endpoints.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const LIST_NETWORKS_ENDPOINT = ENDPOINT_ROOT + "listNetworks";
1111
export const WIFI_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "wifiSettings";
1212
export const WIFI_STATUS_ENDPOINT = ENDPOINT_ROOT + "wifiStatus";
1313
export const OTA_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "otaSettings";
14+
export const UPLOAD_FIRMWARE_ENDPOINT = ENDPOINT_ROOT + "uploadFirmware";
1415
export const MQTT_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "mqttSettings";
1516
export const MQTT_STATUS_ENDPOINT = ENDPOINT_ROOT + "mqttStatus";
1617
export const SYSTEM_STATUS_ENDPOINT = ENDPOINT_ROOT + "systemStatus";

interface/src/authentication/Authentication.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,46 @@ export function authorizedFetch(url: RequestInfo, params?: RequestInit): Promise
5353
return fetch(url, params);
5454
}
5555

56+
/**
57+
* fetch() does not yet support upload progress, this wrapper allows us to configure the xhr request
58+
* for a single file upload and takes care of adding the Authroization header and redirecting on
59+
* authroization errors as we do for normal fetch operations.
60+
*/
61+
export function redirectingAuthorizedUpload(xhr: XMLHttpRequest, url: string, file: File, onProgress: (event: ProgressEvent<EventTarget>) => void): Promise<void> {
62+
return new Promise((resolve, reject) => {
63+
xhr.open("POST", url, true);
64+
const accessToken = getStorage().getItem(ACCESS_TOKEN);
65+
if (accessToken) {
66+
xhr.withCredentials = true;
67+
xhr.setRequestHeader("Authorization", 'Bearer ' + accessToken);
68+
}
69+
xhr.upload.onprogress = onProgress;
70+
xhr.onload = function () {
71+
if (xhr.status === 401 || xhr.status === 403) {
72+
history.push("/unauthorized");
73+
} else {
74+
resolve();
75+
}
76+
};
77+
xhr.onerror = function (event: ProgressEvent<EventTarget>) {
78+
reject(new DOMException('Error', 'UploadError'));
79+
};
80+
xhr.onabort = function () {
81+
reject(new DOMException('Aborted', 'AbortError'));
82+
};
83+
const formData = new FormData();
84+
formData.append('file', file);
85+
xhr.send(formData);
86+
});
87+
}
88+
5689
/**
5790
* Wraps the normal fetch routene which redirects on 401 response.
5891
*/
5992
export function redirectingAuthorizedFetch(url: RequestInfo, params?: RequestInit): Promise<Response> {
6093
return new Promise<Response>((resolve, reject) => {
6194
authorizedFetch(url, params).then(response => {
62-
if (response.status === 401) {
95+
if (response.status === 401 || response.status === 403) {
6396
history.push("/unauthorized");
6497
} else {
6598
resolve(response);

interface/src/components/RestFormLoader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export default function RestFormLoader<D>(props: RestFormLoaderProps<D>) {
3535
<div className={classes.loadingSettings}>
3636
<LinearProgress className={classes.loadingSettingsDetails} />
3737
<Typography variant="h6" className={classes.loadingSettingsDetails}>
38-
Loading...
38+
Loading&hellip;
3939
</Typography>
4040
</div>
4141
);
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React, { FC, Fragment } from 'react';
2+
import { useDropzone, DropzoneState } from 'react-dropzone';
3+
4+
import { makeStyles, createStyles } from '@material-ui/styles';
5+
import CloudUploadIcon from '@material-ui/icons/CloudUpload';
6+
import CancelIcon from '@material-ui/icons/Cancel';
7+
import { Theme, Box, Typography, LinearProgress, Button } from '@material-ui/core';
8+
9+
interface SingleUploadStyleProps extends DropzoneState {
10+
uploading: boolean;
11+
}
12+
13+
const progressPercentage = (progress: ProgressEvent) => Math.round((progress.loaded * 100) / progress.total);
14+
15+
const getBorderColor = (theme: Theme, props: SingleUploadStyleProps) => {
16+
if (props.isDragAccept) {
17+
return theme.palette.success.main;
18+
}
19+
if (props.isDragReject) {
20+
return theme.palette.error.main;
21+
}
22+
if (props.isDragActive) {
23+
return theme.palette.info.main;
24+
}
25+
return theme.palette.grey[700];
26+
}
27+
28+
const useStyles = makeStyles((theme: Theme) => createStyles({
29+
dropzone: {
30+
padding: theme.spacing(8, 2),
31+
borderWidth: 2,
32+
borderRadius: 2,
33+
borderStyle: 'dashed',
34+
color: theme.palette.grey[700],
35+
transition: 'border .24s ease-in-out',
36+
cursor: (props: SingleUploadStyleProps) => props.uploading ? 'default' : 'pointer',
37+
width: '100%',
38+
borderColor: (props: SingleUploadStyleProps) => getBorderColor(theme, props)
39+
}
40+
}));
41+
42+
export interface SingleUploadProps {
43+
onDrop: (acceptedFiles: File[]) => void;
44+
onCancel: () => void;
45+
accept?: string | string[];
46+
uploading: boolean;
47+
progress?: ProgressEvent;
48+
}
49+
50+
const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, accept, uploading, progress }) => {
51+
const dropzoneState = useDropzone({ onDrop, accept, disabled: uploading, multiple: false });
52+
const { getRootProps, getInputProps } = dropzoneState;
53+
const classes = useStyles({ ...dropzoneState, uploading });
54+
55+
56+
const renderProgressText = () => {
57+
if (uploading) {
58+
if (progress?.lengthComputable) {
59+
return `Uploading: ${progressPercentage(progress)}%`;
60+
}
61+
return "Uploading\u2026";
62+
}
63+
return "Drop file here or click to browse";
64+
}
65+
66+
const renderProgress = (progress?: ProgressEvent) => (
67+
<LinearProgress
68+
variant={!progress || progress.lengthComputable ? "determinate" : "indeterminate"}
69+
value={!progress ? 0 : progress.lengthComputable ? progressPercentage(progress) : 0}
70+
/>
71+
);
72+
73+
return (
74+
<div {...getRootProps({ className: classes.dropzone })}>
75+
<input {...getInputProps()} />
76+
<Box flexDirection="column" display="flex" alignItems="center">
77+
<CloudUploadIcon fontSize='large' />
78+
<Typography variant="h6">
79+
{renderProgressText()}
80+
</Typography>
81+
{uploading && (
82+
<Fragment>
83+
<Box width="100%" p={2}>
84+
{renderProgress(progress)}
85+
</Box>
86+
<Button startIcon={<CancelIcon />} variant="contained" color="secondary" onClick={onCancel}>
87+
Cancel
88+
</Button>
89+
</Fragment>
90+
)}
91+
</Box>
92+
</div>
93+
);
94+
}
95+
96+
export default SingleUpload;

interface/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export { default as RestFormLoader } from './RestFormLoader';
88
export { default as SectionContent } from './SectionContent';
99
export { default as WebSocketFormLoader } from './WebSocketFormLoader';
1010
export { default as ErrorButton } from './ErrorButton';
11+
export { default as SingleUpload } from './SingleUpload';
1112

1213
export * from './RestFormLoader';
1314
export * from './RestController';

0 commit comments

Comments
 (0)