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:
rjwats
2020-05-14 23:23:45 +01:00
committed by GitHub
parent c47ea49a5d
commit a1f4e57a21
77 changed files with 2907 additions and 1192 deletions

View File

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

View File

@ -1,2 +1 @@
REACT_APP_ENDPOINT_ROOT=/rest/
GENERATE_SOURCEMAP=false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

View 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"
}
}

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@ -0,0 +1,9 @@
export interface LightState {
led_on: boolean;
}
export interface LightMqttSettings {
unique_id : string;
name: string;
mqtt_path : string;
}

View File

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

View File

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