OTA Upload Feature (#162)
* Improve restart behaviour under esp8266 * Backend to support firmware update over HTTP * UI for uploading new firmware * Documentation changes
This commit is contained in:
		
							
								
								
									
										23
									
								
								interface/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										23
									
								
								interface/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -2311,6 +2311,11 @@ | ||||
|       "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", | ||||
|       "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==" | ||||
|     }, | ||||
|     "attr-accept": { | ||||
|       "version": "2.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.1.0.tgz", | ||||
|       "integrity": "sha512-sLzVM3zCCmmDtDNhI0i96k6PUztkotSOXqE4kDGQt/6iDi5M+H0srjeF+QC6jN581l4X/Zq3Zu/tgcErEssavg==" | ||||
|     }, | ||||
|     "autoprefixer": { | ||||
|       "version": "9.7.5", | ||||
|       "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.7.5.tgz", | ||||
| @@ -5587,6 +5592,14 @@ | ||||
|         "schema-utils": "^2.5.0" | ||||
|       } | ||||
|     }, | ||||
|     "file-selector": { | ||||
|       "version": "0.1.12", | ||||
|       "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.1.12.tgz", | ||||
|       "integrity": "sha512-Kx7RTzxyQipHuiqyZGf+Nz4vY9R1XGxuQl/hLoJwq+J4avk/9wxxgZyHKtbyIPJmbD4A66DWGYfyykWNpcYutQ==", | ||||
|       "requires": { | ||||
|         "tslib": "^1.9.0" | ||||
|       } | ||||
|     }, | ||||
|     "filesize": { | ||||
|       "version": "6.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.0.1.tgz", | ||||
| @@ -10540,6 +10553,16 @@ | ||||
|         "scheduler": "^0.19.1" | ||||
|       } | ||||
|     }, | ||||
|     "react-dropzone": { | ||||
|       "version": "11.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-11.0.1.tgz", | ||||
|       "integrity": "sha512-x/6wqRHaR8jsrNiu/boVMIPYuoxb83Vyfv77hO7/3ZRn8Pr+KH5onsCsB8MLBa3zdJl410C5FXPUINbu16XIzw==", | ||||
|       "requires": { | ||||
|         "attr-accept": "^2.0.0", | ||||
|         "file-selector": "^0.1.12", | ||||
|         "prop-types": "^15.7.2" | ||||
|       } | ||||
|     }, | ||||
|     "react-error-overlay": { | ||||
|       "version": "6.0.7", | ||||
|       "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.7.tgz", | ||||
|   | ||||
| @@ -21,6 +21,7 @@ | ||||
|     "notistack": "^0.9.16", | ||||
|     "react": "^16.13.1", | ||||
|     "react-dom": "^16.13.1", | ||||
|     "react-dropzone": "^11.0.1", | ||||
|     "react-form-validator-core": "^0.6.4", | ||||
|     "react-material-ui-form-validator": "^2.0.10", | ||||
|     "react-router": "^5.1.2", | ||||
|   | ||||
| @@ -18,7 +18,7 @@ class APSettingsForm extends React.Component<APSettingsFormProps> { | ||||
|     return ( | ||||
|       <ValidatorForm onSubmit={saveData} ref="APSettingsForm"> | ||||
|         <SelectValidator name="provision_mode" | ||||
|           label="Provide Access Point..." | ||||
|           label="Provide Access Point…" | ||||
|           value={data.provision_mode} | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|   | ||||
| @@ -11,6 +11,7 @@ export const LIST_NETWORKS_ENDPOINT = ENDPOINT_ROOT + "listNetworks"; | ||||
| export const WIFI_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "wifiSettings"; | ||||
| export const WIFI_STATUS_ENDPOINT = ENDPOINT_ROOT + "wifiStatus"; | ||||
| export const OTA_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "otaSettings"; | ||||
| export const UPLOAD_FIRMWARE_ENDPOINT = ENDPOINT_ROOT + "uploadFirmware"; | ||||
| export const MQTT_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "mqttSettings"; | ||||
| export const MQTT_STATUS_ENDPOINT = ENDPOINT_ROOT + "mqttStatus"; | ||||
| export const SYSTEM_STATUS_ENDPOINT = ENDPOINT_ROOT + "systemStatus"; | ||||
|   | ||||
| @@ -53,13 +53,46 @@ export function authorizedFetch(url: RequestInfo, params?: RequestInit): Promise | ||||
|   return fetch(url, params); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * fetch() does not yet support upload progress, this wrapper allows us to configure the xhr request  | ||||
|  * for a single file upload and takes care of adding the Authroization header and redirecting on  | ||||
|  * authroization errors as we do for normal fetch operations. | ||||
|  */ | ||||
| export function redirectingAuthorizedUpload(xhr: XMLHttpRequest, url: string, file: File, onProgress: (event: ProgressEvent<EventTarget>) => void): Promise<void> { | ||||
|   return new Promise((resolve, reject) => { | ||||
|     xhr.open("POST", url, true); | ||||
|     const accessToken = getStorage().getItem(ACCESS_TOKEN); | ||||
|     if (accessToken) { | ||||
|       xhr.withCredentials = true; | ||||
|       xhr.setRequestHeader("Authorization", 'Bearer ' + accessToken); | ||||
|     } | ||||
|     xhr.upload.onprogress = onProgress; | ||||
|     xhr.onload = function () { | ||||
|       if (xhr.status === 401 || xhr.status === 403) { | ||||
|         history.push("/unauthorized"); | ||||
|       } else { | ||||
|         resolve(); | ||||
|       } | ||||
|     }; | ||||
|     xhr.onerror = function (event: ProgressEvent<EventTarget>) { | ||||
|       reject(new DOMException('Error', 'UploadError')); | ||||
|     }; | ||||
|     xhr.onabort = function () { | ||||
|       reject(new DOMException('Aborted', 'AbortError')); | ||||
|     }; | ||||
|     const formData = new FormData(); | ||||
|     formData.append('file', file); | ||||
|     xhr.send(formData); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Wraps the normal fetch routene which redirects on 401 response. | ||||
|  */ | ||||
| export function redirectingAuthorizedFetch(url: RequestInfo, params?: RequestInit): Promise<Response> { | ||||
|   return new Promise<Response>((resolve, reject) => { | ||||
|     authorizedFetch(url, params).then(response => { | ||||
|       if (response.status === 401) { | ||||
|       if (response.status === 401 || response.status === 403) { | ||||
|         history.push("/unauthorized"); | ||||
|       } else { | ||||
|         resolve(response); | ||||
|   | ||||
| @@ -35,7 +35,7 @@ export default function RestFormLoader<D>(props: RestFormLoaderProps<D>) { | ||||
|       <div className={classes.loadingSettings}> | ||||
|         <LinearProgress className={classes.loadingSettingsDetails} /> | ||||
|         <Typography variant="h6" className={classes.loadingSettingsDetails}> | ||||
|           Loading... | ||||
|           Loading… | ||||
|         </Typography> | ||||
|       </div> | ||||
|     ); | ||||
|   | ||||
							
								
								
									
										96
									
								
								interface/src/components/SingleUpload.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								interface/src/components/SingleUpload.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | ||||
| import React, { FC, Fragment } from 'react'; | ||||
| import { useDropzone, DropzoneState } from 'react-dropzone'; | ||||
|  | ||||
| import { makeStyles, createStyles } from '@material-ui/styles'; | ||||
| import CloudUploadIcon from '@material-ui/icons/CloudUpload'; | ||||
| import CancelIcon from '@material-ui/icons/Cancel'; | ||||
| import { Theme, Box, Typography, LinearProgress, Button } from '@material-ui/core'; | ||||
|  | ||||
| interface SingleUploadStyleProps extends DropzoneState { | ||||
|   uploading: boolean; | ||||
| } | ||||
|  | ||||
| const progressPercentage = (progress: ProgressEvent) => Math.round((progress.loaded * 100) / progress.total); | ||||
|  | ||||
| const getBorderColor = (theme: Theme, props: SingleUploadStyleProps) => { | ||||
|   if (props.isDragAccept) { | ||||
|     return theme.palette.success.main; | ||||
|   } | ||||
|   if (props.isDragReject) { | ||||
|     return theme.palette.error.main; | ||||
|   } | ||||
|   if (props.isDragActive) { | ||||
|     return theme.palette.info.main; | ||||
|   } | ||||
|   return theme.palette.grey[700]; | ||||
| } | ||||
|  | ||||
| const useStyles = makeStyles((theme: Theme) => createStyles({ | ||||
|   dropzone: { | ||||
|     padding: theme.spacing(8, 2), | ||||
|     borderWidth: 2, | ||||
|     borderRadius: 2, | ||||
|     borderStyle: 'dashed', | ||||
|     color: theme.palette.grey[700], | ||||
|     transition: 'border .24s ease-in-out', | ||||
|     cursor: (props: SingleUploadStyleProps) => props.uploading ? 'default' : 'pointer', | ||||
|     width: '100%', | ||||
|     borderColor: (props: SingleUploadStyleProps) => getBorderColor(theme, props) | ||||
|   } | ||||
| })); | ||||
|  | ||||
| export interface SingleUploadProps { | ||||
|   onDrop: (acceptedFiles: File[]) => void; | ||||
|   onCancel: () => void; | ||||
|   accept?: string | string[]; | ||||
|   uploading: boolean; | ||||
|   progress?: ProgressEvent; | ||||
| } | ||||
|  | ||||
| const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, accept, uploading, progress }) => { | ||||
|   const dropzoneState = useDropzone({ onDrop, accept, disabled: uploading, multiple: false }); | ||||
|   const { getRootProps, getInputProps } = dropzoneState; | ||||
|   const classes = useStyles({ ...dropzoneState, uploading }); | ||||
|  | ||||
|  | ||||
|   const renderProgressText = () => { | ||||
|     if (uploading) { | ||||
|       if (progress?.lengthComputable) { | ||||
|         return `Uploading: ${progressPercentage(progress)}%`; | ||||
|       } | ||||
|       return "Uploading\u2026"; | ||||
|     } | ||||
|     return "Drop file here or click to browse"; | ||||
|   } | ||||
|  | ||||
|   const renderProgress = (progress?: ProgressEvent) => ( | ||||
|     <LinearProgress | ||||
|       variant={!progress || progress.lengthComputable ? "determinate" : "indeterminate"} | ||||
|       value={!progress ? 0 : progress.lengthComputable ? progressPercentage(progress) : 0} | ||||
|     /> | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <div {...getRootProps({ className: classes.dropzone })}> | ||||
|       <input {...getInputProps()} /> | ||||
|       <Box flexDirection="column" display="flex" alignItems="center"> | ||||
|         <CloudUploadIcon fontSize='large' /> | ||||
|         <Typography variant="h6"> | ||||
|           {renderProgressText()} | ||||
|         </Typography> | ||||
|         {uploading && ( | ||||
|           <Fragment> | ||||
|             <Box width="100%" p={2}> | ||||
|               {renderProgress(progress)} | ||||
|             </Box> | ||||
|             <Button startIcon={<CancelIcon />} variant="contained" color="secondary" onClick={onCancel}> | ||||
|               Cancel | ||||
|             </Button> | ||||
|           </Fragment> | ||||
|         )} | ||||
|       </Box> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default SingleUpload; | ||||
| @@ -8,6 +8,7 @@ export { default as RestFormLoader } from './RestFormLoader'; | ||||
| export { default as SectionContent } from './SectionContent'; | ||||
| export { default as WebSocketFormLoader } from './WebSocketFormLoader'; | ||||
| export { default as ErrorButton } from './ErrorButton'; | ||||
| export { default as SingleUpload } from './SingleUpload'; | ||||
|  | ||||
| export * from './RestFormLoader'; | ||||
| export * from './RestController'; | ||||
|   | ||||
| @@ -4,4 +4,5 @@ export interface Features { | ||||
|   mqtt: boolean; | ||||
|   ntp: boolean; | ||||
|   ota: boolean; | ||||
|   upload_firmware: boolean; | ||||
| } | ||||
|   | ||||
| @@ -3,12 +3,14 @@ import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' | ||||
|  | ||||
| import { Tabs, Tab } from '@material-ui/core'; | ||||
|  | ||||
| import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext'; | ||||
|  | ||||
| import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication'; | ||||
| import { MenuAppBar } from '../components'; | ||||
|  | ||||
| import SystemStatusController from './SystemStatusController'; | ||||
| import OTASettingsController from './OTASettingsController'; | ||||
| import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext'; | ||||
| import UploadFirmwareController from './UploadFirmwareController'; | ||||
|  | ||||
| type SystemProps = AuthenticatedContextProps & RouteComponentProps & WithFeaturesProps; | ||||
|  | ||||
| @@ -27,12 +29,18 @@ class System extends Component<SystemProps> { | ||||
|           {features.ota && ( | ||||
|             <Tab value="/system/ota" label="OTA Settings" disabled={!authenticatedContext.me.admin} /> | ||||
|           )} | ||||
|           {features.upload_firmware && ( | ||||
|             <Tab value="/system/upload" label="Upload Firmware" disabled={!authenticatedContext.me.admin} /> | ||||
|           )} | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact path="/system/status" component={SystemStatusController} /> | ||||
|           {features.ota && ( | ||||
|             <AuthenticatedRoute exact path="/system/ota" component={OTASettingsController} /> | ||||
|           )} | ||||
|           {features.upload_firmware && ( | ||||
|             <AuthenticatedRoute exact path="/system/upload" component={UploadFirmwareController} /> | ||||
|           )} | ||||
|           <Redirect to="/system/status" /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
|   | ||||
							
								
								
									
										71
									
								
								interface/src/system/UploadFirmwareController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								interface/src/system/UploadFirmwareController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import { SectionContent } from '../components'; | ||||
| import { UPLOAD_FIRMWARE_ENDPOINT } from '../api'; | ||||
|  | ||||
| import UploadFirmwareForm from './UploadFirmwareForm'; | ||||
| import { redirectingAuthorizedUpload } from '../authentication'; | ||||
| import { withSnackbar, WithSnackbarProps } from 'notistack'; | ||||
|  | ||||
| interface UploadFirmwareControllerState { | ||||
|   xhr?: XMLHttpRequest; | ||||
|   progress?: ProgressEvent; | ||||
| } | ||||
|  | ||||
| class UploadFirmwareController extends Component<WithSnackbarProps, UploadFirmwareControllerState> { | ||||
|  | ||||
|   state: UploadFirmwareControllerState = { | ||||
|     xhr: undefined, | ||||
|     progress: undefined | ||||
|   }; | ||||
|  | ||||
|   componentWillUnmount() { | ||||
|     this.state.xhr?.abort(); | ||||
|   } | ||||
|  | ||||
|   updateProgress = (progress: ProgressEvent) => { | ||||
|     this.setState({ progress }); | ||||
|   } | ||||
|  | ||||
|   uploadFile = (file: File) => { | ||||
|     if (this.state.xhr) { | ||||
|       return; | ||||
|     } | ||||
|     var xhr = new XMLHttpRequest(); | ||||
|     this.setState({ xhr }); | ||||
|     redirectingAuthorizedUpload(xhr, UPLOAD_FIRMWARE_ENDPOINT, file, this.updateProgress).then(() => { | ||||
|       if (xhr.status !== 200) { | ||||
|         throw Error("Invalid status code: " + xhr.status); | ||||
|       } | ||||
|       this.props.enqueueSnackbar("Activating new firmware", { variant: 'success' }); | ||||
|       this.setState({ xhr: undefined, progress: undefined }); | ||||
|     }).catch((error: Error) => { | ||||
|       if (error.name === 'AbortError') { | ||||
|         this.props.enqueueSnackbar("Upload cancelled by user", { variant: 'warning' }); | ||||
|       } else { | ||||
|         const errorMessage = error.name === 'UploadError' ? "Error during upload" : (error.message || "Unknown error"); | ||||
|         this.props.enqueueSnackbar("Problem uploading: " + errorMessage, { variant: 'error' }); | ||||
|         this.setState({ xhr: undefined, progress: undefined }); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   cancelUpload = () => { | ||||
|     if (this.state.xhr) { | ||||
|       this.state.xhr.abort(); | ||||
|       this.setState({ xhr: undefined, progress: undefined }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { xhr, progress } = this.state; | ||||
|     return ( | ||||
|       <SectionContent title="Upload Firmware"> | ||||
|         <UploadFirmwareForm onFileSelected={this.uploadFile} onCancel={this.cancelUpload} uploading={!!xhr} progress={progress} /> | ||||
|       </SectionContent> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default withSnackbar(UploadFirmwareController); | ||||
							
								
								
									
										35
									
								
								interface/src/system/UploadFirmwareForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								interface/src/system/UploadFirmwareForm.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| import React, { Fragment } from 'react'; | ||||
| import { SingleUpload } from '../components'; | ||||
| import { Box } from '@material-ui/core'; | ||||
|  | ||||
| interface UploadFirmwareFormProps { | ||||
|   uploading: boolean; | ||||
|   progress?: ProgressEvent; | ||||
|   onFileSelected: (file: File) => void; | ||||
|   onCancel: () => void; | ||||
| } | ||||
|  | ||||
| class UploadFirmwareForm extends React.Component<UploadFirmwareFormProps> { | ||||
|  | ||||
|   handleDrop = (files: File[]) => { | ||||
|     const file = files[0]; | ||||
|     if (file) { | ||||
|       this.props.onFileSelected(files[0]); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     const { uploading, progress, onCancel } = this.props; | ||||
|     return ( | ||||
|       <Fragment> | ||||
|         <Box py={2}> | ||||
|           Upload a new firmware (.bin) file below to replace the existing firmware. | ||||
|         </Box> | ||||
|         <SingleUpload accept="application/octet-stream" onDrop={this.handleDrop} uploading={uploading} progress={progress} onCancel={onCancel} /> | ||||
|       </Fragment> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default UploadFirmwareForm; | ||||
| @@ -130,7 +130,7 @@ class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkS | ||||
|         <div className={classes.scanningSettings}> | ||||
|           <LinearProgress className={classes.scanningSettingsDetails} /> | ||||
|           <Typography variant="h6" className={classes.scanningProgress}> | ||||
|             Scanning... | ||||
|             Scanning… | ||||
|           </Typography> | ||||
|         </div> | ||||
|       ); | ||||
| @@ -156,7 +156,7 @@ class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkS | ||||
|         {this.renderNetworkScanner()} | ||||
|         <FormActions> | ||||
|           <FormButton startIcon={<PermScanWifiIcon />} variant="contained" color="secondary" onClick={this.requestNetworkScan} disabled={scanningForNetworks}> | ||||
|             Scan again... | ||||
|             Scan again… | ||||
|           </FormButton> | ||||
|         </FormActions> | ||||
|       </SectionContent> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user