Rework backend add MQTT and WebSocket support
* Update back end to add MQTT and WebSocket support * Update demo project to demonstrate MQTT and WebSockets * Update documentation to describe newly added and modified functionallity * Introduce separate MQTT pub/sub, HTTP get/post and WebSocket rx/tx classes * Significant reanaming - more accurate class names * Use PROGMEM_WWW as default * Update README documenting PROGMEM_WWW as default * Update README with API changes
This commit is contained in:
		| @@ -1,3 +1,4 @@ | ||||
| # Change the IP address to that of your ESP device to enable local development of the UI. | ||||
| # Remember to also enable CORS in platformio.ini before uploading the code to the device. | ||||
| REACT_APP_ENDPOINT_ROOT=http://192.168.0.21/rest/ | ||||
| REACT_APP_HTTP_ROOT=http://192.168.0.99 | ||||
| REACT_APP_WEB_SOCKET_ROOT=ws://192.168.0.99 | ||||
|   | ||||
| @@ -1,2 +1 @@ | ||||
| REACT_APP_ENDPOINT_ROOT=/rest/ | ||||
| GENERATE_SOURCEMAP=false | ||||
|   | ||||
							
								
								
									
										10
									
								
								interface/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								interface/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -1611,6 +1611,11 @@ | ||||
|       "resolved": "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-2.2.1.tgz", | ||||
|       "integrity": "sha512-aWw2YTtAdT7CskFyxEX2K21/zSDStuf/ikI3yBqmwpwJF0pS+/IX5DWv+1UFffZIbruP6cnT9/LAJV1gFwAT1A==" | ||||
|     }, | ||||
|     "@types/lodash": { | ||||
|       "version": "4.14.149", | ||||
|       "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz", | ||||
|       "integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==" | ||||
|     }, | ||||
|     "@types/material-ui": { | ||||
|       "version": "0.21.7", | ||||
|       "resolved": "https://registry.npmjs.org/@types/material-ui/-/material-ui-0.21.7.tgz", | ||||
| @@ -12041,6 +12046,11 @@ | ||||
|         "kind-of": "^3.2.0" | ||||
|       } | ||||
|     }, | ||||
|     "sockette": { | ||||
|       "version": "2.0.6", | ||||
|       "resolved": "https://registry.npmjs.org/sockette/-/sockette-2.0.6.tgz", | ||||
|       "integrity": "sha512-W6iG8RGV6Zife3Cj+FhuyHV447E6fqFM2hKmnaQrTvg3OydINV3Msj3WPFbX76blUlUxvQSMMMdrJxce8NqI5Q==" | ||||
|     }, | ||||
|     "sockjs": { | ||||
|       "version": "0.3.19", | ||||
|       "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.19.tgz", | ||||
|   | ||||
| @@ -6,6 +6,7 @@ | ||||
|     "@material-ui/core": "^4.9.8", | ||||
|     "@material-ui/icons": "^4.9.1", | ||||
|     "@types/jwt-decode": "^2.2.1", | ||||
|     "@types/lodash": "^4.14.149", | ||||
|     "@types/node": "^12.12.32", | ||||
|     "@types/react": "^16.9.27", | ||||
|     "@types/react-dom": "^16.9.5", | ||||
| @@ -14,6 +15,7 @@ | ||||
|     "@types/react-router-dom": "^5.1.3", | ||||
|     "compression-webpack-plugin": "^3.0.1", | ||||
|     "jwt-decode": "^2.2.0", | ||||
|     "lodash": "^4.17.15", | ||||
|     "mime-types": "^2.1.25", | ||||
|     "moment": "^2.24.0", | ||||
|     "notistack": "^0.9.7", | ||||
| @@ -24,6 +26,7 @@ | ||||
|     "react-router": "^5.1.2", | ||||
|     "react-router-dom": "^5.1.2", | ||||
|     "react-scripts": "3.4.1", | ||||
|     "sockette": "^2.0.6", | ||||
|     "typescript": "^3.7.5", | ||||
|     "zlib": "^1.0.5" | ||||
|   }, | ||||
|   | ||||
| @@ -15,6 +15,7 @@ import Security from './security/Security'; | ||||
| import System from './system/System'; | ||||
|  | ||||
| import { PROJECT_PATH } from './api'; | ||||
| import Mqtt from './mqtt/Mqtt'; | ||||
|  | ||||
| class AppRouting extends Component { | ||||
|  | ||||
| @@ -31,6 +32,7 @@ class AppRouting extends Component { | ||||
|           <AuthenticatedRoute exact path="/wifi/*" component={WiFiConnection} />          | ||||
|           <AuthenticatedRoute exact path="/ap/*" component={AccessPoint} /> | ||||
|           <AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} /> | ||||
|           <AuthenticatedRoute exact path="/mqtt/*" component={Mqtt} /> | ||||
|           <AuthenticatedRoute exact path="/security/*" component={Security} />  | ||||
|           <AuthenticatedRoute exact path="/system/*" component={System} />           | ||||
|           <Redirect to="/" /> | ||||
|   | ||||
| @@ -9,6 +9,8 @@ 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 MQTT_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "mqttSettings"; | ||||
| export const MQTT_STATUS_ENDPOINT = ENDPOINT_ROOT + "mqttStatus"; | ||||
| export const SYSTEM_STATUS_ENDPOINT = ENDPOINT_ROOT + "systemStatus"; | ||||
| export const SIGN_IN_ENDPOINT = ENDPOINT_ROOT + "signIn"; | ||||
| export const VERIFY_AUTHORIZATION_ENDPOINT = ENDPOINT_ROOT + "verifyAuthorization"; | ||||
|   | ||||
| @@ -1,3 +1,24 @@ | ||||
| export const PROJECT_NAME = process.env.REACT_APP_PROJECT_NAME!; | ||||
| export const PROJECT_PATH = process.env.REACT_APP_PROJECT_PATH!; | ||||
| export const ENDPOINT_ROOT = process.env.REACT_APP_ENDPOINT_ROOT!; | ||||
|  | ||||
| export const ENDPOINT_ROOT = calculateEndpointRoot("/rest/"); | ||||
| export const WEB_SOCKET_ROOT = calculateWebSocketRoot("/ws/"); | ||||
|  | ||||
| function calculateEndpointRoot(endpointPath: string) { | ||||
|     const httpRoot = process.env.REACT_APP_HTTP_ROOT; | ||||
|     if (httpRoot) { | ||||
|         return httpRoot + endpointPath; | ||||
|     } | ||||
|     const location = window.location; | ||||
|     return location.protocol + "//" + location.host + endpointPath; | ||||
| } | ||||
|  | ||||
| function calculateWebSocketRoot(webSocketPath: string) { | ||||
|     const webSocketRoot = process.env.REACT_APP_WEB_SOCKET_ROOT; | ||||
|     if (webSocketRoot) { | ||||
|         return webSocketRoot + webSocketPath; | ||||
|     } | ||||
|     const location = window.location; | ||||
|     const webProtocol = location.protocol === "https:" ? "wss:" : "ws:"; | ||||
|     return webProtocol + "//" + location.host + webSocketPath; | ||||
| } | ||||
|   | ||||
| @@ -61,3 +61,13 @@ export function redirectingAuthorizedFetch(url: RequestInfo, params?: RequestIni | ||||
|     }); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export function addAccessTokenParameter(url: string) { | ||||
|   const accessToken = localStorage.getItem(ACCESS_TOKEN); | ||||
|   if (!accessToken) { | ||||
|     return url; | ||||
|   } | ||||
|   const parsedUrl = new URL(url); | ||||
|   parsedUrl.searchParams.set(ACCESS_TOKEN, accessToken); | ||||
|   return parsedUrl.toString(); | ||||
| } | ||||
|   | ||||
| @@ -13,6 +13,7 @@ import SettingsIcon from '@material-ui/icons/Settings'; | ||||
| import AccessTimeIcon from '@material-ui/icons/AccessTime'; | ||||
| import AccountCircleIcon from '@material-ui/icons/AccountCircle'; | ||||
| import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna'; | ||||
| import DeviceHubIcon from '@material-ui/icons/DeviceHub'; | ||||
| import LockIcon from '@material-ui/icons/Lock'; | ||||
| import MenuIcon from '@material-ui/icons/Menu'; | ||||
|  | ||||
| @@ -136,6 +137,12 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> { | ||||
|             </ListItemIcon> | ||||
|             <ListItemText primary="Network Time" /> | ||||
|           </ListItem> | ||||
|           <ListItem to='/mqtt/' selected={path.startsWith('/mqtt/')} button component={Link}> | ||||
|             <ListItemIcon> | ||||
|               <DeviceHubIcon /> | ||||
|             </ListItemIcon> | ||||
|             <ListItemText primary="MQTT" /> | ||||
|           </ListItem>           | ||||
|           <ListItem to='/security/' selected={path.startsWith('/security/')} button component={Link} disabled={!authenticatedContext.me.admin}> | ||||
|             <ListItemIcon> | ||||
|               <LockIcon /> | ||||
|   | ||||
| @@ -5,9 +5,8 @@ import { redirectingAuthorizedFetch } from '../authentication'; | ||||
|  | ||||
| export interface RestControllerProps<D> extends WithSnackbarProps { | ||||
|   handleValueChange: (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => void; | ||||
|   handleSliderChange: (name: keyof D) => (event: React.ChangeEvent<{}>, value: number | number[]) => void; | ||||
|  | ||||
|   setData: (data: D) => void; | ||||
|   setData: (data: D, callback?: () => void) => void; | ||||
|   saveData: () => void; | ||||
|   loadData: () => void; | ||||
|  | ||||
| @@ -16,13 +15,7 @@ export interface RestControllerProps<D> extends WithSnackbarProps { | ||||
|   errorMessage?: string; | ||||
| } | ||||
|  | ||||
| interface RestControllerState<D> { | ||||
|   data?: D; | ||||
|   loading: boolean; | ||||
|   errorMessage?: string; | ||||
| } | ||||
|  | ||||
| const extractValue = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
| export const extractEventValue = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|   switch (event.target.type) { | ||||
|     case "number": | ||||
|       return event.target.valueAsNumber; | ||||
| @@ -33,6 +26,12 @@ const extractValue = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|   } | ||||
| } | ||||
|  | ||||
| interface RestControllerState<D> { | ||||
|   data?: D; | ||||
|   loading: boolean; | ||||
|   errorMessage?: string; | ||||
| } | ||||
|  | ||||
| export function restController<D, P extends RestControllerProps<D>>(endpointUrl: string, RestController: React.ComponentType<P & RestControllerProps<D>>) { | ||||
|   return withSnackbar( | ||||
|     class extends React.Component<Omit<P, keyof RestControllerProps<D>> & WithSnackbarProps, RestControllerState<D>> { | ||||
| @@ -43,12 +42,12 @@ export function restController<D, P extends RestControllerProps<D>>(endpointUrl: | ||||
|         errorMessage: undefined | ||||
|       }; | ||||
|  | ||||
|       setData = (data: D) => { | ||||
|       setData = (data: D, callback?: () => void) => { | ||||
|         this.setState({ | ||||
|           data, | ||||
|           loading: false, | ||||
|           errorMessage: undefined | ||||
|         }); | ||||
|         }, callback); | ||||
|       } | ||||
|  | ||||
|       loadData = () => { | ||||
| @@ -95,19 +94,13 @@ export function restController<D, P extends RestControllerProps<D>>(endpointUrl: | ||||
|       } | ||||
|  | ||||
|       handleValueChange = (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|         const data = { ...this.state.data!, [name]: extractValue(event) }; | ||||
|         const data = { ...this.state.data!, [name]: extractEventValue(event) }; | ||||
|         this.setState({ data }); | ||||
|       } | ||||
|  | ||||
|       handleSliderChange = (name: keyof D) => (event: React.ChangeEvent<{}>, value: number | number[]) => { | ||||
|         const data = { ...this.state.data!, [name]: value }; | ||||
|         this.setState({ data }); | ||||
|       }; | ||||
|  | ||||
|       render() { | ||||
|         return <RestController | ||||
|           handleValueChange={this.handleValueChange} | ||||
|           handleSliderChange={this.handleSliderChange} | ||||
|           setData={this.setData} | ||||
|           saveData={this.saveData} | ||||
|           loadData={this.loadData} | ||||
|   | ||||
| @@ -2,7 +2,8 @@ import React from 'react'; | ||||
|  | ||||
| import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; | ||||
| import { Button, LinearProgress, Typography } from '@material-ui/core'; | ||||
| import { RestControllerProps } from './RestController'; | ||||
|  | ||||
| import { RestControllerProps } from '.'; | ||||
|  | ||||
| const useStyles = makeStyles((theme: Theme) => | ||||
|   createStyles({ | ||||
|   | ||||
							
								
								
									
										133
									
								
								interface/src/components/WebSocketController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								interface/src/components/WebSocketController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | ||||
| import React from 'react'; | ||||
| import Sockette from 'sockette'; | ||||
| import throttle from 'lodash/throttle'; | ||||
| import { withSnackbar, WithSnackbarProps } from 'notistack'; | ||||
|  | ||||
| import { addAccessTokenParameter } from '../authentication'; | ||||
| import { extractEventValue } from '.'; | ||||
|  | ||||
| export interface WebSocketControllerProps<D> extends WithSnackbarProps { | ||||
|   handleValueChange: (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => void; | ||||
|  | ||||
|   setData: (data: D, callback?: () => void) => void; | ||||
|   saveData: () => void; | ||||
|   saveDataAndClear(): () => void; | ||||
|  | ||||
|   connected: boolean; | ||||
|   data?: D; | ||||
| } | ||||
|  | ||||
| interface WebSocketControllerState<D> { | ||||
|   ws: Sockette; | ||||
|   connected: boolean; | ||||
|   clientId?: string; | ||||
|   data?: D; | ||||
| } | ||||
|  | ||||
| enum WebSocketMessageType { | ||||
|   ID = "id", | ||||
|   PAYLOAD = "payload" | ||||
| } | ||||
|  | ||||
| interface WebSocketIdMessage { | ||||
|   type: typeof WebSocketMessageType.ID; | ||||
|   id: string; | ||||
| } | ||||
|  | ||||
| interface WebSocketPayloadMessage<D> { | ||||
|   type: typeof WebSocketMessageType.PAYLOAD; | ||||
|   origin_id: string; | ||||
|   payload: D; | ||||
| } | ||||
|  | ||||
| export type WebSocketMessage<D> = WebSocketIdMessage | WebSocketPayloadMessage<D>; | ||||
|  | ||||
| export function webSocketController<D, P extends WebSocketControllerProps<D>>(wsUrl: string, wsThrottle: number, WebSocketController: React.ComponentType<P & WebSocketControllerProps<D>>) { | ||||
|   return withSnackbar( | ||||
|     class extends React.Component<Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps, WebSocketControllerState<D>> { | ||||
|       constructor(props: Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps) { | ||||
|         super(props); | ||||
|         this.state = { | ||||
|           ws: new Sockette(addAccessTokenParameter(wsUrl), { | ||||
|             onmessage: this.onMessage, | ||||
|             onopen: this.onOpen, | ||||
|             onclose: this.onClose, | ||||
|           }), | ||||
|           connected: false | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       componentWillUnmount() { | ||||
|         this.state.ws.close(); | ||||
|       } | ||||
|  | ||||
|       onMessage = (event: MessageEvent) => { | ||||
|         const rawData = event.data; | ||||
|         if (typeof rawData === 'string' || rawData instanceof String) { | ||||
|           this.handleMessage(JSON.parse(rawData as string) as WebSocketMessage<D>); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       handleMessage = (message: WebSocketMessage<D>) => { | ||||
|         switch (message.type) { | ||||
|           case WebSocketMessageType.ID: | ||||
|             this.setState({ clientId: message.id }); | ||||
|             break; | ||||
|           case WebSocketMessageType.PAYLOAD: | ||||
|             const { clientId, data } = this.state; | ||||
|             if (clientId && (!data || clientId !== message.origin_id)) { | ||||
|               this.setState( | ||||
|                 { data: message.payload } | ||||
|               ); | ||||
|             } | ||||
|             break; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       onOpen = () => { | ||||
|         this.setState({ connected: true }); | ||||
|       } | ||||
|  | ||||
|       onClose = () => { | ||||
|         this.setState({ connected: false, clientId: undefined, data: undefined }); | ||||
|       } | ||||
|  | ||||
|       setData = (data: D, callback?: () => void) => { | ||||
|         this.setState({ data }, callback); | ||||
|       } | ||||
|  | ||||
|       saveData = throttle(() => { | ||||
|         const { ws, connected, data } = this.state; | ||||
|         if (connected) { | ||||
|           ws.json(data); | ||||
|         } | ||||
|       }, wsThrottle); | ||||
|  | ||||
|       saveDataAndClear = throttle(() => { | ||||
|         const { ws, connected, data } = this.state; | ||||
|         if (connected) { | ||||
|           this.setState({ | ||||
|             data: undefined | ||||
|           }, () => ws.json(data)); | ||||
|         } | ||||
|       }, wsThrottle); | ||||
|  | ||||
|       handleValueChange = (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|         const data = { ...this.state.data!, [name]: extractEventValue(event) }; | ||||
|         this.setState({ data }); | ||||
|       } | ||||
|  | ||||
|       render() { | ||||
|         return <WebSocketController | ||||
|           handleValueChange={this.handleValueChange} | ||||
|           setData={this.setData} | ||||
|           saveData={this.saveData} | ||||
|           saveDataAndClear={this.saveDataAndClear} | ||||
|           connected={this.state.connected} | ||||
|           data={this.state.data} | ||||
|           {...this.props as P} | ||||
|         />; | ||||
|       } | ||||
|  | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										40
									
								
								interface/src/components/WebSocketFormLoader.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								interface/src/components/WebSocketFormLoader.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| import React from 'react'; | ||||
|  | ||||
| import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; | ||||
| import { LinearProgress, Typography } from '@material-ui/core'; | ||||
|  | ||||
| import { WebSocketControllerProps } from '.'; | ||||
|  | ||||
| const useStyles = makeStyles((theme: Theme) => | ||||
|   createStyles({ | ||||
|     loadingSettings: { | ||||
|       margin: theme.spacing(0.5), | ||||
|     }, | ||||
|     loadingSettingsDetails: { | ||||
|       margin: theme.spacing(4), | ||||
|       textAlign: "center" | ||||
|     } | ||||
|   }) | ||||
| ); | ||||
|  | ||||
| export type WebSocketFormProps<D> = Omit<WebSocketControllerProps<D>, "connected"> & { data: D }; | ||||
|  | ||||
| interface WebSocketFormLoaderProps<D> extends WebSocketControllerProps<D> { | ||||
|   render: (props: WebSocketFormProps<D>) => JSX.Element; | ||||
| } | ||||
|  | ||||
| export default function WebSocketFormLoader<D>(props: WebSocketFormLoaderProps<D>) { | ||||
|   const { connected, render, data, ...rest } = props; | ||||
|   const classes = useStyles(); | ||||
|   if (!connected || !data) { | ||||
|     return ( | ||||
|       <div className={classes.loadingSettings}> | ||||
|         <LinearProgress className={classes.loadingSettingsDetails} /> | ||||
|         <Typography variant="h6" className={classes.loadingSettingsDetails}> | ||||
|           Connecting to WebSocket... | ||||
|         </Typography> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|   return render({ ...rest, data }); | ||||
| } | ||||
| @@ -6,6 +6,10 @@ export { default as MenuAppBar } from './MenuAppBar'; | ||||
| export { default as PasswordValidator } from './PasswordValidator'; | ||||
| export { default as RestFormLoader } from './RestFormLoader'; | ||||
| export { default as SectionContent } from './SectionContent'; | ||||
| export { default as WebSocketFormLoader } from './WebSocketFormLoader'; | ||||
|  | ||||
| export * from './RestFormLoader'; | ||||
| export * from './RestController'; | ||||
|  | ||||
| export * from './WebSocketFormLoader'; | ||||
| export * from './WebSocketController'; | ||||
|   | ||||
							
								
								
									
										37
									
								
								interface/src/mqtt/Mqtt.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								interface/src/mqtt/Mqtt.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' | ||||
|  | ||||
| import { Tabs, Tab } from '@material-ui/core'; | ||||
|  | ||||
| import { AuthenticatedContextProps, withAuthenticatedContext, AuthenticatedRoute } from '../authentication'; | ||||
| import { MenuAppBar } from '../components'; | ||||
| import MqttStatusController from './MqttStatusController'; | ||||
| import MqttSettingsController from './MqttSettingsController'; | ||||
|  | ||||
| type MqttProps = AuthenticatedContextProps & RouteComponentProps; | ||||
|  | ||||
| class Mqtt extends Component<MqttProps> { | ||||
|  | ||||
|   handleTabChange = (event: React.ChangeEvent<{}>, path: string) => { | ||||
|     this.props.history.push(path); | ||||
|   }; | ||||
|  | ||||
|   render() { | ||||
|     const { authenticatedContext } = this.props; | ||||
|     return ( | ||||
|       <MenuAppBar sectionTitle="MQTT"> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> | ||||
|           <Tab value="/mqtt/status" label="MQTT Status" /> | ||||
|           <Tab value="/mqtt/settings" label="MQTT Settings" disabled={!authenticatedContext.me.admin} /> | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact path="/mqtt/status" component={MqttStatusController} /> | ||||
|           <AuthenticatedRoute exact path="/mqtt/settings" component={MqttSettingsController} /> | ||||
|           <Redirect to="/mqtt/status" /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default withAuthenticatedContext(Mqtt); | ||||
							
								
								
									
										30
									
								
								interface/src/mqtt/MqttSettingsController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								interface/src/mqtt/MqttSettingsController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; | ||||
| import { MQTT_SETTINGS_ENDPOINT } from '../api'; | ||||
|  | ||||
| import MqttSettingsForm from './MqttSettingsForm'; | ||||
| import { MqttSettings } from './types'; | ||||
|  | ||||
| type MqttSettingsControllerProps = RestControllerProps<MqttSettings>; | ||||
|  | ||||
| class MqttSettingsController extends Component<MqttSettingsControllerProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title="MQTT Settings" titleGutter> | ||||
|         <RestFormLoader | ||||
|           {...this.props} | ||||
|           render={formProps => <MqttSettingsForm {...formProps} />} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restController(MQTT_SETTINGS_ENDPOINT, MqttSettingsController); | ||||
							
								
								
									
										131
									
								
								interface/src/mqtt/MqttSettingsForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								interface/src/mqtt/MqttSettingsForm.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,131 @@ | ||||
| import React from 'react'; | ||||
| import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { Checkbox, TextField } from '@material-ui/core'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| import { RestFormProps, FormActions, FormButton, BlockFormControlLabel, PasswordValidator } from '../components'; | ||||
| import { isIP, isHostname, or } from '../validators'; | ||||
|  | ||||
| import { MqttSettings } from './types'; | ||||
|  | ||||
| type MqttSettingsFormProps = RestFormProps<MqttSettings>; | ||||
|  | ||||
| class MqttSettingsForm extends React.Component<MqttSettingsFormProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname)); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { data, handleValueChange, saveData, loadData } = this.props; | ||||
|     return ( | ||||
|       <ValidatorForm onSubmit={saveData}> | ||||
|         <BlockFormControlLabel | ||||
|           control={ | ||||
|             <Checkbox | ||||
|               checked={data.enabled} | ||||
|               onChange={handleValueChange('enabled')} | ||||
|               value="enabled" | ||||
|             /> | ||||
|           } | ||||
|           label="Enable MQTT?" | ||||
|         /> | ||||
|         <TextValidator | ||||
|           validators={['required', 'isIPOrHostname']} | ||||
|           errorMessages={['Host is required', "Not a valid IP address or hostname"]} | ||||
|           name="host" | ||||
|           label="Host" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           value={data.host} | ||||
|           onChange={handleValueChange('host')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <TextValidator | ||||
|           validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:65535']} | ||||
|           errorMessages={['Port is required', "Must be a number", "Must be greater than 0 ", "Max value is 65535"]} | ||||
|           name="port" | ||||
|           label="Port" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           value={data.port} | ||||
|           type="number" | ||||
|           onChange={handleValueChange('port')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <TextField | ||||
|           name="username" | ||||
|           label="Username" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           value={data.username} | ||||
|           onChange={handleValueChange('username')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <PasswordValidator | ||||
|           name="password" | ||||
|           label="Password" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           value={data.password} | ||||
|           onChange={handleValueChange('password')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <TextField | ||||
|           name="client_id" | ||||
|           label="Client ID (optional)" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           value={data.client_id} | ||||
|           onChange={handleValueChange('client_id')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <TextValidator | ||||
|           validators={['required', 'isNumber', 'minNumber:1', 'maxNumber:65535']} | ||||
|           errorMessages={['Keep alive is required', "Must be a number", "Must be greater than 0", "Max value is 65535"]} | ||||
|           name="keep_alive" | ||||
|           label="Keep Alive (seconds)" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           value={data.keep_alive} | ||||
|           type="number" | ||||
|           onChange={handleValueChange('keep_alive')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <BlockFormControlLabel | ||||
|           control={ | ||||
|             <Checkbox | ||||
|               checked={data.clean_session} | ||||
|               onChange={handleValueChange('clean_session')} | ||||
|               value="clean_session" | ||||
|             /> | ||||
|           } | ||||
|           label="Clean Session?" | ||||
|         /> | ||||
|         <TextValidator | ||||
|           validators={['required', 'isNumber', 'minNumber:1', 'maxNumber:65535']} | ||||
|           errorMessages={['Max topic length is required', "Must be a number", "Must be greater than 0", "Max value is 65535"]} | ||||
|           name="max_topic_length" | ||||
|           label="Max Topic Length" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           value={data.max_topic_length} | ||||
|           type="number" | ||||
|           onChange={handleValueChange('max_topic_length')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <FormActions> | ||||
|           <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> | ||||
|             Save | ||||
|           </FormButton> | ||||
|           <FormButton variant="contained" color="secondary" onClick={loadData}> | ||||
|             Reset | ||||
|           </FormButton> | ||||
|         </FormActions> | ||||
|       </ValidatorForm> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default MqttSettingsForm; | ||||
							
								
								
									
										45
									
								
								interface/src/mqtt/MqttStatus.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								interface/src/mqtt/MqttStatus.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| import { Theme } from "@material-ui/core"; | ||||
| import { MqttStatus, MqttDisconnectReason } from "./types"; | ||||
|  | ||||
| export const mqttStatusHighlight = ({ enabled, connected }: MqttStatus, theme: Theme) => { | ||||
|   if (!enabled) { | ||||
|     return theme.palette.info.main; | ||||
|   } | ||||
|   if (connected) { | ||||
|     return theme.palette.success.main; | ||||
|   } | ||||
|   return theme.palette.error.main; | ||||
| } | ||||
|  | ||||
| export const mqttStatus = ({ enabled, connected }: MqttStatus) => { | ||||
|   if (!enabled) { | ||||
|     return "Not enabled"; | ||||
|   } | ||||
|   if (connected) { | ||||
|     return "Connected"; | ||||
|   } | ||||
|   return "Disconnected"; | ||||
| } | ||||
|  | ||||
| export const disconnectReason = ({ disconnect_reason }: MqttStatus) => { | ||||
|   switch (disconnect_reason) { | ||||
|     case MqttDisconnectReason.TCP_DISCONNECTED: | ||||
|       return "TCP disconnected"; | ||||
|     case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION: | ||||
|       return "Unacceptable protocol version"; | ||||
|     case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED: | ||||
|       return "Client ID rejected"; | ||||
|     case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE: | ||||
|       return "Server unavailable"; | ||||
|     case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS: | ||||
|       return "Malformed credentials"; | ||||
|     case MqttDisconnectReason.MQTT_NOT_AUTHORIZED: | ||||
|       return "Not authorized"; | ||||
|     case MqttDisconnectReason.ESP8266_NOT_ENOUGH_SPACE: | ||||
|       return "Device out of memory"; | ||||
|     case MqttDisconnectReason.TLS_BAD_FINGERPRINT: | ||||
|       return "Server fingerprint invalid"; | ||||
|     default: | ||||
|       return "Unknown" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										29
									
								
								interface/src/mqtt/MqttStatusController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								interface/src/mqtt/MqttStatusController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| import React, { Component } from 'react'; | ||||
|  | ||||
| import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; | ||||
| import { MQTT_STATUS_ENDPOINT } from '../api'; | ||||
|  | ||||
| import MqttStatusForm from './MqttStatusForm'; | ||||
| import { MqttStatus } from './types'; | ||||
|  | ||||
| type MqttStatusControllerProps = RestControllerProps<MqttStatus>; | ||||
|  | ||||
| class MqttStatusController extends Component<MqttStatusControllerProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title="MQTT Status"> | ||||
|         <RestFormLoader | ||||
|           {...this.props} | ||||
|           render={formProps => <MqttStatusForm {...formProps} />} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default restController(MQTT_STATUS_ENDPOINT, MqttStatusController); | ||||
							
								
								
									
										83
									
								
								interface/src/mqtt/MqttStatusForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								interface/src/mqtt/MqttStatusForm.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | ||||
| import React, { Component, Fragment } from 'react'; | ||||
|  | ||||
| import { WithTheme, withTheme } from '@material-ui/core/styles'; | ||||
| import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core'; | ||||
|  | ||||
| import DeviceHubIcon from '@material-ui/icons/DeviceHub'; | ||||
| import RefreshIcon from '@material-ui/icons/Refresh'; | ||||
| import ReportIcon from '@material-ui/icons/Report'; | ||||
|  | ||||
| import { RestFormProps, FormActions, FormButton, HighlightAvatar } from '../components'; | ||||
| import { mqttStatusHighlight, mqttStatus, disconnectReason } from './MqttStatus'; | ||||
| import { MqttStatus } from './types'; | ||||
|  | ||||
| type MqttStatusFormProps = RestFormProps<MqttStatus> & WithTheme; | ||||
|  | ||||
| class MqttStatusForm extends Component<MqttStatusFormProps> { | ||||
|  | ||||
|   renderConnectionStatus() { | ||||
|     const { data } = this.props | ||||
|     if (data.connected) { | ||||
|       return ( | ||||
|         <Fragment> | ||||
|           <ListItem> | ||||
|             <ListItemAvatar> | ||||
|               <Avatar>#</Avatar> | ||||
|             </ListItemAvatar> | ||||
|             <ListItemText primary="Client ID" secondary={data.client_id} /> | ||||
|           </ListItem> | ||||
|           <Divider variant="inset" component="li" /> | ||||
|         </Fragment> | ||||
|       ); | ||||
|     } | ||||
|     return ( | ||||
|       <Fragment> | ||||
|         <ListItem> | ||||
|           <ListItemAvatar> | ||||
|             <Avatar> | ||||
|               <ReportIcon /> | ||||
|             </Avatar> | ||||
|           </ListItemAvatar> | ||||
|           <ListItemText primary="Disconnect Reason" secondary={disconnectReason(data)} /> | ||||
|         </ListItem> | ||||
|         <Divider variant="inset" component="li" /> | ||||
|       </Fragment> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   createListItems() { | ||||
|     const { data, theme } = this.props | ||||
|     return ( | ||||
|       <Fragment> | ||||
|         <ListItem> | ||||
|           <ListItemAvatar> | ||||
|             <HighlightAvatar color={mqttStatusHighlight(data, theme)}> | ||||
|               <DeviceHubIcon /> | ||||
|             </HighlightAvatar> | ||||
|           </ListItemAvatar> | ||||
|           <ListItemText primary="Status" secondary={mqttStatus(data)} /> | ||||
|         </ListItem> | ||||
|         <Divider variant="inset" component="li" /> | ||||
|         {data.enabled && this.renderConnectionStatus()} | ||||
|       </Fragment> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <Fragment> | ||||
|         <List> | ||||
|           {this.createListItems()} | ||||
|         </List> | ||||
|         <FormActions> | ||||
|           <FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}> | ||||
|             Refresh | ||||
|           </FormButton> | ||||
|         </FormActions> | ||||
|       </Fragment> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default withTheme(MqttStatusForm); | ||||
							
								
								
									
										29
									
								
								interface/src/mqtt/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								interface/src/mqtt/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| export enum MqttDisconnectReason { | ||||
|   TCP_DISCONNECTED = 0, | ||||
|   MQTT_UNACCEPTABLE_PROTOCOL_VERSION = 1, | ||||
|   MQTT_IDENTIFIER_REJECTED = 2, | ||||
|   MQTT_SERVER_UNAVAILABLE = 3, | ||||
|   MQTT_MALFORMED_CREDENTIALS = 4, | ||||
|   MQTT_NOT_AUTHORIZED = 5, | ||||
|   ESP8266_NOT_ENOUGH_SPACE = 6, | ||||
|   TLS_BAD_FINGERPRINT = 7 | ||||
| } | ||||
|  | ||||
| export interface MqttStatus { | ||||
|   enabled: boolean; | ||||
|   connected: boolean; | ||||
|   client_id: string; | ||||
|   disconnect_reason: MqttDisconnectReason; | ||||
| } | ||||
|  | ||||
| export interface MqttSettings { | ||||
|   enabled: boolean; | ||||
|   host: string; | ||||
|   port: number; | ||||
|   username: string; | ||||
|   password: string; | ||||
|   client_id: string; | ||||
|   keep_alive: number; | ||||
|   clean_session: boolean; | ||||
|   max_topic_length: number; | ||||
| } | ||||
| @@ -56,11 +56,10 @@ class NTPSettingsForm extends React.Component<NTPSettingsFormProps> { | ||||
|           validators={['required']} | ||||
|           errorMessages={['Time zone is required']} | ||||
|           name="tz_label" | ||||
|           labelId="tz_label" | ||||
|           label="Time zone" | ||||
|           fullWidth | ||||
|           variant="outlined" | ||||
|           native | ||||
|           native="true" | ||||
|           value={selectedTimeZone(data.tz_label, data.tz_format)} | ||||
|           onChange={this.changeTimeZone} | ||||
|           margin="normal" | ||||
|   | ||||
| @@ -474,6 +474,6 @@ export function selectedTimeZone(label: string, format: string) { | ||||
|  | ||||
| export function timeZoneSelectItems() { | ||||
|   return Object.keys(TIME_ZONES).map(label => ( | ||||
|     <MenuItem value={label}>{label}</MenuItem> | ||||
|     <MenuItem key={label} value={label}>{label}</MenuItem> | ||||
|   )); | ||||
| } | ||||
|   | ||||
| @@ -1,75 +0,0 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { ValidatorForm } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { Typography, Slider, Box } from '@material-ui/core'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| import { ENDPOINT_ROOT } from '../api'; | ||||
| import { restController, RestControllerProps, RestFormLoader, RestFormProps, FormActions, FormButton, SectionContent } from '../components'; | ||||
|  | ||||
| export const DEMO_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "demoSettings"; | ||||
|  | ||||
| interface DemoSettings { | ||||
|   blink_speed: number; | ||||
| } | ||||
|  | ||||
| type DemoControllerProps = RestControllerProps<DemoSettings>; | ||||
|  | ||||
| class DemoController extends Component<DemoControllerProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title='Demo Controller' titleGutter> | ||||
|         <RestFormLoader | ||||
|           {...this.props} | ||||
|           render={props => ( | ||||
|             <DemoControllerForm {...props} /> | ||||
|           )} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restController(DEMO_SETTINGS_ENDPOINT, DemoController); | ||||
|  | ||||
| const valueToPercentage = (value: number) => `${Math.round(value / 255 * 100)}%`; | ||||
|  | ||||
| type DemoControllerFormProps = RestFormProps<DemoSettings>; | ||||
|  | ||||
| function DemoControllerForm(props: DemoControllerFormProps) { | ||||
|   const { data, saveData, loadData, handleSliderChange } = props; | ||||
|   return ( | ||||
|     <ValidatorForm onSubmit={saveData}> | ||||
|       <Typography id="blink-speed-slider"> | ||||
|         Blink Speed | ||||
|       </Typography> | ||||
|       <Box pt={5}> | ||||
|         <Slider | ||||
|           value={data.blink_speed} | ||||
|           valueLabelFormat={valueToPercentage} | ||||
|           aria-labelledby="blink-speed-slider" | ||||
|           valueLabelDisplay="on" | ||||
|           min={0} | ||||
|           max={255} | ||||
|           onChange={handleSliderChange('blink_speed')} | ||||
|         /> | ||||
|       </Box> | ||||
|       <FormActions> | ||||
|         <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> | ||||
|           Save | ||||
|         </FormButton> | ||||
|         <FormButton variant="contained" color="secondary" onClick={loadData}> | ||||
|           Reset | ||||
|         </FormButton> | ||||
|       </FormActions> | ||||
|     </ValidatorForm> | ||||
|   ); | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -65,10 +65,26 @@ class DemoInformation extends Component { | ||||
|             </TableRow> | ||||
|             <TableRow> | ||||
|               <TableCell> | ||||
|                 DemoController.tsx | ||||
|                 LightStateRestController.tsx | ||||
|               </TableCell> | ||||
|               <TableCell> | ||||
|                 The demo controller tab, to control the built-in LED. | ||||
|                 A form which lets the user control the LED over a REST service. | ||||
|               </TableCell> | ||||
|             </TableRow> | ||||
|             <TableRow> | ||||
|               <TableCell> | ||||
|                 LightStateWebSocketController.tsx | ||||
|               </TableCell> | ||||
|               <TableCell> | ||||
|                 A form which lets the user control and monitor the status of the LED over WebSockets. | ||||
|               </TableCell> | ||||
|             </TableRow> | ||||
|             <TableRow> | ||||
|               <TableCell> | ||||
|                 LightMqttSettingsController.tsx | ||||
|               </TableCell> | ||||
|               <TableCell> | ||||
|                 A form which lets the user change the MQTT settings for MQTT based control of the LED. | ||||
|               </TableCell> | ||||
|             </TableRow> | ||||
|           </TableBody> | ||||
|   | ||||
| @@ -8,7 +8,9 @@ import { MenuAppBar } from '../components'; | ||||
| import { AuthenticatedRoute } from '../authentication'; | ||||
|  | ||||
| import DemoInformation from './DemoInformation'; | ||||
| import DemoController from './DemoController'; | ||||
| import LightStateRestController from './LightStateRestController'; | ||||
| import LightStateWebSocketController from './LightStateWebSocketController'; | ||||
| import LightMqttSettingsController from './LightMqttSettingsController'; | ||||
|  | ||||
| class DemoProject extends Component<RouteComponentProps> { | ||||
|  | ||||
| @@ -20,12 +22,16 @@ class DemoProject extends Component<RouteComponentProps> { | ||||
|     return ( | ||||
|       <MenuAppBar sectionTitle="Demo Project"> | ||||
|         <Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> | ||||
|           <Tab value={`/${PROJECT_PATH}/demo/information`} label="Demo Information" /> | ||||
|           <Tab value={`/${PROJECT_PATH}/demo/controller`} label="Demo Controller" /> | ||||
|           <Tab value={`/${PROJECT_PATH}/demo/information`} label="Information" /> | ||||
|           <Tab value={`/${PROJECT_PATH}/demo/rest`} label="REST Controller" /> | ||||
|           <Tab value={`/${PROJECT_PATH}/demo/socket`} label="WebSocket Controller" /> | ||||
|           <Tab value={`/${PROJECT_PATH}/demo/mqtt`} label="MQTT Controller" /> | ||||
|         </Tabs> | ||||
|         <Switch> | ||||
|           <AuthenticatedRoute exact path={`/${PROJECT_PATH}/demo/information`} component={DemoInformation} /> | ||||
|           <AuthenticatedRoute exact path={`/${PROJECT_PATH}/demo/controller`} component={DemoController} /> | ||||
|           <AuthenticatedRoute exact path={`/${PROJECT_PATH}/demo/rest`} component={LightStateRestController} /> | ||||
|           <AuthenticatedRoute exact path={`/${PROJECT_PATH}/demo/socket`} component={LightStateWebSocketController} /> | ||||
|           <AuthenticatedRoute exact path={`/${PROJECT_PATH}/demo/mqtt`} component={LightMqttSettingsController} /> | ||||
|           <Redirect to={`/${PROJECT_PATH}/demo/information`} /> | ||||
|         </Switch> | ||||
|       </MenuAppBar> | ||||
|   | ||||
							
								
								
									
										93
									
								
								interface/src/project/LightMqttSettingsController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								interface/src/project/LightMqttSettingsController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,93 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { ValidatorForm, TextValidator } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { Typography, Box } from '@material-ui/core'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| import { ENDPOINT_ROOT } from '../api'; | ||||
| import { restController, RestControllerProps, RestFormLoader, RestFormProps, FormActions, FormButton, SectionContent } from '../components'; | ||||
|  | ||||
| import { LightMqttSettings } from './types'; | ||||
|  | ||||
| export const LIGHT_BROKER_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "brokerSettings"; | ||||
|  | ||||
| type LightMqttSettingsControllerProps = RestControllerProps<LightMqttSettings>; | ||||
|  | ||||
| class LightMqttSettingsController extends Component<LightMqttSettingsControllerProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title='MQTT Controller' titleGutter> | ||||
|         <RestFormLoader | ||||
|           {...this.props} | ||||
|           render={props => ( | ||||
|             <LightMqttSettingsControllerForm {...props} /> | ||||
|           )} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restController(LIGHT_BROKER_SETTINGS_ENDPOINT, LightMqttSettingsController); | ||||
|  | ||||
| type LightMqttSettingsControllerFormProps = RestFormProps<LightMqttSettings>; | ||||
|  | ||||
| function LightMqttSettingsControllerForm(props: LightMqttSettingsControllerFormProps) { | ||||
|   const { data, saveData, loadData, handleValueChange } = props; | ||||
|   return ( | ||||
|     <ValidatorForm onSubmit={saveData}> | ||||
|       <Box bgcolor="primary.main" color="primary.contrastText" p={2} mt={2} mb={2}> | ||||
|         <Typography variant="body1"> | ||||
|           The LED is controllable via MQTT with the demo project designed to work with Home Assistant's auto discovery feature. | ||||
|         </Typography> | ||||
|       </Box> | ||||
|       <TextValidator | ||||
|         validators={['required']} | ||||
|         errorMessages={['Unique ID is required']} | ||||
|         name="unique_id" | ||||
|         label="Unique ID" | ||||
|         fullWidth | ||||
|         variant="outlined" | ||||
|         value={data.unique_id} | ||||
|         onChange={handleValueChange('unique_id')} | ||||
|         margin="normal" | ||||
|       /> | ||||
|       <TextValidator | ||||
|         validators={['required']} | ||||
|         errorMessages={['Name is required']} | ||||
|         name="name" | ||||
|         label="Name" | ||||
|         fullWidth | ||||
|         variant="outlined" | ||||
|         value={data.name} | ||||
|         onChange={handleValueChange('name')} | ||||
|         margin="normal" | ||||
|       /> | ||||
|       <TextValidator | ||||
|         validators={['required']} | ||||
|         errorMessages={['MQTT Path is required']} | ||||
|         name="mqtt_path" | ||||
|         label="MQTT Path" | ||||
|         fullWidth | ||||
|         variant="outlined" | ||||
|         value={data.mqtt_path} | ||||
|         onChange={handleValueChange('mqtt_path')} | ||||
|         margin="normal" | ||||
|       /> | ||||
|       <FormActions> | ||||
|         <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> | ||||
|           Save | ||||
|         </FormButton> | ||||
|         <FormButton variant="contained" color="secondary" onClick={loadData}> | ||||
|           Reset | ||||
|         </FormButton> | ||||
|       </FormActions> | ||||
|     </ValidatorForm> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										70
									
								
								interface/src/project/LightStateRestController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								interface/src/project/LightStateRestController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { ValidatorForm } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { Typography, Box, Checkbox } from '@material-ui/core'; | ||||
| import SaveIcon from '@material-ui/icons/Save'; | ||||
|  | ||||
| import { ENDPOINT_ROOT } from '../api'; | ||||
| import { restController, RestControllerProps, RestFormLoader, RestFormProps, FormActions, FormButton, SectionContent, BlockFormControlLabel } from '../components'; | ||||
|  | ||||
| import { LightState } from './types'; | ||||
|  | ||||
| export const LIGHT_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "lightState"; | ||||
|  | ||||
| type LightStateRestControllerProps = RestControllerProps<LightState>; | ||||
|  | ||||
| class LightStateRestController extends Component<LightStateRestControllerProps> { | ||||
|  | ||||
|   componentDidMount() { | ||||
|     this.props.loadData(); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title='REST Controller' titleGutter> | ||||
|         <RestFormLoader | ||||
|           {...this.props} | ||||
|           render={props => ( | ||||
|             <LightStateRestControllerForm {...props} /> | ||||
|           )} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default restController(LIGHT_SETTINGS_ENDPOINT, LightStateRestController); | ||||
|  | ||||
| type LightStateRestControllerFormProps = RestFormProps<LightState>; | ||||
|  | ||||
| function LightStateRestControllerForm(props: LightStateRestControllerFormProps) { | ||||
|   const { data, saveData, loadData, handleValueChange } = props; | ||||
|   return ( | ||||
|     <ValidatorForm onSubmit={saveData}> | ||||
|       <Box bgcolor="primary.main" color="primary.contrastText" p={2} mt={2} mb={2}> | ||||
|         <Typography variant="body1"> | ||||
|           The form below controls the LED via the RESTful service exposed by the ESP device. | ||||
|         </Typography> | ||||
|       </Box> | ||||
|       <BlockFormControlLabel | ||||
|         control={ | ||||
|           <Checkbox | ||||
|             checked={data.led_on} | ||||
|             onChange={handleValueChange('led_on')} | ||||
|             color="primary" | ||||
|           /> | ||||
|         } | ||||
|         label="LED State?" | ||||
|       /> | ||||
|       <FormActions> | ||||
|         <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> | ||||
|           Save | ||||
|         </FormButton> | ||||
|         <FormButton variant="contained" color="secondary" onClick={loadData}> | ||||
|           Reset | ||||
|         </FormButton> | ||||
|       </FormActions> | ||||
|     </ValidatorForm> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										62
									
								
								interface/src/project/LightStateWebSocketController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								interface/src/project/LightStateWebSocketController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| import React, { Component } from 'react'; | ||||
| import { ValidatorForm } from 'react-material-ui-form-validator'; | ||||
|  | ||||
| import { Typography, Box, Switch } from '@material-ui/core'; | ||||
| import { WEB_SOCKET_ROOT } from '../api'; | ||||
| import { WebSocketControllerProps, WebSocketFormLoader, WebSocketFormProps, webSocketController } from '../components'; | ||||
| import { SectionContent, BlockFormControlLabel } from '../components'; | ||||
|  | ||||
| import { LightState } from './types'; | ||||
|  | ||||
| export const LIGHT_SETTINGS_WEBSOCKET_URL = WEB_SOCKET_ROOT + "lightState"; | ||||
|  | ||||
| type LightStateWebSocketControllerProps = WebSocketControllerProps<LightState>; | ||||
|  | ||||
| class LightStateWebSocketController extends Component<LightStateWebSocketControllerProps> { | ||||
|  | ||||
|   render() { | ||||
|     return ( | ||||
|       <SectionContent title='WebSocket Controller' titleGutter> | ||||
|         <WebSocketFormLoader | ||||
|           {...this.props} | ||||
|           render={props => ( | ||||
|             <LightStateWebSocketControllerForm {...props} /> | ||||
|           )} | ||||
|         /> | ||||
|       </SectionContent> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default webSocketController(LIGHT_SETTINGS_WEBSOCKET_URL, 100, LightStateWebSocketController); | ||||
|  | ||||
| type LightStateWebSocketControllerFormProps = WebSocketFormProps<LightState>; | ||||
|  | ||||
| function LightStateWebSocketControllerForm(props: LightStateWebSocketControllerFormProps) { | ||||
|   const { data, saveData, setData } = props; | ||||
|  | ||||
|   const changeLedOn = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     setData({ led_on: event.target.checked }, saveData); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <ValidatorForm onSubmit={saveData}> | ||||
|       <Box bgcolor="primary.main" color="primary.contrastText" p={2} mt={2} mb={2}> | ||||
|         <Typography variant="body1"> | ||||
|           The switch below controls the LED via the WebSocket. It will automatically update whenever the LED state changes. | ||||
|         </Typography> | ||||
|       </Box> | ||||
|       <BlockFormControlLabel | ||||
|         control={ | ||||
|           <Switch | ||||
|             checked={data.led_on} | ||||
|             onChange={changeLedOn} | ||||
|             color="primary" | ||||
|           /> | ||||
|         } | ||||
|         label="LED State?" | ||||
|       /> | ||||
|     </ValidatorForm> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										9
									
								
								interface/src/project/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								interface/src/project/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| export interface LightState { | ||||
|   led_on: boolean; | ||||
| } | ||||
|  | ||||
| export interface LightMqttSettings {  | ||||
|   unique_id : string; | ||||
|   name: string; | ||||
|   mqtt_path : string; | ||||
| } | ||||
| @@ -154,11 +154,13 @@ class ManageUsersForm extends React.Component<ManageUsersFormProps, ManageUsersF | ||||
|           </Table> | ||||
|           { | ||||
|             this.noAdminConfigured() && | ||||
|             <Typography component="div" variant="body1"> | ||||
|             ( | ||||
|               <Box bgcolor="error.main" color="error.contrastText" p={2} mt={2} mb={2}> | ||||
|                 You must have at least one admin user configured. | ||||
|                 <Typography variant="body1"> | ||||
|                   You must have at least one admin user configured. | ||||
|                 </Typography> | ||||
|               </Box> | ||||
|             </Typography> | ||||
|             ) | ||||
|           } | ||||
|           <FormActions> | ||||
|             <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit" disabled={this.noAdminConfigured()}> | ||||
|   | ||||
| @@ -33,11 +33,11 @@ class SecuritySettingsForm extends React.Component<SecuritySettingsFormProps> { | ||||
|           onChange={handleValueChange('jwt_secret')} | ||||
|           margin="normal" | ||||
|         /> | ||||
|         <Typography component="div" variant="body1"> | ||||
|           <Box bgcolor="primary.main" color="primary.contrastText" p={2} mt={2} mb={2}> | ||||
|         <Box bgcolor="primary.main" color="primary.contrastText" p={2} mt={2} mb={2}> | ||||
|           <Typography variant="body1"> | ||||
|             If you modify the JWT Secret, all users will be logged out. | ||||
|           </Box> | ||||
|         </Typography> | ||||
|           </Typography> | ||||
|         </Box> | ||||
|         <FormActions> | ||||
|           <FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> | ||||
|             Save | ||||
|   | ||||
		Reference in New Issue
	
	Block a user