Skip to content

Rework backend add MQTT and WebSocket support #102

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 37 commits into from
May 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ab806e7
Rework backend add MQTT and WebSocket support
rjwats Apr 7, 2020
5f396f4
de-dupe define names
rjwats Apr 7, 2020
9c94ce4
infer endpoint root in production mode
rjwats Apr 7, 2020
96d95b9
Start improving documentation to describe newly added and modified fu…
rjwats Apr 7, 2020
2a52c78
add security to websockets
rjwats Apr 13, 2020
02ba38c
more improvements to documentation
rjwats Apr 22, 2020
fdf676a
fix code examples in readme
rjwats Apr 22, 2020
bb1c81f
continue documenting new features
rjwats Apr 22, 2020
27e049b
update documentation in the ui
rjwats Apr 22, 2020
c33479a
add documentation
rjwats Apr 26, 2020
c44d4af
Fix typos in README.md
rjwats May 2, 2020
b00c6fb
start conversion of serializers to static functions
rjwats May 6, 2020
134de74
Allow SettingsService to forward constructor arguments to settings in…
rjwats May 8, 2020
518313f
Reduce use of callbacks with overloads for common use-case
rjwats May 8, 2020
18d6adc
document new overloaded functions
rjwats May 8, 2020
94c5ef4
fix argument names
rjwats May 9, 2020
314e6b1
Introduce separate MQTT pub/sub, HTTP get/post and WebSocket rx/tx cl…
rjwats May 9, 2020
66ffb27
fix component name and props name
rjwats May 10, 2020
a511a52
delegate to mqtt client connected function instead of tracking connec…
rjwats May 10, 2020
6180211
Retain copies of the cstr values we pass to AsyncMqttClient
rjwats May 10, 2020
e125e46
Update comments, remove references to HomeAssistant.
rjwats May 10, 2020
52ba541
Merge branch 'ft-mqtt-websockets' into rename-experiment
rjwats May 10, 2020
7af7fba
Replace use of SettingsEndpoint, and SettingsPersistence.
rjwats May 10, 2020
b54f076
rename SettingsService to StatefulService
rjwats May 10, 2020
e35ebb3
introduce "core" and "util" directories
rjwats May 10, 2020
8adcf96
move util and core back to framework for now
rjwats May 11, 2020
c74f770
Asymmetrical implementations & renames of core classes
rjwats May 11, 2020
4f48c74
two stage rename for mqtt
rjwats May 12, 2020
189476f
Complete two-part rename of MQTT front-end and back-end classes
rjwats May 12, 2020
8dc87f0
fix broken rename
rjwats May 12, 2020
de330c8
fix broken rename
rjwats May 12, 2020
437cac7
rename example project in keeping with removal of "Settings" concept …
rjwats May 12, 2020
d705c1b
rename Socket to WebSocket in front-end
rjwats May 12, 2020
eb3dd4a
Use PROGMEM_WWW as default
rjwats May 13, 2020
33b08a5
update light state endpoint paths
rjwats May 13, 2020
4dce60b
Update README with API changes
rjwats May 13, 2020
2332671
update diagram
rjwats May 13, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
364 changes: 236 additions & 128 deletions README.md

Large diffs are not rendered by default.

3 changes: 0 additions & 3 deletions data/config/demoSettings.json

This file was deleted.

11 changes: 11 additions & 0 deletions data/config/mqttSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"enabled": false,
"host": "test.mosquitto.org",
"port": 1883,
"authenticated": false,
"username": "mqttuser",
"password": "mqttpassword",
"keepAlive": 16,
"cleanSession": true,
"maxTopicLength": 128
}
3 changes: 2 additions & 1 deletion interface/.env.development
Original file line number Diff line number Diff line change
@@ -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 change: 0 additions & 1 deletion interface/.env.production
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
REACT_APP_ENDPOINT_ROOT=/rest/
GENERATE_SOURCEMAP=false
10 changes: 10 additions & 0 deletions interface/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions interface/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"
},
Expand Down
2 changes: 2 additions & 0 deletions interface/src/AppRouting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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="/" />
Expand Down
2 changes: 2 additions & 0 deletions interface/src/api/Endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
23 changes: 22 additions & 1 deletion interface/src/api/Env.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions interface/src/authentication/Authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
7 changes: 7 additions & 0 deletions interface/src/components/MenuAppBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 />
Expand Down
29 changes: 11 additions & 18 deletions interface/src/components/RestController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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>> {
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 2 additions & 1 deletion interface/src/components/RestFormLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
133 changes: 133 additions & 0 deletions interface/src/components/WebSocketController.tsx
Original file line number Diff line number Diff line change
@@ -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}
/>;
}

});
}
Loading