>(endpointUrl: string, RestController: React.ComponentType>) {
+ return withSnackbar(
+ class extends React.Component> & WithSnackbarProps, RestControllerState> {
+
+ state: RestControllerState = {
+ data: undefined,
+ loading: false,
+ errorMessage: undefined
+ };
+
+ setData = (data: D, callback?: () => void) => {
+ this.setState({
+ data,
+ loading: false,
+ errorMessage: undefined
+ }, callback);
+ }
+
+ loadData = () => {
+ this.setState({
+ data: undefined,
+ loading: true,
+ errorMessage: undefined
+ });
+ redirectingAuthorizedFetch(endpointUrl).then(response => {
+ if (response.status === 200) {
+ return response.json();
+ }
+ throw Error("Invalid status code: " + response.status);
+ }).then(json => {
+ this.setState({ data: json, loading: false })
+ }).catch(error => {
+ const errorMessage = error.message || "Unknown error";
+ this.props.enqueueSnackbar("Problem fetching: " + errorMessage, { variant: 'error' });
+ this.setState({ data: undefined, loading: false, errorMessage });
+ });
+ }
+
+ saveData = () => {
+ this.setState({ loading: true });
+ redirectingAuthorizedFetch(endpointUrl, {
+ method: 'POST',
+ body: JSON.stringify(this.state.data),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ }).then(response => {
+ if (response.status === 200) {
+ return response.json();
+ }
+ throw Error("Invalid status code: " + response.status);
+ }).then(json => {
+ this.props.enqueueSnackbar("Update successful.", { variant: 'success' });
+ this.setState({ data: json, loading: false });
+ }).catch(error => {
+ const errorMessage = error.message || "Unknown error";
+ this.props.enqueueSnackbar("Problem updating: " + errorMessage, { variant: 'error' });
+ this.setState({ data: undefined, loading: false, errorMessage });
+ });
+ }
+
+ handleValueChange = (name: keyof D) => (event: React.ChangeEvent) => {
+ const data = { ...this.state.data!, [name]: extractEventValue(event) };
+ this.setState({ data });
+ }
+
+ render() {
+ return ;
+ }
+
+ });
+}
diff --git a/interface/src/components/RestFormLoader.tsx b/interface/src/components/RestFormLoader.tsx
new file mode 100644
index 0000000..29fb304
--- /dev/null
+++ b/interface/src/components/RestFormLoader.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+
+import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
+import { Button, LinearProgress, Typography } from '@material-ui/core';
+
+import { RestControllerProps } from '.';
+
+const useStyles = makeStyles((theme: Theme) =>
+ createStyles({
+ loadingSettings: {
+ margin: theme.spacing(0.5),
+ },
+ loadingSettingsDetails: {
+ margin: theme.spacing(4),
+ textAlign: "center"
+ },
+ button: {
+ marginRight: theme.spacing(2),
+ marginTop: theme.spacing(2),
+ }
+ })
+);
+
+export type RestFormProps = Omit, "loading" | "errorMessage"> & { data: D };
+
+interface RestFormLoaderProps extends RestControllerProps {
+ render: (props: RestFormProps) => JSX.Element;
+}
+
+export default function RestFormLoader(props: RestFormLoaderProps) {
+ const { loading, errorMessage, loadData, render, data, ...rest } = props;
+ const classes = useStyles();
+ if (loading || !data) {
+ return (
+
+
+
+ Loading…
+
+
+ );
+ }
+ if (errorMessage) {
+ return (
+
+
+ {errorMessage}
+
+
+
+ );
+ }
+ return render({ ...rest, loadData, data });
+}
diff --git a/interface/src/components/SectionContent.tsx b/interface/src/components/SectionContent.tsx
new file mode 100644
index 0000000..457014f
--- /dev/null
+++ b/interface/src/components/SectionContent.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+
+import { Typography, Paper } from '@material-ui/core';
+import { createStyles, Theme, makeStyles } from '@material-ui/core/styles';
+
+const useStyles = makeStyles((theme: Theme) =>
+ createStyles({
+ content: {
+ padding: theme.spacing(2),
+ margin: theme.spacing(3),
+ }
+ })
+);
+
+interface SectionContentProps {
+ title: string;
+ titleGutter?: boolean;
+}
+
+const SectionContent: React.FC = (props) => {
+ const { children, title, titleGutter } = props;
+ const classes = useStyles();
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+};
+
+export default SectionContent;
diff --git a/interface/src/components/SingleUpload.tsx b/interface/src/components/SingleUpload.tsx
new file mode 100644
index 0000000..003c286
--- /dev/null
+++ b/interface/src/components/SingleUpload.tsx
@@ -0,0 +1,96 @@
+import React, { FC, Fragment } from 'react';
+import { useDropzone, DropzoneState } from 'react-dropzone';
+
+import { makeStyles, createStyles } from '@material-ui/styles';
+import CloudUploadIcon from '@material-ui/icons/CloudUpload';
+import CancelIcon from '@material-ui/icons/Cancel';
+import { Theme, Box, Typography, LinearProgress, Button } from '@material-ui/core';
+
+interface SingleUploadStyleProps extends DropzoneState {
+ uploading: boolean;
+}
+
+const progressPercentage = (progress: ProgressEvent) => Math.round((progress.loaded * 100) / progress.total);
+
+const getBorderColor = (theme: Theme, props: SingleUploadStyleProps) => {
+ if (props.isDragAccept) {
+ return theme.palette.success.main;
+ }
+ if (props.isDragReject) {
+ return theme.palette.error.main;
+ }
+ if (props.isDragActive) {
+ return theme.palette.info.main;
+ }
+ return theme.palette.grey[700];
+}
+
+const useStyles = makeStyles((theme: Theme) => createStyles({
+ dropzone: {
+ padding: theme.spacing(8, 2),
+ borderWidth: 2,
+ borderRadius: 2,
+ borderStyle: 'dashed',
+ color: theme.palette.grey[700],
+ transition: 'border .24s ease-in-out',
+ cursor: (props: SingleUploadStyleProps) => props.uploading ? 'default' : 'pointer',
+ width: '100%',
+ borderColor: (props: SingleUploadStyleProps) => getBorderColor(theme, props)
+ }
+}));
+
+export interface SingleUploadProps {
+ onDrop: (acceptedFiles: File[]) => void;
+ onCancel: () => void;
+ accept?: string | string[];
+ uploading: boolean;
+ progress?: ProgressEvent;
+}
+
+const SingleUpload: FC = ({ onDrop, onCancel, accept, uploading, progress }) => {
+ const dropzoneState = useDropzone({ onDrop, accept, disabled: uploading, multiple: false });
+ const { getRootProps, getInputProps } = dropzoneState;
+ const classes = useStyles({ ...dropzoneState, uploading });
+
+
+ const renderProgressText = () => {
+ if (uploading) {
+ if (progress?.lengthComputable) {
+ return `Uploading: ${progressPercentage(progress)}%`;
+ }
+ return "Uploading\u2026";
+ }
+ return "Drop file or click here";
+ }
+
+ const renderProgress = (progress?: ProgressEvent) => (
+
+ );
+
+ return (
+
+
+
+
+
+ {renderProgressText()}
+
+ {uploading && (
+
+
+ {renderProgress(progress)}
+
+ } variant="contained" color="secondary" onClick={onCancel}>
+ Cancel
+
+
+ )}
+
+
+ );
+}
+
+export default SingleUpload;
diff --git a/interface/src/components/WebSocketController.tsx b/interface/src/components/WebSocketController.tsx
new file mode 100644
index 0000000..5fe9fa3
--- /dev/null
+++ b/interface/src/components/WebSocketController.tsx
@@ -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 extends WithSnackbarProps {
+ handleValueChange: (name: keyof D) => (event: React.ChangeEvent) => void;
+
+ setData: (data: D, callback?: () => void) => void;
+ saveData: () => void;
+ saveDataAndClear(): () => void;
+
+ connected: boolean;
+ data?: D;
+}
+
+interface WebSocketControllerState {
+ ws: Sockette;
+ connected: boolean;
+ clientId?: string;
+ data?: D;
+}
+
+enum WebSocketMessageType {
+ ID = "id",
+ PAYLOAD = "payload"
+}
+
+interface WebSocketIdMessage {
+ type: typeof WebSocketMessageType.ID;
+ id: string;
+}
+
+interface WebSocketPayloadMessage {
+ type: typeof WebSocketMessageType.PAYLOAD;
+ origin_id: string;
+ payload: D;
+}
+
+export type WebSocketMessage = WebSocketIdMessage | WebSocketPayloadMessage;
+
+export function webSocketController>(wsUrl: string, wsThrottle: number, WebSocketController: React.ComponentType>) {
+ return withSnackbar(
+ class extends React.Component> & WithSnackbarProps, WebSocketControllerState> {
+ constructor(props: Omit> & 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);
+ }
+ }
+
+ handleMessage = (message: WebSocketMessage) => {
+ 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) => {
+ const data = { ...this.state.data!, [name]: extractEventValue(event) };
+ this.setState({ data });
+ }
+
+ render() {
+ return ;
+ }
+
+ });
+}
diff --git a/interface/src/components/WebSocketFormLoader.tsx b/interface/src/components/WebSocketFormLoader.tsx
new file mode 100644
index 0000000..ee5f335
--- /dev/null
+++ b/interface/src/components/WebSocketFormLoader.tsx
@@ -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 = Omit, "connected"> & { data: D };
+
+interface WebSocketFormLoaderProps extends WebSocketControllerProps {
+ render: (props: WebSocketFormProps) => JSX.Element;
+}
+
+export default function WebSocketFormLoader(props: WebSocketFormLoaderProps) {
+ const { connected, render, data, ...rest } = props;
+ const classes = useStyles();
+ if (!connected || !data) {
+ return (
+
+
+
+ Connecting to WebSocket...
+
+
+ );
+ }
+ return render({ ...rest, data });
+}
diff --git a/interface/src/components/index.ts b/interface/src/components/index.ts
new file mode 100644
index 0000000..4a6cc6a
--- /dev/null
+++ b/interface/src/components/index.ts
@@ -0,0 +1,17 @@
+export { default as BlockFormControlLabel } from './BlockFormControlLabel';
+export { default as FormActions } from './FormActions';
+export { default as FormButton } from './FormButton';
+export { default as HighlightAvatar } from './HighlightAvatar';
+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 { default as ErrorButton } from './ErrorButton';
+export { default as SingleUpload } from './SingleUpload';
+
+export * from './RestFormLoader';
+export * from './RestController';
+
+export * from './WebSocketFormLoader';
+export * from './WebSocketController';
diff --git a/interface/src/features/ApplicationContext.tsx b/interface/src/features/ApplicationContext.tsx
new file mode 100644
index 0000000..fbc15f2
--- /dev/null
+++ b/interface/src/features/ApplicationContext.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { Features } from './types';
+
+export interface ApplicationContext {
+ features: Features;
+}
+
+const ApplicationContextDefaultValue = {} as ApplicationContext
+export const ApplicationContext = React.createContext(
+ ApplicationContextDefaultValue
+);
+
+export function withAuthenticatedContexApplicationContext(Component: React.ComponentType) {
+ return class extends React.Component> {
+ render() {
+ return (
+
+ {authenticatedContext => }
+
+ );
+ }
+ };
+}
diff --git a/interface/src/features/FeaturesContext.tsx b/interface/src/features/FeaturesContext.tsx
new file mode 100644
index 0000000..8c8f104
--- /dev/null
+++ b/interface/src/features/FeaturesContext.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { Features } from './types';
+
+export interface FeaturesContext {
+ features: Features;
+}
+
+const FeaturesContextDefaultValue = {} as FeaturesContext
+export const FeaturesContext = React.createContext(
+ FeaturesContextDefaultValue
+);
+
+export interface WithFeaturesProps {
+ features: Features;
+}
+
+export function withFeatures(Component: React.ComponentType) {
+ return class extends React.Component> {
+ render() {
+ return (
+
+ {featuresContext => }
+
+ );
+ }
+ };
+}
diff --git a/interface/src/features/FeaturesWrapper.tsx b/interface/src/features/FeaturesWrapper.tsx
new file mode 100644
index 0000000..aac3533
--- /dev/null
+++ b/interface/src/features/FeaturesWrapper.tsx
@@ -0,0 +1,61 @@
+import React, { Component } from 'react';
+
+import { Features } from './types';
+import { FeaturesContext } from './FeaturesContext';
+import FullScreenLoading from '../components/FullScreenLoading';
+import ApplicationError from '../components/ApplicationError';
+import { FEATURES_ENDPOINT } from '../api';
+
+interface FeaturesWrapperState {
+ features?: Features;
+ error?: string;
+};
+
+class FeaturesWrapper extends Component<{}, FeaturesWrapperState> {
+
+ state: FeaturesWrapperState = {};
+
+ componentDidMount() {
+ this.fetchFeaturesDetails();
+ }
+
+ fetchFeaturesDetails = () => {
+ fetch(FEATURES_ENDPOINT)
+ .then(response => {
+ if (response.status === 200) {
+ return response.json();
+ } else {
+ throw Error("Unexpected status code: " + response.status);
+ }
+ }).then(features => {
+ this.setState({ features });
+ })
+ .catch(error => {
+ this.setState({ error: error.message });
+ });
+ }
+
+ render() {
+ const { features, error } = this.state;
+ if (features) {
+ return (
+
+ {this.props.children}
+
+ );
+ }
+ if (error) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+
+}
+
+export default FeaturesWrapper;
diff --git a/interface/src/features/types.ts b/interface/src/features/types.ts
new file mode 100644
index 0000000..1753d9a
--- /dev/null
+++ b/interface/src/features/types.ts
@@ -0,0 +1,8 @@
+export interface Features {
+ project: boolean;
+ security: boolean;
+ mqtt: boolean;
+ ntp: boolean;
+ ota: boolean;
+ upload_firmware: boolean;
+}
diff --git a/interface/src/history.ts b/interface/src/history.ts
new file mode 100644
index 0000000..eb70d7b
--- /dev/null
+++ b/interface/src/history.ts
@@ -0,0 +1,5 @@
+import { createBrowserHistory } from 'history';
+
+export default createBrowserHistory({
+ /* pass a configuration object here if needed */
+})
diff --git a/interface/src/index.tsx b/interface/src/index.tsx
new file mode 100644
index 0000000..a0801fc
--- /dev/null
+++ b/interface/src/index.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { render } from 'react-dom';
+
+import history from './history';
+import { Router } from 'react-router';
+
+import App from './App';
+
+render((
+
+
+
+), document.getElementById("root"))
diff --git a/interface/src/mqtt/Mqtt.tsx b/interface/src/mqtt/Mqtt.tsx
new file mode 100644
index 0000000..8daca77
--- /dev/null
+++ b/interface/src/mqtt/Mqtt.tsx
@@ -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 {
+
+ handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
+ this.props.history.push(path);
+ };
+
+ render() {
+ const { authenticatedContext } = this.props;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+}
+
+export default withAuthenticatedContext(Mqtt);
diff --git a/interface/src/mqtt/MqttSettingsController.tsx b/interface/src/mqtt/MqttSettingsController.tsx
new file mode 100644
index 0000000..8cc9d16
--- /dev/null
+++ b/interface/src/mqtt/MqttSettingsController.tsx
@@ -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;
+
+class MqttSettingsController extends Component {
+
+ componentDidMount() {
+ this.props.loadData();
+ }
+
+ render() {
+ return (
+
+ }
+ />
+
+ )
+ }
+
+}
+
+export default restController(MQTT_SETTINGS_ENDPOINT, MqttSettingsController);
diff --git a/interface/src/mqtt/MqttSettingsForm.tsx b/interface/src/mqtt/MqttSettingsForm.tsx
new file mode 100644
index 0000000..fb9c77e
--- /dev/null
+++ b/interface/src/mqtt/MqttSettingsForm.tsx
@@ -0,0 +1,128 @@
+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;
+
+class MqttSettingsForm extends React.Component {
+
+ componentDidMount() {
+ ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
+ }
+
+ render() {
+ const { data, handleValueChange, saveData } = this.props;
+ return (
+
+
+ }
+ label="Enable MQTT?"
+ />
+
+
+
+
+
+
+
+ }
+ label="Clean Session?"
+ />
+
+
+ } variant="contained" color="primary" type="submit">
+ Save
+
+
+
+ );
+ }
+}
+
+export default MqttSettingsForm;
diff --git a/interface/src/mqtt/MqttStatus.ts b/interface/src/mqtt/MqttStatus.ts
new file mode 100644
index 0000000..b9bb80c
--- /dev/null
+++ b/interface/src/mqtt/MqttStatus.ts
@@ -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"
+ }
+}
diff --git a/interface/src/mqtt/MqttStatusController.tsx b/interface/src/mqtt/MqttStatusController.tsx
new file mode 100644
index 0000000..4dd5409
--- /dev/null
+++ b/interface/src/mqtt/MqttStatusController.tsx
@@ -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;
+
+class MqttStatusController extends Component {
+
+ componentDidMount() {
+ this.props.loadData();
+ }
+
+ render() {
+ return (
+
+ }
+ />
+
+ )
+ }
+}
+
+export default restController(MQTT_STATUS_ENDPOINT, MqttStatusController);
diff --git a/interface/src/mqtt/MqttStatusForm.tsx b/interface/src/mqtt/MqttStatusForm.tsx
new file mode 100644
index 0000000..5a80a41
--- /dev/null
+++ b/interface/src/mqtt/MqttStatusForm.tsx
@@ -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 & WithTheme;
+
+class MqttStatusForm extends Component {
+
+ renderConnectionStatus() {
+ const { data } = this.props
+ if (data.connected) {
+ return (
+
+
+
+ #
+
+
+
+
+
+ );
+ }
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ createListItems() {
+ const { data, theme } = this.props
+ return (
+
+
+
+
+
+
+
+
+
+
+ {data.enabled && this.renderConnectionStatus()}
+
+ );
+ }
+
+ render() {
+ return (
+
+
+ {this.createListItems()}
+
+
+ } variant="contained" color="secondary" onClick={this.props.loadData}>
+ Refresh
+
+
+
+ );
+ }
+
+}
+
+export default withTheme(MqttStatusForm);
diff --git a/interface/src/mqtt/types.ts b/interface/src/mqtt/types.ts
new file mode 100644
index 0000000..04e20ca
--- /dev/null
+++ b/interface/src/mqtt/types.ts
@@ -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;
+}
diff --git a/interface/src/ntp/NTPSettingsController.tsx b/interface/src/ntp/NTPSettingsController.tsx
new file mode 100644
index 0000000..78f5f63
--- /dev/null
+++ b/interface/src/ntp/NTPSettingsController.tsx
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+
+import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
+import { NTP_SETTINGS_ENDPOINT } from '../api';
+
+import NTPSettingsForm from './NTPSettingsForm';
+import { NTPSettings } from './types';
+
+type NTPSettingsControllerProps = RestControllerProps;
+
+class NTPSettingsController extends Component {
+
+ componentDidMount() {
+ this.props.loadData();
+ }
+
+ render() {
+ return (
+
+ }
+ />
+
+ )
+ }
+
+}
+
+export default restController(NTP_SETTINGS_ENDPOINT, NTPSettingsController);
diff --git a/interface/src/ntp/NTPSettingsForm.tsx b/interface/src/ntp/NTPSettingsForm.tsx
new file mode 100644
index 0000000..c3e4dab
--- /dev/null
+++ b/interface/src/ntp/NTPSettingsForm.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { TextValidator, ValidatorForm, SelectValidator } from 'react-material-ui-form-validator';
+
+import { Checkbox, MenuItem } from '@material-ui/core';
+import SaveIcon from '@material-ui/icons/Save';
+
+import { RestFormProps, FormActions, FormButton, BlockFormControlLabel } from '../components';
+import { isIP, isHostname, or } from '../validators';
+
+import { TIME_ZONES, timeZoneSelectItems, selectedTimeZone } from './TZ';
+import { NTPSettings } from './types';
+
+type NTPSettingsFormProps = RestFormProps;
+
+class NTPSettingsForm extends React.Component {
+
+ componentDidMount() {
+ ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
+ }
+
+ changeTimeZone = (event: React.ChangeEvent) => {
+ const { data, setData } = this.props;
+ setData({
+ ...data,
+ tz_label: event.target.value,
+ tz_format: TIME_ZONES[event.target.value]
+ });
+ }
+
+ render() {
+ const { data, handleValueChange, saveData } = this.props;
+ return (
+
+
+ }
+ label="Enable NTP?"
+ />
+
+
+
+ {timeZoneSelectItems()}
+
+
+ } variant="contained" color="primary" type="submit">
+ Save
+
+
+
+ );
+ }
+}
+
+export default NTPSettingsForm;
diff --git a/interface/src/ntp/NTPStatus.ts b/interface/src/ntp/NTPStatus.ts
new file mode 100644
index 0000000..744b56d
--- /dev/null
+++ b/interface/src/ntp/NTPStatus.ts
@@ -0,0 +1,26 @@
+import { Theme } from "@material-ui/core";
+import { NTPStatus, NTPSyncStatus } from "./types";
+
+export const isNtpActive = ({ status }: NTPStatus) => status === NTPSyncStatus.NTP_ACTIVE;
+
+export const ntpStatusHighlight = ({ status }: NTPStatus, theme: Theme) => {
+ switch (status) {
+ case NTPSyncStatus.NTP_INACTIVE:
+ return theme.palette.info.main;
+ case NTPSyncStatus.NTP_ACTIVE:
+ return theme.palette.success.main;
+ default:
+ return theme.palette.error.main;
+ }
+}
+
+export const ntpStatus = ({ status }: NTPStatus) => {
+ switch (status) {
+ case NTPSyncStatus.NTP_INACTIVE:
+ return "Inactive";
+ case NTPSyncStatus.NTP_ACTIVE:
+ return "Active";
+ default:
+ return "Unknown";
+ }
+}
diff --git a/interface/src/ntp/NTPStatusController.tsx b/interface/src/ntp/NTPStatusController.tsx
new file mode 100644
index 0000000..25ea4de
--- /dev/null
+++ b/interface/src/ntp/NTPStatusController.tsx
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+
+import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
+import { NTP_STATUS_ENDPOINT } from '../api';
+
+import NTPStatusForm from './NTPStatusForm';
+import { NTPStatus } from './types';
+
+type NTPStatusControllerProps = RestControllerProps;
+
+class NTPStatusController extends Component {
+
+ componentDidMount() {
+ this.props.loadData();
+ }
+
+ render() {
+ return (
+
+ }
+ />
+
+ );
+ }
+
+}
+
+export default restController(NTP_STATUS_ENDPOINT, NTPStatusController);
diff --git a/interface/src/ntp/NTPStatusForm.tsx b/interface/src/ntp/NTPStatusForm.tsx
new file mode 100644
index 0000000..6b277dd
--- /dev/null
+++ b/interface/src/ntp/NTPStatusForm.tsx
@@ -0,0 +1,198 @@
+import React, { Component, Fragment } from 'react';
+import moment from 'moment';
+
+import { WithTheme, withTheme } from '@material-ui/core/styles';
+import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText, Button } from '@material-ui/core';
+import { Dialog, DialogTitle, DialogContent, DialogActions, Box, TextField } from '@material-ui/core';
+
+import SwapVerticalCircleIcon from '@material-ui/icons/SwapVerticalCircle';
+import AccessTimeIcon from '@material-ui/icons/AccessTime';
+import DNSIcon from '@material-ui/icons/Dns';
+import UpdateIcon from '@material-ui/icons/Update';
+import AvTimerIcon from '@material-ui/icons/AvTimer';
+import RefreshIcon from '@material-ui/icons/Refresh';
+
+import { RestFormProps, FormButton, HighlightAvatar } from '../components';
+import { isNtpActive, ntpStatusHighlight, ntpStatus } from './NTPStatus';
+import { formatIsoDateTime, formatLocalDateTime } from './TimeFormat';
+import { NTPStatus, Time } from './types';
+import { redirectingAuthorizedFetch, withAuthenticatedContext, AuthenticatedContextProps } from '../authentication';
+import { TIME_ENDPOINT } from '../api';
+
+type NTPStatusFormProps = RestFormProps & WithTheme & AuthenticatedContextProps;
+
+interface NTPStatusFormState {
+ settingTime: boolean;
+ localTime: string;
+ processing: boolean;
+}
+
+class NTPStatusForm extends Component {
+
+ constructor(props: NTPStatusFormProps) {
+ super(props);
+ this.state = {
+ settingTime: false,
+ localTime: '',
+ processing: false
+ };
+ }
+
+ updateLocalTime = (event: React.ChangeEvent) => {
+ this.setState({ localTime: event.target.value });
+ }
+
+ openSetTime = () => {
+ this.setState({ localTime: formatLocalDateTime(moment()), settingTime: true, });
+ }
+
+ closeSetTime = () => {
+ this.setState({ settingTime: false });
+ }
+
+ createAdjustedTime = (): Time => {
+ const currentLocalTime = moment(this.props.data.time_local);
+ const newLocalTime = moment(this.state.localTime);
+ newLocalTime.subtract(currentLocalTime.utcOffset())
+ newLocalTime.milliseconds(0);
+ newLocalTime.utc();
+ return {
+ time_utc: newLocalTime.format()
+ }
+ }
+
+ configureTime = () => {
+ this.setState({ processing: true });
+ redirectingAuthorizedFetch(TIME_ENDPOINT,
+ {
+ method: 'POST',
+ body: JSON.stringify(this.createAdjustedTime()),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ })
+ .then(response => {
+ if (response.status === 200) {
+ this.props.enqueueSnackbar("Time set successfully", { variant: 'success' });
+ this.setState({ processing: false, settingTime: false }, this.props.loadData);
+ } else {
+ throw Error("Error setting time, status code: " + response.status);
+ }
+ })
+ .catch(error => {
+ this.props.enqueueSnackbar(error.message || "Problem setting the time", { variant: 'error' });
+ this.setState({ processing: false, settingTime: false });
+ });
+ }
+
+ renderSetTimeDialog() {
+ return (
+
+ )
+ }
+
+ render() {
+ const { data, theme } = this.props
+ const me = this.props.authenticatedContext.me;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {isNtpActive(data) && (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } variant="contained" color="secondary" onClick={this.props.loadData}>
+ Refresh
+
+
+ {me.admin && !isNtpActive(data) && (
+
+ }>
+ Set Time
+
+
+ )}
+
+ {this.renderSetTimeDialog()}
+
+ );
+ }
+}
+
+export default withAuthenticatedContext(withTheme(NTPStatusForm));
diff --git a/interface/src/ntp/NetworkTime.tsx b/interface/src/ntp/NetworkTime.tsx
new file mode 100644
index 0000000..ebefb6e
--- /dev/null
+++ b/interface/src/ntp/NetworkTime.tsx
@@ -0,0 +1,39 @@
+import React, { Component } from 'react';
+import { Redirect, Switch, RouteComponentProps } from 'react-router-dom'
+
+import { Tabs, Tab } from '@material-ui/core';
+
+import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication';
+import { MenuAppBar } from '../components';
+
+import NTPStatusController from './NTPStatusController';
+import NTPSettingsController from './NTPSettingsController';
+
+type NetworkTimeProps = AuthenticatedContextProps & RouteComponentProps;
+
+class NetworkTime extends Component {
+
+ handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
+ this.props.history.push(path);
+ };
+
+ render() {
+ const { authenticatedContext } = this.props;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+}
+
+export default withAuthenticatedContext(NetworkTime)
diff --git a/interface/src/ntp/TZ.tsx b/interface/src/ntp/TZ.tsx
new file mode 100644
index 0000000..4e56e9d
--- /dev/null
+++ b/interface/src/ntp/TZ.tsx
@@ -0,0 +1,479 @@
+import React from 'react';
+import MenuItem from '@material-ui/core/MenuItem';
+
+type TimeZones = {
+ [name: string]: string
+};
+
+export const TIME_ZONES: TimeZones = {
+ "Africa/Abidjan": "GMT0",
+ "Africa/Accra": "GMT0",
+ "Africa/Addis_Ababa": "EAT-3",
+ "Africa/Algiers": "CET-1",
+ "Africa/Asmara": "EAT-3",
+ "Africa/Bamako": "GMT0",
+ "Africa/Bangui": "WAT-1",
+ "Africa/Banjul": "GMT0",
+ "Africa/Bissau": "GMT0",
+ "Africa/Blantyre": "CAT-2",
+ "Africa/Brazzaville": "WAT-1",
+ "Africa/Bujumbura": "CAT-2",
+ "Africa/Cairo": "EET-2",
+ "Africa/Casablanca": "UNK-1",
+ "Africa/Ceuta": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Africa/Conakry": "GMT0",
+ "Africa/Dakar": "GMT0",
+ "Africa/Dar_es_Salaam": "EAT-3",
+ "Africa/Djibouti": "EAT-3",
+ "Africa/Douala": "WAT-1",
+ "Africa/El_Aaiun": "UNK-1",
+ "Africa/Freetown": "GMT0",
+ "Africa/Gaborone": "CAT-2",
+ "Africa/Harare": "CAT-2",
+ "Africa/Johannesburg": "SAST-2",
+ "Africa/Juba": "EAT-3",
+ "Africa/Kampala": "EAT-3",
+ "Africa/Khartoum": "CAT-2",
+ "Africa/Kigali": "CAT-2",
+ "Africa/Kinshasa": "WAT-1",
+ "Africa/Lagos": "WAT-1",
+ "Africa/Libreville": "WAT-1",
+ "Africa/Lome": "GMT0",
+ "Africa/Luanda": "WAT-1",
+ "Africa/Lubumbashi": "CAT-2",
+ "Africa/Lusaka": "CAT-2",
+ "Africa/Malabo": "WAT-1",
+ "Africa/Maputo": "CAT-2",
+ "Africa/Maseru": "SAST-2",
+ "Africa/Mbabane": "SAST-2",
+ "Africa/Mogadishu": "EAT-3",
+ "Africa/Monrovia": "GMT0",
+ "Africa/Nairobi": "EAT-3",
+ "Africa/Ndjamena": "WAT-1",
+ "Africa/Niamey": "WAT-1",
+ "Africa/Nouakchott": "GMT0",
+ "Africa/Ouagadougou": "GMT0",
+ "Africa/Porto-Novo": "WAT-1",
+ "Africa/Sao_Tome": "GMT0",
+ "Africa/Tripoli": "EET-2",
+ "Africa/Tunis": "CET-1",
+ "Africa/Windhoek": "CAT-2",
+ "America/Adak": "HST10HDT,M3.2.0,M11.1.0",
+ "America/Anchorage": "AKST9AKDT,M3.2.0,M11.1.0",
+ "America/Anguilla": "AST4",
+ "America/Antigua": "AST4",
+ "America/Araguaina": "UNK3",
+ "America/Argentina/Buenos_Aires": "UNK3",
+ "America/Argentina/Catamarca": "UNK3",
+ "America/Argentina/Cordoba": "UNK3",
+ "America/Argentina/Jujuy": "UNK3",
+ "America/Argentina/La_Rioja": "UNK3",
+ "America/Argentina/Mendoza": "UNK3",
+ "America/Argentina/Rio_Gallegos": "UNK3",
+ "America/Argentina/Salta": "UNK3",
+ "America/Argentina/San_Juan": "UNK3",
+ "America/Argentina/San_Luis": "UNK3",
+ "America/Argentina/Tucuman": "UNK3",
+ "America/Argentina/Ushuaia": "UNK3",
+ "America/Aruba": "AST4",
+ "America/Asuncion": "UNK4UNK,M10.1.0/0,M3.4.0/0",
+ "America/Atikokan": "EST5",
+ "America/Bahia": "UNK3",
+ "America/Bahia_Banderas": "CST6CDT,M4.1.0,M10.5.0",
+ "America/Barbados": "AST4",
+ "America/Belem": "UNK3",
+ "America/Belize": "CST6",
+ "America/Blanc-Sablon": "AST4",
+ "America/Boa_Vista": "UNK4",
+ "America/Bogota": "UNK5",
+ "America/Boise": "MST7MDT,M3.2.0,M11.1.0",
+ "America/Cambridge_Bay": "MST7MDT,M3.2.0,M11.1.0",
+ "America/Campo_Grande": "UNK4",
+ "America/Cancun": "EST5",
+ "America/Caracas": "UNK4",
+ "America/Cayenne": "UNK3",
+ "America/Cayman": "EST5",
+ "America/Chicago": "CST6CDT,M3.2.0,M11.1.0",
+ "America/Chihuahua": "MST7MDT,M4.1.0,M10.5.0",
+ "America/Costa_Rica": "CST6",
+ "America/Creston": "MST7",
+ "America/Cuiaba": "UNK4",
+ "America/Curacao": "AST4",
+ "America/Danmarkshavn": "GMT0",
+ "America/Dawson": "MST7",
+ "America/Dawson_Creek": "MST7",
+ "America/Denver": "MST7MDT,M3.2.0,M11.1.0",
+ "America/Detroit": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Dominica": "AST4",
+ "America/Edmonton": "MST7MDT,M3.2.0,M11.1.0",
+ "America/Eirunepe": "UNK5",
+ "America/El_Salvador": "CST6",
+ "America/Fort_Nelson": "MST7",
+ "America/Fortaleza": "UNK3",
+ "America/Glace_Bay": "AST4ADT,M3.2.0,M11.1.0",
+ "America/Godthab": "UNK3UNK,M3.5.0/-2,M10.5.0/-1",
+ "America/Goose_Bay": "AST4ADT,M3.2.0,M11.1.0",
+ "America/Grand_Turk": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Grenada": "AST4",
+ "America/Guadeloupe": "AST4",
+ "America/Guatemala": "CST6",
+ "America/Guayaquil": "UNK5",
+ "America/Guyana": "UNK4",
+ "America/Halifax": "AST4ADT,M3.2.0,M11.1.0",
+ "America/Havana": "CST5CDT,M3.2.0/0,M11.1.0/1",
+ "America/Hermosillo": "MST7",
+ "America/Indiana/Indianapolis": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Indiana/Knox": "CST6CDT,M3.2.0,M11.1.0",
+ "America/Indiana/Marengo": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Indiana/Petersburg": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Indiana/Tell_City": "CST6CDT,M3.2.0,M11.1.0",
+ "America/Indiana/Vevay": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Indiana/Vincennes": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Indiana/Winamac": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Inuvik": "MST7MDT,M3.2.0,M11.1.0",
+ "America/Iqaluit": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Jamaica": "EST5",
+ "America/Juneau": "AKST9AKDT,M3.2.0,M11.1.0",
+ "America/Kentucky/Louisville": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Kentucky/Monticello": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Kralendijk": "AST4",
+ "America/La_Paz": "UNK4",
+ "America/Lima": "UNK5",
+ "America/Los_Angeles": "PST8PDT,M3.2.0,M11.1.0",
+ "America/Lower_Princes": "AST4",
+ "America/Maceio": "UNK3",
+ "America/Managua": "CST6",
+ "America/Manaus": "UNK4",
+ "America/Marigot": "AST4",
+ "America/Martinique": "AST4",
+ "America/Matamoros": "CST6CDT,M3.2.0,M11.1.0",
+ "America/Mazatlan": "MST7MDT,M4.1.0,M10.5.0",
+ "America/Menominee": "CST6CDT,M3.2.0,M11.1.0",
+ "America/Merida": "CST6CDT,M4.1.0,M10.5.0",
+ "America/Metlakatla": "AKST9AKDT,M3.2.0,M11.1.0",
+ "America/Mexico_City": "CST6CDT,M4.1.0,M10.5.0",
+ "America/Miquelon": "UNK3UNK,M3.2.0,M11.1.0",
+ "America/Moncton": "AST4ADT,M3.2.0,M11.1.0",
+ "America/Monterrey": "CST6CDT,M4.1.0,M10.5.0",
+ "America/Montevideo": "UNK3",
+ "America/Montreal": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Montserrat": "AST4",
+ "America/Nassau": "EST5EDT,M3.2.0,M11.1.0",
+ "America/New_York": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Nipigon": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Nome": "AKST9AKDT,M3.2.0,M11.1.0",
+ "America/Noronha": "UNK2",
+ "America/North_Dakota/Beulah": "CST6CDT,M3.2.0,M11.1.0",
+ "America/North_Dakota/Center": "CST6CDT,M3.2.0,M11.1.0",
+ "America/North_Dakota/New_Salem": "CST6CDT,M3.2.0,M11.1.0",
+ "America/Ojinaga": "MST7MDT,M3.2.0,M11.1.0",
+ "America/Panama": "EST5",
+ "America/Pangnirtung": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Paramaribo": "UNK3",
+ "America/Phoenix": "MST7",
+ "America/Port-au-Prince": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Port_of_Spain": "AST4",
+ "America/Porto_Velho": "UNK4",
+ "America/Puerto_Rico": "AST4",
+ "America/Punta_Arenas": "UNK3",
+ "America/Rainy_River": "CST6CDT,M3.2.0,M11.1.0",
+ "America/Rankin_Inlet": "CST6CDT,M3.2.0,M11.1.0",
+ "America/Recife": "UNK3",
+ "America/Regina": "CST6",
+ "America/Resolute": "CST6CDT,M3.2.0,M11.1.0",
+ "America/Rio_Branco": "UNK5",
+ "America/Santarem": "UNK3",
+ "America/Santiago": "UNK4UNK,M9.1.6/24,M4.1.6/24",
+ "America/Santo_Domingo": "AST4",
+ "America/Sao_Paulo": "UNK3",
+ "America/Scoresbysund": "UNK1UNK,M3.5.0/0,M10.5.0/1",
+ "America/Sitka": "AKST9AKDT,M3.2.0,M11.1.0",
+ "America/St_Barthelemy": "AST4",
+ "America/St_Johns": "NST3:30NDT,M3.2.0,M11.1.0",
+ "America/St_Kitts": "AST4",
+ "America/St_Lucia": "AST4",
+ "America/St_Thomas": "AST4",
+ "America/St_Vincent": "AST4",
+ "America/Swift_Current": "CST6",
+ "America/Tegucigalpa": "CST6",
+ "America/Thule": "AST4ADT,M3.2.0,M11.1.0",
+ "America/Thunder_Bay": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Tijuana": "PST8PDT,M3.2.0,M11.1.0",
+ "America/Toronto": "EST5EDT,M3.2.0,M11.1.0",
+ "America/Tortola": "AST4",
+ "America/Vancouver": "PST8PDT,M3.2.0,M11.1.0",
+ "America/Whitehorse": "MST7",
+ "America/Winnipeg": "CST6CDT,M3.2.0,M11.1.0",
+ "America/Yakutat": "AKST9AKDT,M3.2.0,M11.1.0",
+ "America/Yellowknife": "MST7MDT,M3.2.0,M11.1.0",
+ "Antarctica/Casey": "UNK-8",
+ "Antarctica/Davis": "UNK-7",
+ "Antarctica/DumontDUrville": "UNK-10",
+ "Antarctica/Macquarie": "UNK-11",
+ "Antarctica/Mawson": "UNK-5",
+ "Antarctica/McMurdo": "NZST-12NZDT,M9.5.0,M4.1.0/3",
+ "Antarctica/Palmer": "UNK3",
+ "Antarctica/Rothera": "UNK3",
+ "Antarctica/Syowa": "UNK-3",
+ "Antarctica/Troll": "UNK0UNK-2,M3.5.0/1,M10.5.0/3",
+ "Antarctica/Vostok": "UNK-6",
+ "Arctic/Longyearbyen": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Asia/Aden": "UNK-3",
+ "Asia/Almaty": "UNK-6",
+ "Asia/Amman": "EET-2EEST,M3.5.4/24,M10.5.5/1",
+ "Asia/Anadyr": "UNK-12",
+ "Asia/Aqtau": "UNK-5",
+ "Asia/Aqtobe": "UNK-5",
+ "Asia/Ashgabat": "UNK-5",
+ "Asia/Atyrau": "UNK-5",
+ "Asia/Baghdad": "UNK-3",
+ "Asia/Bahrain": "UNK-3",
+ "Asia/Baku": "UNK-4",
+ "Asia/Bangkok": "UNK-7",
+ "Asia/Barnaul": "UNK-7",
+ "Asia/Beirut": "EET-2EEST,M3.5.0/0,M10.5.0/0",
+ "Asia/Bishkek": "UNK-6",
+ "Asia/Brunei": "UNK-8",
+ "Asia/Chita": "UNK-9",
+ "Asia/Choibalsan": "UNK-8",
+ "Asia/Colombo": "UNK-5:30",
+ "Asia/Damascus": "EET-2EEST,M3.5.5/0,M10.5.5/0",
+ "Asia/Dhaka": "UNK-6",
+ "Asia/Dili": "UNK-9",
+ "Asia/Dubai": "UNK-4",
+ "Asia/Dushanbe": "UNK-5",
+ "Asia/Famagusta": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Asia/Gaza": "EET-2EEST,M3.5.5/0,M10.5.6/1",
+ "Asia/Hebron": "EET-2EEST,M3.5.5/0,M10.5.6/1",
+ "Asia/Ho_Chi_Minh": "UNK-7",
+ "Asia/Hong_Kong": "HKT-8",
+ "Asia/Hovd": "UNK-7",
+ "Asia/Irkutsk": "UNK-8",
+ "Asia/Jakarta": "WIB-7",
+ "Asia/Jayapura": "WIT-9",
+ "Asia/Jerusalem": "IST-2IDT,M3.4.4/26,M10.5.0",
+ "Asia/Kabul": "UNK-4:30",
+ "Asia/Kamchatka": "UNK-12",
+ "Asia/Karachi": "PKT-5",
+ "Asia/Kathmandu": "UNK-5:45",
+ "Asia/Khandyga": "UNK-9",
+ "Asia/Kolkata": "IST-5:30",
+ "Asia/Krasnoyarsk": "UNK-7",
+ "Asia/Kuala_Lumpur": "UNK-8",
+ "Asia/Kuching": "UNK-8",
+ "Asia/Kuwait": "UNK-3",
+ "Asia/Macau": "CST-8",
+ "Asia/Magadan": "UNK-11",
+ "Asia/Makassar": "WITA-8",
+ "Asia/Manila": "PST-8",
+ "Asia/Muscat": "UNK-4",
+ "Asia/Nicosia": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Asia/Novokuznetsk": "UNK-7",
+ "Asia/Novosibirsk": "UNK-7",
+ "Asia/Omsk": "UNK-6",
+ "Asia/Oral": "UNK-5",
+ "Asia/Phnom_Penh": "UNK-7",
+ "Asia/Pontianak": "WIB-7",
+ "Asia/Pyongyang": "KST-9",
+ "Asia/Qatar": "UNK-3",
+ "Asia/Qyzylorda": "UNK-5",
+ "Asia/Riyadh": "UNK-3",
+ "Asia/Sakhalin": "UNK-11",
+ "Asia/Samarkand": "UNK-5",
+ "Asia/Seoul": "KST-9",
+ "Asia/Shanghai": "CST-8",
+ "Asia/Singapore": "UNK-8",
+ "Asia/Srednekolymsk": "UNK-11",
+ "Asia/Taipei": "CST-8",
+ "Asia/Tashkent": "UNK-5",
+ "Asia/Tbilisi": "UNK-4",
+ "Asia/Tehran": "UNK-3:30UNK,J79/24,J263/24",
+ "Asia/Thimphu": "UNK-6",
+ "Asia/Tokyo": "JST-9",
+ "Asia/Tomsk": "UNK-7",
+ "Asia/Ulaanbaatar": "UNK-8",
+ "Asia/Urumqi": "UNK-6",
+ "Asia/Ust-Nera": "UNK-10",
+ "Asia/Vientiane": "UNK-7",
+ "Asia/Vladivostok": "UNK-10",
+ "Asia/Yakutsk": "UNK-9",
+ "Asia/Yangon": "UNK-6:30",
+ "Asia/Yekaterinburg": "UNK-5",
+ "Asia/Yerevan": "UNK-4",
+ "Atlantic/Azores": "UNK1UNK,M3.5.0/0,M10.5.0/1",
+ "Atlantic/Bermuda": "AST4ADT,M3.2.0,M11.1.0",
+ "Atlantic/Canary": "WET0WEST,M3.5.0/1,M10.5.0",
+ "Atlantic/Cape_Verde": "UNK1",
+ "Atlantic/Faroe": "WET0WEST,M3.5.0/1,M10.5.0",
+ "Atlantic/Madeira": "WET0WEST,M3.5.0/1,M10.5.0",
+ "Atlantic/Reykjavik": "GMT0",
+ "Atlantic/South_Georgia": "UNK2",
+ "Atlantic/St_Helena": "GMT0",
+ "Atlantic/Stanley": "UNK3",
+ "Australia/Adelaide": "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
+ "Australia/Brisbane": "AEST-10",
+ "Australia/Broken_Hill": "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
+ "Australia/Currie": "AEST-10AEDT,M10.1.0,M4.1.0/3",
+ "Australia/Darwin": "ACST-9:30",
+ "Australia/Eucla": "UNK-8:45",
+ "Australia/Hobart": "AEST-10AEDT,M10.1.0,M4.1.0/3",
+ "Australia/Lindeman": "AEST-10",
+ "Australia/Lord_Howe": "UNK-10:30UNK-11,M10.1.0,M4.1.0",
+ "Australia/Melbourne": "AEST-10AEDT,M10.1.0,M4.1.0/3",
+ "Australia/Perth": "AWST-8",
+ "Australia/Sydney": "AEST-10AEDT,M10.1.0,M4.1.0/3",
+ "Etc/GMT": "GMT0",
+ "Etc/GMT+0": "GMT0",
+ "Etc/GMT+1": "UNK1",
+ "Etc/GMT+10": "UNK10",
+ "Etc/GMT+11": "UNK11",
+ "Etc/GMT+12": "UNK12",
+ "Etc/GMT+2": "UNK2",
+ "Etc/GMT+3": "UNK3",
+ "Etc/GMT+4": "UNK4",
+ "Etc/GMT+5": "UNK5",
+ "Etc/GMT+6": "UNK6",
+ "Etc/GMT+7": "UNK7",
+ "Etc/GMT+8": "UNK8",
+ "Etc/GMT+9": "UNK9",
+ "Etc/GMT-0": "GMT0",
+ "Etc/GMT-1": "UNK-1",
+ "Etc/GMT-10": "UNK-10",
+ "Etc/GMT-11": "UNK-11",
+ "Etc/GMT-12": "UNK-12",
+ "Etc/GMT-13": "UNK-13",
+ "Etc/GMT-14": "UNK-14",
+ "Etc/GMT-2": "UNK-2",
+ "Etc/GMT-3": "UNK-3",
+ "Etc/GMT-4": "UNK-4",
+ "Etc/GMT-5": "UNK-5",
+ "Etc/GMT-6": "UNK-6",
+ "Etc/GMT-7": "UNK-7",
+ "Etc/GMT-8": "UNK-8",
+ "Etc/GMT-9": "UNK-9",
+ "Etc/GMT0": "GMT0",
+ "Etc/Greenwich": "GMT0",
+ "Etc/UCT": "UTC0",
+ "Etc/UTC": "UTC0",
+ "Etc/Universal": "UTC0",
+ "Etc/Zulu": "UTC0",
+ "Europe/Amsterdam": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Andorra": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Astrakhan": "UNK-4",
+ "Europe/Athens": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Europe/Belgrade": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Berlin": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Bratislava": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Brussels": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Bucharest": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Europe/Budapest": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Busingen": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Chisinau": "EET-2EEST,M3.5.0,M10.5.0/3",
+ "Europe/Copenhagen": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Dublin": "IST-1GMT0,M10.5.0,M3.5.0/1",
+ "Europe/Gibraltar": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Guernsey": "GMT0BST,M3.5.0/1,M10.5.0",
+ "Europe/Helsinki": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Europe/Isle_of_Man": "GMT0BST,M3.5.0/1,M10.5.0",
+ "Europe/Istanbul": "UNK-3",
+ "Europe/Jersey": "GMT0BST,M3.5.0/1,M10.5.0",
+ "Europe/Kaliningrad": "EET-2",
+ "Europe/Kiev": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Europe/Kirov": "UNK-3",
+ "Europe/Lisbon": "WET0WEST,M3.5.0/1,M10.5.0",
+ "Europe/Ljubljana": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/London": "GMT0BST,M3.5.0/1,M10.5.0",
+ "Europe/Luxembourg": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Madrid": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Malta": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Mariehamn": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Europe/Minsk": "UNK-3",
+ "Europe/Monaco": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Moscow": "MSK-3",
+ "Europe/Oslo": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Paris": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Podgorica": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Prague": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Riga": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Europe/Rome": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Samara": "UNK-4",
+ "Europe/San_Marino": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Sarajevo": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Saratov": "UNK-4",
+ "Europe/Simferopol": "MSK-3",
+ "Europe/Skopje": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Sofia": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Europe/Stockholm": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Tallinn": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Europe/Tirane": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Ulyanovsk": "UNK-4",
+ "Europe/Uzhgorod": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Europe/Vaduz": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Vatican": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Vienna": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Vilnius": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Europe/Volgograd": "UNK-4",
+ "Europe/Warsaw": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Zagreb": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Europe/Zaporozhye": "EET-2EEST,M3.5.0/3,M10.5.0/4",
+ "Europe/Zurich": "CET-1CEST,M3.5.0,M10.5.0/3",
+ "Indian/Antananarivo": "EAT-3",
+ "Indian/Chagos": "UNK-6",
+ "Indian/Christmas": "UNK-7",
+ "Indian/Cocos": "UNK-6:30",
+ "Indian/Comoro": "EAT-3",
+ "Indian/Kerguelen": "UNK-5",
+ "Indian/Mahe": "UNK-4",
+ "Indian/Maldives": "UNK-5",
+ "Indian/Mauritius": "UNK-4",
+ "Indian/Mayotte": "EAT-3",
+ "Indian/Reunion": "UNK-4",
+ "Pacific/Apia": "UNK-13UNK,M9.5.0/3,M4.1.0/4",
+ "Pacific/Auckland": "NZST-12NZDT,M9.5.0,M4.1.0/3",
+ "Pacific/Bougainville": "UNK-11",
+ "Pacific/Chatham": "UNK-12:45UNK,M9.5.0/2:45,M4.1.0/3:45",
+ "Pacific/Chuuk": "UNK-10",
+ "Pacific/Easter": "UNK6UNK,M9.1.6/22,M4.1.6/22",
+ "Pacific/Efate": "UNK-11",
+ "Pacific/Enderbury": "UNK-13",
+ "Pacific/Fakaofo": "UNK-13",
+ "Pacific/Fiji": "UNK-12UNK,M11.2.0,M1.2.3/99",
+ "Pacific/Funafuti": "UNK-12",
+ "Pacific/Galapagos": "UNK6",
+ "Pacific/Gambier": "UNK9",
+ "Pacific/Guadalcanal": "UNK-11",
+ "Pacific/Guam": "ChST-10",
+ "Pacific/Honolulu": "HST10",
+ "Pacific/Kiritimati": "UNK-14",
+ "Pacific/Kosrae": "UNK-11",
+ "Pacific/Kwajalein": "UNK-12",
+ "Pacific/Majuro": "UNK-12",
+ "Pacific/Marquesas": "UNK9:30",
+ "Pacific/Midway": "SST11",
+ "Pacific/Nauru": "UNK-12",
+ "Pacific/Niue": "UNK11",
+ "Pacific/Norfolk": "UNK-11UNK,M10.1.0,M4.1.0/3",
+ "Pacific/Noumea": "UNK-11",
+ "Pacific/Pago_Pago": "SST11",
+ "Pacific/Palau": "UNK-9",
+ "Pacific/Pitcairn": "UNK8",
+ "Pacific/Pohnpei": "UNK-11",
+ "Pacific/Port_Moresby": "UNK-10",
+ "Pacific/Rarotonga": "UNK10",
+ "Pacific/Saipan": "ChST-10",
+ "Pacific/Tahiti": "UNK10",
+ "Pacific/Tarawa": "UNK-12",
+ "Pacific/Tongatapu": "UNK-13",
+ "Pacific/Wake": "UNK-12",
+ "Pacific/Wallis": "UNK-12"
+}
+
+export function selectedTimeZone(label: string, format: string) {
+ return TIME_ZONES[label] === format ? label : undefined;
+}
+
+export function timeZoneSelectItems() {
+ return Object.keys(TIME_ZONES).map(label => (
+
+ ));
+}
diff --git a/interface/src/ntp/TimeFormat.ts b/interface/src/ntp/TimeFormat.ts
new file mode 100644
index 0000000..7e0bb82
--- /dev/null
+++ b/interface/src/ntp/TimeFormat.ts
@@ -0,0 +1,5 @@
+import moment, { Moment } from 'moment';
+
+export const formatIsoDateTime = (isoDateString: string) => moment.parseZone(isoDateString).format('ll @ HH:mm:ss');
+
+export const formatLocalDateTime = (moment: Moment) => moment.format('YYYY-MM-DDTHH:mm');
diff --git a/interface/src/ntp/types.ts b/interface/src/ntp/types.ts
new file mode 100644
index 0000000..a266d12
--- /dev/null
+++ b/interface/src/ntp/types.ts
@@ -0,0 +1,23 @@
+export enum NTPSyncStatus {
+ NTP_INACTIVE = 0,
+ NTP_ACTIVE = 1
+}
+
+export interface NTPStatus {
+ status: NTPSyncStatus;
+ time_utc: string;
+ time_local: string;
+ server: string;
+ uptime: number;
+}
+
+export interface NTPSettings {
+ enabled: boolean;
+ server: string;
+ tz_label: string;
+ tz_format: string;
+}
+
+export interface Time {
+ time_utc: string;
+}
diff --git a/interface/src/project/GeneralInformation.tsx b/interface/src/project/GeneralInformation.tsx
new file mode 100644
index 0000000..ccd0735
--- /dev/null
+++ b/interface/src/project/GeneralInformation.tsx
@@ -0,0 +1,120 @@
+import React, {Component} from 'react';
+import {Box, List, ListItem, ListItemText} from '@material-ui/core';
+import {
+ FormButton,
+ restController,
+ RestControllerProps,
+ RestFormLoader,
+ SectionContent
+} from '../components';
+import {ENDPOINT_ROOT} from "../api";
+import {GeneralInformaitonState} from "./types";
+import RefreshIcon from "@material-ui/icons/Refresh";
+
+// define api endpoint
+export const GENERALINFORMATION_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "generalinfo";
+
+type GeneralInformationRestControllerProps = RestControllerProps;
+
+class GeneralInformation extends Component {
+ intervalhandler: number | undefined;
+
+ componentDidMount() {
+ this.props.loadData();
+
+ // this.intervalhandler = window.setInterval(() => {
+ // this.props.loadData();
+ // console.log("refreshing data");
+ // console.log(this.props.data)
+ // }, 10000);
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.intervalhandler);
+ }
+
+ render() {
+ return (
+
+ (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } variant="contained" color="secondary"
+ onClick={this.props.loadData}>
+ Refresh
+
+
+
+ Version: {props.data.version}
+
+
+ >
+ )}
+ />
+
+ );
+ }
+
+ /**
+ * stringify seconds to a pretty format
+ * @param sec number of seconds
+ */
+ stringifyTime(sec: number): string {
+ if (sec >= 86400) {
+ // display days
+ return (Math.trunc(sec / 86400) + "d " + Math.trunc((sec % 86400) / 3600) + "h " + Math.trunc((sec % 3600) / 60) + "min " + sec % 60 + "sec");
+ } else if (sec >= 3600) {
+ // display hours
+ return (Math.trunc(sec / 3600) + "h " + Math.trunc((sec % 3600) / 60) + "min " + sec % 60 + "sec");
+ } else if (sec >= 60) {
+ // only seconds and minutes
+ return (Math.trunc(sec / 60) + "min " + sec % 60 + "sec");
+ } else {
+ // only seconds
+ return (sec + "sec");
+ }
+ }
+}
+
+export default restController(GENERALINFORMATION_SETTINGS_ENDPOINT, GeneralInformation);
diff --git a/interface/src/project/ProjectMenu.tsx b/interface/src/project/ProjectMenu.tsx
new file mode 100644
index 0000000..452a7c5
--- /dev/null
+++ b/interface/src/project/ProjectMenu.tsx
@@ -0,0 +1,27 @@
+import React, { Component } from 'react';
+import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
+
+import {List, ListItem, ListItemIcon, ListItemText} from '@material-ui/core';
+import SettingsRemoteIcon from '@material-ui/icons/SettingsRemote';
+
+import { PROJECT_PATH } from '../api';
+
+class ProjectMenu extends Component {
+
+ render() {
+ const path = this.props.match.url;
+ return (
+
+
+
+
+
+
+
+
+ )
+ }
+
+}
+
+export default withRouter(ProjectMenu);
diff --git a/interface/src/project/ProjectRouting.tsx b/interface/src/project/ProjectRouting.tsx
new file mode 100644
index 0000000..e6cbe33
--- /dev/null
+++ b/interface/src/project/ProjectRouting.tsx
@@ -0,0 +1,33 @@
+import React, { Component } from 'react';
+import { Redirect, Switch } from 'react-router';
+
+import { PROJECT_PATH } from '../api';
+import { AuthenticatedRoute } from '../authentication';
+
+import PumpControl from './PumpControl';
+
+class ProjectRouting extends Component {
+
+ render() {
+ return (
+
+ {
+ /*
+ * Add your project page routing below.
+ */
+ }
+
+ {
+ /*
+ * The redirect below caters for the default project route and redirecting invalid paths.
+ * The "to" property must match one of the routes above for this to work correctly.
+ */
+ }
+
+
+ )
+ }
+
+}
+
+export default ProjectRouting;
diff --git a/interface/src/project/PumpControl.tsx b/interface/src/project/PumpControl.tsx
new file mode 100644
index 0000000..204e812
--- /dev/null
+++ b/interface/src/project/PumpControl.tsx
@@ -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 { PROJECT_PATH } from '../api';
+import { MenuAppBar } from '../components';
+import { AuthenticatedRoute } from '../authentication';
+
+import GeneralInformation from './GeneralInformation';
+import SettingsController from "./SettingsController";
+
+class PumpControl extends Component {
+
+ handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
+ this.props.history.push(path);
+ };
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+}
+
+export default PumpControl;
diff --git a/interface/src/project/SettingsController.tsx b/interface/src/project/SettingsController.tsx
new file mode 100644
index 0000000..5508bb6
--- /dev/null
+++ b/interface/src/project/SettingsController.tsx
@@ -0,0 +1,84 @@
+import React, {Component} from 'react';
+import {ValidatorForm} from 'react-material-ui-form-validator';
+
+import {Typography, Box, TextField} from '@material-ui/core';
+import SaveIcon from '@material-ui/icons/Save';
+
+import {ENDPOINT_ROOT} from '../api';
+import {
+ restController,
+ RestControllerProps,
+ RestFormLoader,
+ FormActions,
+ FormButton,
+ SectionContent,
+} from '../components';
+
+import {SettingsState} from './types';
+
+export const LIGHT_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "settings";
+
+type LightStateRestControllerProps = RestControllerProps;
+
+class SettingsController extends Component {
+
+ componentDidMount() {
+ this.props.loadData();
+ }
+
+ render() {
+ return (
+
+ {
+ const {data, saveData, handleValueChange} = props;
+ return (
+
+
+
+ Die unten eingegebenen Werte werden nach klick des 'SAVE' Buttons übernommen und überleben einen Neustart.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } variant="contained" color="primary"
+ type="submit">Save
+
+
+
+ )
+ }}
+ />
+
+ )
+ }
+
+}
+
+export default restController(LIGHT_SETTINGS_ENDPOINT, SettingsController);
diff --git a/interface/src/project/types.ts b/interface/src/project/types.ts
new file mode 100644
index 0000000..460dd62
--- /dev/null
+++ b/interface/src/project/types.ts
@@ -0,0 +1,19 @@
+export interface SettingsState {
+ maxpumpduration: number;
+ waterOutageWaitDuration: number;
+ heatUp: number;
+ heatLow: number;
+ fanRuntime: number;
+}
+
+export interface GeneralInformaitonState {
+ hum: number;
+ temp: number;
+ lastpumptime: number;
+ lastWaterOutage: number;
+ lastPumpDuration: number;
+ runtime: number;
+ watersensor: boolean;
+ pressuresensor: boolean;
+ version: string;
+}
diff --git a/interface/src/react-app-env.d.ts b/interface/src/react-app-env.d.ts
new file mode 100644
index 0000000..6431bc5
--- /dev/null
+++ b/interface/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/interface/src/security/ManageUsersController.tsx b/interface/src/security/ManageUsersController.tsx
new file mode 100644
index 0000000..ccd3cde
--- /dev/null
+++ b/interface/src/security/ManageUsersController.tsx
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+
+import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
+import { SECURITY_SETTINGS_ENDPOINT } from '../api';
+
+import ManageUsersForm from './ManageUsersForm';
+import { SecuritySettings } from './types';
+
+type ManageUsersControllerProps = RestControllerProps;
+
+class ManageUsersController extends Component {
+
+ componentDidMount() {
+ this.props.loadData();
+ }
+
+ render() {
+ return (
+
+ }
+ />
+
+ )
+ }
+
+}
+
+export default restController(SECURITY_SETTINGS_ENDPOINT, ManageUsersController);
diff --git a/interface/src/security/ManageUsersForm.tsx b/interface/src/security/ManageUsersForm.tsx
new file mode 100644
index 0000000..b8c63b7
--- /dev/null
+++ b/interface/src/security/ManageUsersForm.tsx
@@ -0,0 +1,184 @@
+import React, { Fragment } from 'react';
+import { ValidatorForm } from 'react-material-ui-form-validator';
+
+import { Table, TableBody, TableCell, TableHead, TableFooter, TableRow, withWidth, WithWidthProps, isWidthDown } from '@material-ui/core';
+import { Box, Button, Typography, } from '@material-ui/core';
+
+import EditIcon from '@material-ui/icons/Edit';
+import DeleteIcon from '@material-ui/icons/Delete';
+import CloseIcon from '@material-ui/icons/Close';
+import CheckIcon from '@material-ui/icons/Check';
+import IconButton from '@material-ui/core/IconButton';
+import SaveIcon from '@material-ui/icons/Save';
+import PersonAddIcon from '@material-ui/icons/PersonAdd';
+
+import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication';
+import { RestFormProps, FormActions, FormButton, extractEventValue } from '../components';
+
+import UserForm from './UserForm';
+import { SecuritySettings, User } from './types';
+
+function compareUsers(a: User, b: User) {
+ if (a.username < b.username) {
+ return -1;
+ }
+ if (a.username > b.username) {
+ return 1;
+ }
+ return 0;
+}
+
+type ManageUsersFormProps = RestFormProps & AuthenticatedContextProps & WithWidthProps;
+
+type ManageUsersFormState = {
+ creating: boolean;
+ user?: User;
+}
+
+class ManageUsersForm extends React.Component {
+
+ state: ManageUsersFormState = {
+ creating: false
+ };
+
+ createUser = () => {
+ this.setState({
+ creating: true,
+ user: {
+ username: "",
+ password: "",
+ admin: true
+ }
+ });
+ };
+
+ uniqueUsername = (username: string) => {
+ return !this.props.data.users.find(u => u.username === username);
+ }
+
+ noAdminConfigured = () => {
+ return !this.props.data.users.find(u => u.admin);
+ }
+
+ removeUser = (user: User) => {
+ const { data } = this.props;
+ const users = data.users.filter(u => u.username !== user.username);
+ this.props.setData({ ...data, users });
+ }
+
+ startEditingUser = (user: User) => {
+ this.setState({
+ creating: false,
+ user
+ });
+ };
+
+ cancelEditingUser = () => {
+ this.setState({
+ user: undefined
+ });
+ }
+
+ doneEditingUser = () => {
+ const { user } = this.state;
+ if (user) {
+ const { data } = this.props;
+ const users = data.users.filter(u => u.username !== user.username);
+ users.push(user);
+ this.props.setData({ ...data, users });
+ this.setState({
+ user: undefined
+ });
+ }
+ };
+
+ handleUserValueChange = (name: keyof User) => (event: React.ChangeEvent) => {
+ this.setState({ user: { ...this.state.user!, [name]: extractEventValue(event) } });
+ };
+
+ onSubmit = () => {
+ this.props.saveData();
+ this.props.authenticatedContext.refresh();
+ }
+
+ render() {
+ const { width, data } = this.props;
+ const { user, creating } = this.state;
+ return (
+
+
+
+
+
+ Username
+ Admin?
+
+
+
+
+ {data.users.sort(compareUsers).map(user => (
+
+
+ {user.username}
+
+
+ {
+ user.admin ? :
+ }
+
+
+ this.removeUser(user)}>
+
+
+ this.startEditingUser(user)}>
+
+
+
+
+ ))}
+
+
+
+
+
+ } variant="contained" color="secondary" onClick={this.createUser}>
+ Add
+
+
+
+
+
+ {
+ this.noAdminConfigured() &&
+ (
+
+
+ You must have at least one admin user configured.
+
+
+ )
+ }
+
+ } variant="contained" color="primary" type="submit" disabled={this.noAdminConfigured()}>
+ Save
+
+
+
+ {
+ user &&
+
+ }
+
+ );
+ }
+
+}
+
+export default withAuthenticatedContext(withWidth()(ManageUsersForm));
diff --git a/interface/src/security/Security.tsx b/interface/src/security/Security.tsx
new file mode 100644
index 0000000..4e99769
--- /dev/null
+++ b/interface/src/security/Security.tsx
@@ -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, AuthenticatedRoute } from '../authentication';
+import { MenuAppBar } from '../components';
+
+import ManageUsersController from './ManageUsersController';
+import SecuritySettingsController from './SecuritySettingsController';
+
+type SecurityProps = AuthenticatedContextProps & RouteComponentProps;
+
+class Security extends Component {
+
+ handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
+ this.props.history.push(path);
+ };
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+}
+
+export default Security;
diff --git a/interface/src/security/SecuritySettingsController.tsx b/interface/src/security/SecuritySettingsController.tsx
new file mode 100644
index 0000000..d42328a
--- /dev/null
+++ b/interface/src/security/SecuritySettingsController.tsx
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+
+import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
+import { SECURITY_SETTINGS_ENDPOINT } from '../api';
+
+import SecuritySettingsForm from './SecuritySettingsForm';
+import { SecuritySettings } from './types';
+
+type SecuritySettingsControllerProps = RestControllerProps;
+
+class SecuritySettingsController extends Component {
+
+ componentDidMount() {
+ this.props.loadData();
+ }
+
+ render() {
+ return (
+
+ }
+ />
+
+ );
+ }
+
+}
+
+export default restController(SECURITY_SETTINGS_ENDPOINT, SecuritySettingsController);
diff --git a/interface/src/security/SecuritySettingsForm.tsx b/interface/src/security/SecuritySettingsForm.tsx
new file mode 100644
index 0000000..6407f5f
--- /dev/null
+++ b/interface/src/security/SecuritySettingsForm.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { ValidatorForm } from 'react-material-ui-form-validator';
+
+import { Box, Typography } from '@material-ui/core';
+import SaveIcon from '@material-ui/icons/Save';
+
+import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication';
+import { RestFormProps, PasswordValidator, FormActions, FormButton } from '../components';
+
+import { SecuritySettings } from './types';
+
+type SecuritySettingsFormProps = RestFormProps & AuthenticatedContextProps;
+
+class SecuritySettingsForm extends React.Component {
+
+ onSubmit = () => {
+ this.props.saveData();
+ this.props.authenticatedContext.refresh();
+ }
+
+ render() {
+ const { data, handleValueChange } = this.props;
+ return (
+
+
+
+
+ The JWT secret is used to sign authentication tokens. If you modify the JWT Secret, all users will be signed out.
+
+
+
+ } variant="contained" color="primary" type="submit">
+ Save
+
+
+
+ );
+ }
+
+}
+
+export default withAuthenticatedContext(SecuritySettingsForm);
diff --git a/interface/src/security/UserForm.tsx b/interface/src/security/UserForm.tsx
new file mode 100644
index 0000000..8eefa7d
--- /dev/null
+++ b/interface/src/security/UserForm.tsx
@@ -0,0 +1,86 @@
+import React, { RefObject } from 'react';
+import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
+
+import { Dialog, DialogTitle, DialogContent, DialogActions, Checkbox } from '@material-ui/core';
+
+import { PasswordValidator, BlockFormControlLabel, FormButton } from '../components';
+
+import { User } from './types';
+
+interface UserFormProps {
+ creating: boolean;
+ user: User;
+ uniqueUsername: (value: any) => boolean;
+ handleValueChange: (name: keyof User) => (event: React.ChangeEvent) => void;
+ onDoneEditing: () => void;
+ onCancelEditing: () => void;
+}
+
+class UserForm extends React.Component {
+
+ formRef: RefObject = React.createRef();
+
+ componentDidMount() {
+ ValidatorForm.addValidationRule('uniqueUsername', this.props.uniqueUsername);
+ }
+
+ submit = () => {
+ this.formRef.current.submit();
+ }
+
+ render() {
+ const { user, creating, handleValueChange, onDoneEditing, onCancelEditing } = this.props;
+ return (
+
+
+
+ );
+ }
+}
+
+export default UserForm;
diff --git a/interface/src/security/types.ts b/interface/src/security/types.ts
new file mode 100644
index 0000000..99d54de
--- /dev/null
+++ b/interface/src/security/types.ts
@@ -0,0 +1,11 @@
+export interface User {
+ username: string;
+ password: string;
+ admin: boolean;
+}
+
+export interface SecuritySettings {
+ users: User[];
+ jwt_secret: string;
+}
+
diff --git a/interface/src/serviceWorker.ts b/interface/src/serviceWorker.ts
new file mode 100644
index 0000000..d5f0275
--- /dev/null
+++ b/interface/src/serviceWorker.ts
@@ -0,0 +1,145 @@
+// This optional code is used to register a service worker.
+// register() is not called by default.
+
+// This lets the app load faster on subsequent visits in production, and gives
+// it offline capabilities. However, it also means that developers (and users)
+// will only see deployed updates on subsequent visits to a page, after all the
+// existing tabs open on the page have been closed, since previously cached
+// resources are updated in the background.
+
+// To learn more about the benefits of this model and instructions on how to
+// opt-in, read https://bit.ly/CRA-PWA
+
+const isLocalhost = Boolean(
+ window.location.hostname === 'localhost' ||
+ // [::1] is the IPv6 localhost address.
+ window.location.hostname === '[::1]' ||
+ // 127.0.0.0/8 are considered localhost for IPv4.
+ window.location.hostname.match(
+ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
+ )
+);
+
+type Config = {
+ onSuccess?: (registration: ServiceWorkerRegistration) => void;
+ onUpdate?: (registration: ServiceWorkerRegistration) => void;
+};
+
+export function register(config?: Config) {
+ if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
+ // The URL constructor is available in all browsers that support SW.
+ const publicUrl = new URL(
+ process.env.PUBLIC_URL,
+ window.location.href
+ );
+ if (publicUrl.origin !== window.location.origin) {
+ // Our service worker won't work if PUBLIC_URL is on a different origin
+ // from what our page is served on. This might happen if a CDN is used to
+ // serve assets; see https://github.com/facebook/create-react-app/issues/2374
+ return;
+ }
+
+ window.addEventListener('load', () => {
+ const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
+
+ if (isLocalhost) {
+ // This is running on localhost. Let's check if a service worker still exists or not.
+ checkValidServiceWorker(swUrl, config);
+
+ // Add some additional logging to localhost, pointing developers to the
+ // service worker/PWA documentation.
+ navigator.serviceWorker.ready.then(() => {
+ console.log(
+ 'This web app is being served cache-first by a service ' +
+ 'worker. To learn more, visit https://bit.ly/CRA-PWA'
+ );
+ });
+ } else {
+ // Is not localhost. Just register service worker
+ registerValidSW(swUrl, config);
+ }
+ });
+ }
+}
+
+function registerValidSW(swUrl: string, config?: Config) {
+ navigator.serviceWorker
+ .register(swUrl)
+ .then(registration => {
+ registration.onupdatefound = () => {
+ const installingWorker = registration.installing;
+ if (installingWorker == null) {
+ return;
+ }
+ installingWorker.onstatechange = () => {
+ if (installingWorker.state === 'installed') {
+ if (navigator.serviceWorker.controller) {
+ // At this point, the updated precached content has been fetched,
+ // but the previous service worker will still serve the older
+ // content until all client tabs are closed.
+ console.log(
+ 'New content is available and will be used when all ' +
+ 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
+ );
+
+ // Execute callback
+ if (config && config.onUpdate) {
+ config.onUpdate(registration);
+ }
+ } else {
+ // At this point, everything has been precached.
+ // It's the perfect time to display a
+ // "Content is cached for offline use." message.
+ console.log('Content is cached for offline use.');
+
+ // Execute callback
+ if (config && config.onSuccess) {
+ config.onSuccess(registration);
+ }
+ }
+ }
+ };
+ };
+ })
+ .catch(error => {
+ console.error('Error during service worker registration:', error);
+ });
+}
+
+function checkValidServiceWorker(swUrl: string, config?: Config) {
+ // Check if the service worker can be found. If it can't reload the page.
+ fetch(swUrl, {
+ headers: { 'Service-Worker': 'script' }
+ })
+ .then(response => {
+ // Ensure service worker exists, and that we really are getting a JS file.
+ const contentType = response.headers.get('content-type');
+ if (
+ response.status === 404 ||
+ (contentType != null && contentType.indexOf('javascript') === -1)
+ ) {
+ // No service worker found. Probably a different app. Reload the page.
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister().then(() => {
+ window.location.reload();
+ });
+ });
+ } else {
+ // Service worker found. Proceed as normal.
+ registerValidSW(swUrl, config);
+ }
+ })
+ .catch(() => {
+ console.log(
+ 'No internet connection found. App is running in offline mode.'
+ );
+ });
+}
+
+export function unregister() {
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister();
+ });
+ }
+}
diff --git a/interface/src/system/OTASettingsController.tsx b/interface/src/system/OTASettingsController.tsx
new file mode 100644
index 0000000..2f683b3
--- /dev/null
+++ b/interface/src/system/OTASettingsController.tsx
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+
+import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
+import { OTA_SETTINGS_ENDPOINT } from '../api';
+
+import OTASettingsForm from './OTASettingsForm';
+import { OTASettings } from './types';
+
+type OTASettingsControllerProps = RestControllerProps;
+
+class OTASettingsController extends Component {
+
+ componentDidMount() {
+ this.props.loadData();
+ }
+
+ render() {
+ return (
+
+ }
+ />
+
+ );
+ }
+
+}
+
+export default restController(OTA_SETTINGS_ENDPOINT, OTASettingsController);
diff --git a/interface/src/system/OTASettingsForm.tsx b/interface/src/system/OTASettingsForm.tsx
new file mode 100644
index 0000000..c518995
--- /dev/null
+++ b/interface/src/system/OTASettingsForm.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
+
+import { Checkbox } from '@material-ui/core';
+import SaveIcon from '@material-ui/icons/Save';
+
+import { RestFormProps, BlockFormControlLabel, PasswordValidator, FormButton, FormActions } from '../components';
+import {isIP,isHostname,or} from '../validators';
+
+import { OTASettings } from './types';
+
+type OTASettingsFormProps = RestFormProps;
+
+class OTASettingsForm extends React.Component {
+
+ componentDidMount() {
+ ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
+ }
+
+ render() {
+ const { data, handleValueChange, saveData } = this.props;
+ return (
+
+
+ }
+ label="Enable OTA Updates?"
+ />
+
+
+
+ } variant="contained" color="primary" type="submit">
+ Save
+
+
+
+ );
+ }
+}
+
+export default OTASettingsForm;
diff --git a/interface/src/system/System.tsx b/interface/src/system/System.tsx
new file mode 100644
index 0000000..671d5e0
--- /dev/null
+++ b/interface/src/system/System.tsx
@@ -0,0 +1,51 @@
+import React, { Component } from 'react';
+import { Redirect, Switch, RouteComponentProps } from 'react-router-dom'
+
+import { Tabs, Tab } from '@material-ui/core';
+
+import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext';
+
+import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication';
+import { MenuAppBar } from '../components';
+
+import SystemStatusController from './SystemStatusController';
+import OTASettingsController from './OTASettingsController';
+import UploadFirmwareController from './UploadFirmwareController';
+
+type SystemProps = AuthenticatedContextProps & RouteComponentProps & WithFeaturesProps;
+
+class System extends Component {
+
+ handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
+ this.props.history.push(path);
+ };
+
+ render() {
+ const { authenticatedContext, features } = this.props;
+ return (
+
+
+
+ {features.ota && (
+
+ )}
+ {features.upload_firmware && (
+
+ )}
+
+
+
+ {features.ota && (
+
+ )}
+ {features.upload_firmware && (
+
+ )}
+
+
+
+ )
+ }
+}
+
+export default withFeatures(withAuthenticatedContext(System));
diff --git a/interface/src/system/SystemStatusController.tsx b/interface/src/system/SystemStatusController.tsx
new file mode 100644
index 0000000..abee0b5
--- /dev/null
+++ b/interface/src/system/SystemStatusController.tsx
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+
+import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
+import { SYSTEM_STATUS_ENDPOINT } from '../api';
+
+import SystemStatusForm from './SystemStatusForm';
+import { SystemStatus } from './types';
+
+type SystemStatusControllerProps = RestControllerProps;
+
+class SystemStatusController extends Component {
+
+ componentDidMount() {
+ this.props.loadData();
+ }
+
+ render() {
+ return (
+
+ }
+ />
+
+ );
+ }
+
+}
+
+export default restController(SYSTEM_STATUS_ENDPOINT, SystemStatusController);
diff --git a/interface/src/system/SystemStatusForm.tsx b/interface/src/system/SystemStatusForm.tsx
new file mode 100644
index 0000000..751946e
--- /dev/null
+++ b/interface/src/system/SystemStatusForm.tsx
@@ -0,0 +1,245 @@
+import React, { Component, Fragment } from 'react';
+
+import { Avatar, Button, Divider, Dialog, DialogTitle, DialogContent, DialogActions, Box } from '@material-ui/core';
+import { List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core';
+
+import DevicesIcon from '@material-ui/icons/Devices';
+import MemoryIcon from '@material-ui/icons/Memory';
+import ShowChartIcon from '@material-ui/icons/ShowChart';
+import SdStorageIcon from '@material-ui/icons/SdStorage';
+import FolderIcon from '@material-ui/icons/Folder';
+import DataUsageIcon from '@material-ui/icons/DataUsage';
+import AppsIcon from '@material-ui/icons/Apps';
+import PowerSettingsNewIcon from '@material-ui/icons/PowerSettingsNew';
+import RefreshIcon from '@material-ui/icons/Refresh';
+import SettingsBackupRestoreIcon from '@material-ui/icons/SettingsBackupRestore';
+
+import { redirectingAuthorizedFetch, AuthenticatedContextProps, withAuthenticatedContext } from '../authentication';
+import { RestFormProps, FormButton, ErrorButton } from '../components';
+import { FACTORY_RESET_ENDPOINT, RESTART_ENDPOINT } from '../api';
+
+import { SystemStatus, EspPlatform } from './types';
+
+interface SystemStatusFormState {
+ confirmRestart: boolean;
+ confirmFactoryReset: boolean;
+ processing: boolean;
+}
+
+type SystemStatusFormProps = AuthenticatedContextProps & RestFormProps;
+
+function formatNumber(num: number) {
+ return new Intl.NumberFormat().format(num);
+}
+
+class SystemStatusForm extends Component {
+
+ state: SystemStatusFormState = {
+ confirmRestart: false,
+ confirmFactoryReset: false,
+ processing: false
+ }
+
+ createListItems() {
+ const { data } = this.props
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ (data.esp_platform === EspPlatform.ESP32 && data.psram_size > 0) && (
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ renderRestartDialog() {
+ return (
+
+ )
+ }
+
+ onRestart = () => {
+ this.setState({ confirmRestart: true });
+ }
+
+ onRestartRejected = () => {
+ this.setState({ confirmRestart: false });
+ }
+
+ onRestartConfirmed = () => {
+ this.setState({ processing: true });
+ redirectingAuthorizedFetch(RESTART_ENDPOINT, { method: 'POST' })
+ .then(response => {
+ if (response.status === 200) {
+ this.props.enqueueSnackbar("Device is restarting", { variant: 'info' });
+ this.setState({ processing: false, confirmRestart: false });
+ } else {
+ throw Error("Invalid status code: " + response.status);
+ }
+ })
+ .catch(error => {
+ this.props.enqueueSnackbar(error.message || "Problem restarting device", { variant: 'error' });
+ this.setState({ processing: false, confirmRestart: false });
+ });
+ }
+
+ renderFactoryResetDialog() {
+ return (
+
+ )
+ }
+
+ onFactoryReset = () => {
+ this.setState({ confirmFactoryReset: true });
+ }
+
+ onFactoryResetRejected = () => {
+ this.setState({ confirmFactoryReset: false });
+ }
+
+ onFactoryResetConfirmed = () => {
+ this.setState({ processing: true });
+ redirectingAuthorizedFetch(FACTORY_RESET_ENDPOINT, { method: 'POST' })
+ .then(response => {
+ if (response.status === 200) {
+ this.props.enqueueSnackbar("Factory reset in progress.", { variant: 'error' });
+ this.setState({ processing: false, confirmFactoryReset: false });
+ } else {
+ throw Error("Invalid status code: " + response.status);
+ }
+ })
+ .catch(error => {
+ this.props.enqueueSnackbar(error.message || "Problem factory resetting device", { variant: 'error' });
+ this.setState({ processing: false, confirmRestart: false });
+ });
+ }
+
+ render() {
+ const me = this.props.authenticatedContext.me;
+ return (
+
+
+ {this.createListItems()}
+
+
+
+ } variant="contained" color="secondary" onClick={this.props.loadData}>
+ Refresh
+
+
+ {me.admin &&
+
+ } variant="contained" color="primary" onClick={this.onRestart}>
+ Restart
+
+ } variant="contained" onClick={this.onFactoryReset}>
+ Factory reset
+
+
+ }
+
+ {this.renderRestartDialog()}
+ {this.renderFactoryResetDialog()}
+
+ );
+ }
+
+}
+
+export default withAuthenticatedContext(SystemStatusForm);
diff --git a/interface/src/system/UploadFirmwareController.tsx b/interface/src/system/UploadFirmwareController.tsx
new file mode 100644
index 0000000..16d21ff
--- /dev/null
+++ b/interface/src/system/UploadFirmwareController.tsx
@@ -0,0 +1,71 @@
+import React, { Component } from 'react';
+
+import { SectionContent } from '../components';
+import { UPLOAD_FIRMWARE_ENDPOINT } from '../api';
+
+import UploadFirmwareForm from './UploadFirmwareForm';
+import { redirectingAuthorizedUpload } from '../authentication';
+import { withSnackbar, WithSnackbarProps } from 'notistack';
+
+interface UploadFirmwareControllerState {
+ xhr?: XMLHttpRequest;
+ progress?: ProgressEvent;
+}
+
+class UploadFirmwareController extends Component {
+
+ state: UploadFirmwareControllerState = {
+ xhr: undefined,
+ progress: undefined
+ };
+
+ componentWillUnmount() {
+ this.state.xhr?.abort();
+ }
+
+ updateProgress = (progress: ProgressEvent) => {
+ this.setState({ progress });
+ }
+
+ uploadFile = (file: File) => {
+ if (this.state.xhr) {
+ return;
+ }
+ var xhr = new XMLHttpRequest();
+ this.setState({ xhr });
+ redirectingAuthorizedUpload(xhr, UPLOAD_FIRMWARE_ENDPOINT, file, this.updateProgress).then(() => {
+ if (xhr.status !== 200) {
+ throw Error("Invalid status code: " + xhr.status);
+ }
+ this.props.enqueueSnackbar("Activating new firmware", { variant: 'success' });
+ this.setState({ xhr: undefined, progress: undefined });
+ }).catch((error: Error) => {
+ if (error.name === 'AbortError') {
+ this.props.enqueueSnackbar("Upload cancelled by user", { variant: 'warning' });
+ } else {
+ const errorMessage = error.name === 'UploadError' ? "Error during upload" : (error.message || "Unknown error");
+ this.props.enqueueSnackbar("Problem uploading: " + errorMessage, { variant: 'error' });
+ this.setState({ xhr: undefined, progress: undefined });
+ }
+ });
+ }
+
+ cancelUpload = () => {
+ if (this.state.xhr) {
+ this.state.xhr.abort();
+ this.setState({ xhr: undefined, progress: undefined });
+ }
+ }
+
+ render() {
+ const { xhr, progress } = this.state;
+ return (
+
+
+
+ );
+ }
+
+}
+
+export default withSnackbar(UploadFirmwareController);
diff --git a/interface/src/system/UploadFirmwareForm.tsx b/interface/src/system/UploadFirmwareForm.tsx
new file mode 100644
index 0000000..2c40be3
--- /dev/null
+++ b/interface/src/system/UploadFirmwareForm.tsx
@@ -0,0 +1,35 @@
+import React, { Fragment } from 'react';
+import { SingleUpload } from '../components';
+import { Box } from '@material-ui/core';
+
+interface UploadFirmwareFormProps {
+ uploading: boolean;
+ progress?: ProgressEvent;
+ onFileSelected: (file: File) => void;
+ onCancel: () => void;
+}
+
+class UploadFirmwareForm extends React.Component {
+
+ handleDrop = (files: File[]) => {
+ const file = files[0];
+ if (file) {
+ this.props.onFileSelected(files[0]);
+ }
+ };
+
+ render() {
+ const { uploading, progress, onCancel } = this.props;
+ return (
+
+
+ Upload a new firmware (.bin) file below to replace the existing firmware.
+
+
+
+ );
+ }
+
+}
+
+export default UploadFirmwareForm;
diff --git a/interface/src/system/types.ts b/interface/src/system/types.ts
new file mode 100644
index 0000000..67b15e2
--- /dev/null
+++ b/interface/src/system/types.ts
@@ -0,0 +1,37 @@
+export enum EspPlatform {
+ ESP8266 = "esp8266",
+ ESP32 = "esp32"
+}
+
+interface ESPSystemStatus {
+ esp_platform: EspPlatform;
+ max_alloc_heap: number;
+ cpu_freq_mhz: number;
+ free_heap: number;
+ sketch_size: number;
+ free_sketch_space: number;
+ sdk_version: string;
+ flash_chip_size: number;
+ flash_chip_speed: number;
+ fs_used: number;
+ fs_total: number;
+}
+
+export interface ESP32SystemStatus extends ESPSystemStatus {
+ esp_platform: EspPlatform.ESP32;
+ psram_size: number;
+ free_psram: number;
+}
+
+export interface ESP8266SystemStatus extends ESPSystemStatus {
+ esp_platform: EspPlatform.ESP8266;
+ heap_fragmentation: number;
+}
+
+export type SystemStatus = ESP8266SystemStatus | ESP32SystemStatus;
+
+export interface OTASettings {
+ enabled: boolean;
+ port: number;
+ password: string;
+}
diff --git a/interface/src/validators/index.ts b/interface/src/validators/index.ts
new file mode 100644
index 0000000..82aafa8
--- /dev/null
+++ b/interface/src/validators/index.ts
@@ -0,0 +1,4 @@
+export { default as isHostname } from './isHostname';
+export { default as isIP } from './isIP';
+export { default as optional } from './optional';
+export { default as or } from './or';
diff --git a/interface/src/validators/isHostname.ts b/interface/src/validators/isHostname.ts
new file mode 100644
index 0000000..7c1ca25
--- /dev/null
+++ b/interface/src/validators/isHostname.ts
@@ -0,0 +1,6 @@
+const hostnameLengthRegex = /^.{0,32}$/
+const hostnamePatternRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/
+
+export default function isHostname(hostname: string) {
+ return hostnameLengthRegex.test(hostname) && hostnamePatternRegex.test(hostname);
+}
diff --git a/interface/src/validators/isIP.ts b/interface/src/validators/isIP.ts
new file mode 100644
index 0000000..439c57c
--- /dev/null
+++ b/interface/src/validators/isIP.ts
@@ -0,0 +1,5 @@
+const ipAddressRegexp = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
+
+export default function isIp(ipAddress: string) {
+ return ipAddressRegexp.test(ipAddress);
+}
\ No newline at end of file
diff --git a/interface/src/validators/optional.ts b/interface/src/validators/optional.ts
new file mode 100644
index 0000000..841c57b
--- /dev/null
+++ b/interface/src/validators/optional.ts
@@ -0,0 +1 @@
+export default (validator: (value: any) => boolean) => (value: any) => !value || validator(value);
diff --git a/interface/src/validators/or.ts b/interface/src/validators/or.ts
new file mode 100644
index 0000000..047ecf0
--- /dev/null
+++ b/interface/src/validators/or.ts
@@ -0,0 +1,3 @@
+export default (validator1: (value: any) => boolean, validator2: (value: any) => boolean) => {
+ return (value: any) => validator1(value) || validator2(value);
+}
diff --git a/interface/src/wifi/WiFiConnection.tsx b/interface/src/wifi/WiFiConnection.tsx
new file mode 100644
index 0000000..c6cef06
--- /dev/null
+++ b/interface/src/wifi/WiFiConnection.tsx
@@ -0,0 +1,62 @@
+import React, { Component } from 'react';
+import { Redirect, Switch, RouteComponentProps } from 'react-router-dom'
+
+import { Tabs, Tab } from '@material-ui/core';
+
+import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication';
+import { MenuAppBar } from '../components';
+
+import WiFiStatusController from './WiFiStatusController';
+import WiFiSettingsController from './WiFiSettingsController';
+import WiFiNetworkScanner from './WiFiNetworkScanner';
+import { WiFiConnectionContext } from './WiFiConnectionContext';
+import { WiFiNetwork } from './types';
+
+type WiFiConnectionProps = AuthenticatedContextProps & RouteComponentProps;
+
+class WiFiConnection extends Component {
+
+ constructor(props: WiFiConnectionProps) {
+ super(props);
+ this.state = {
+ selectNetwork: this.selectNetwork,
+ deselectNetwork: this.deselectNetwork
+ };
+ }
+
+ selectNetwork = (network: WiFiNetwork) => {
+ this.setState({ selectedNetwork: network });
+ this.props.history.push('/wifi/settings');
+ }
+
+ deselectNetwork = () => {
+ this.setState({ selectedNetwork: undefined });
+ }
+
+ handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
+ this.props.history.push(path);
+ };
+
+ render() {
+ const { authenticatedContext } = this.props;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+}
+
+export default withAuthenticatedContext(WiFiConnection);
diff --git a/interface/src/wifi/WiFiConnectionContext.tsx b/interface/src/wifi/WiFiConnectionContext.tsx
new file mode 100644
index 0000000..85b0c17
--- /dev/null
+++ b/interface/src/wifi/WiFiConnectionContext.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { WiFiNetwork } from './types';
+
+export interface WiFiConnectionContext {
+ selectedNetwork?: WiFiNetwork;
+ selectNetwork: (network: WiFiNetwork) => void;
+ deselectNetwork: () => void;
+}
+
+const WiFiConnectionContextDefaultValue = {} as WiFiConnectionContext
+export const WiFiConnectionContext = React.createContext(
+ WiFiConnectionContextDefaultValue
+);
diff --git a/interface/src/wifi/WiFiNetworkScanner.tsx b/interface/src/wifi/WiFiNetworkScanner.tsx
new file mode 100644
index 0000000..744f515
--- /dev/null
+++ b/interface/src/wifi/WiFiNetworkScanner.tsx
@@ -0,0 +1,168 @@
+import React, { Component } from 'react';
+import { withSnackbar, WithSnackbarProps } from 'notistack';
+
+import { createStyles, WithStyles, Theme, withStyles, Typography, LinearProgress } from '@material-ui/core';
+import PermScanWifiIcon from '@material-ui/icons/PermScanWifi';
+
+import { FormActions, FormButton, SectionContent } from '../components';
+import { redirectingAuthorizedFetch } from '../authentication';
+import { SCAN_NETWORKS_ENDPOINT, LIST_NETWORKS_ENDPOINT } from '../api';
+
+import WiFiNetworkSelector from './WiFiNetworkSelector';
+import { WiFiNetworkList, WiFiNetwork } from './types';
+
+const NUM_POLLS = 10
+const POLLING_FREQUENCY = 500
+const RETRY_EXCEPTION_TYPE = "retry"
+
+interface WiFiNetworkScannerState {
+ scanningForNetworks: boolean;
+ errorMessage?: string;
+ networkList?: WiFiNetworkList;
+}
+
+const styles = (theme: Theme) => createStyles({
+ scanningSettings: {
+ margin: theme.spacing(0.5),
+ },
+ scanningSettingsDetails: {
+ margin: theme.spacing(4),
+ textAlign: "center"
+ },
+ scanningProgress: {
+ margin: theme.spacing(4),
+ textAlign: "center"
+ }
+});
+
+type WiFiNetworkScannerProps = WithSnackbarProps & WithStyles;
+
+class WiFiNetworkScanner extends Component {
+
+ pollCount: number = 0;
+
+ state: WiFiNetworkScannerState = {
+ scanningForNetworks: false,
+ };
+
+ componentDidMount() {
+ this.scanNetworks();
+ }
+
+ requestNetworkScan = () => {
+ const { scanningForNetworks } = this.state;
+ if (!scanningForNetworks) {
+ this.scanNetworks();
+ }
+ }
+
+ scanNetworks() {
+ this.pollCount = 0;
+ this.setState({ scanningForNetworks: true, networkList: undefined, errorMessage: undefined });
+ redirectingAuthorizedFetch(SCAN_NETWORKS_ENDPOINT).then(response => {
+ if (response.status === 202) {
+ this.schedulePollTimeout();
+ return;
+ }
+ throw Error("Scanning for networks returned unexpected response code: " + response.status);
+ }).catch(error => {
+ this.props.enqueueSnackbar("Problem scanning: " + error.message, {
+ variant: 'error',
+ });
+ this.setState({ scanningForNetworks: false, networkList: undefined, errorMessage: error.message });
+ });
+ }
+
+ schedulePollTimeout() {
+ setTimeout(this.pollNetworkList, POLLING_FREQUENCY);
+ }
+
+ retryError() {
+ return {
+ name: RETRY_EXCEPTION_TYPE,
+ message: "Network list not ready, will retry in " + POLLING_FREQUENCY + "ms."
+ };
+ }
+
+ compareNetworks(network1: WiFiNetwork, network2: WiFiNetwork) {
+ if (network1.rssi < network2.rssi)
+ return 1;
+ if (network1.rssi > network2.rssi)
+ return -1;
+ return 0;
+ }
+
+ pollNetworkList = () => {
+ redirectingAuthorizedFetch(LIST_NETWORKS_ENDPOINT)
+ .then(response => {
+ if (response.status === 200) {
+ return response.json();
+ }
+ if (response.status === 202) {
+ if (++this.pollCount < NUM_POLLS) {
+ this.schedulePollTimeout();
+ throw this.retryError();
+ } else {
+ throw Error("Device did not return network list in timely manner.");
+ }
+ }
+ throw Error("Device returned unexpected response code: " + response.status);
+ })
+ .then(json => {
+ json.networks.sort(this.compareNetworks)
+ this.setState({ scanningForNetworks: false, networkList: json, errorMessage: undefined })
+ })
+ .catch(error => {
+ if (error.name !== RETRY_EXCEPTION_TYPE) {
+ this.props.enqueueSnackbar("Problem scanning: " + error.message, {
+ variant: 'error',
+ });
+ this.setState({ scanningForNetworks: false, networkList: undefined, errorMessage: error.message });
+ }
+ });
+ }
+
+ renderNetworkScanner() {
+ const { classes } = this.props;
+ const { scanningForNetworks, networkList, errorMessage } = this.state;
+ if (scanningForNetworks || !networkList) {
+ return (
+
+
+
+ Scanning…
+
+
+ );
+ }
+ if (errorMessage) {
+ return (
+
+
+ {errorMessage}
+
+
+ );
+ }
+ return (
+
+ );
+ }
+
+ render() {
+ const { scanningForNetworks } = this.state;
+ return (
+
+ {this.renderNetworkScanner()}
+
+ } variant="contained" color="secondary" onClick={this.requestNetworkScan} disabled={scanningForNetworks}>
+ Scan again…
+
+
+
+ );
+ }
+
+}
+
+export default withSnackbar(withStyles(styles)(WiFiNetworkScanner));
diff --git a/interface/src/wifi/WiFiNetworkSelector.tsx b/interface/src/wifi/WiFiNetworkSelector.tsx
new file mode 100644
index 0000000..2043651
--- /dev/null
+++ b/interface/src/wifi/WiFiNetworkSelector.tsx
@@ -0,0 +1,54 @@
+import React, { Component } from 'react';
+
+import { Avatar, Badge } from '@material-ui/core';
+import { List, ListItem, ListItemIcon, ListItemText, ListItemAvatar } from '@material-ui/core';
+
+import WifiIcon from '@material-ui/icons/Wifi';
+import LockIcon from '@material-ui/icons/Lock';
+import LockOpenIcon from '@material-ui/icons/LockOpen';
+
+import { isNetworkOpen, networkSecurityMode } from './WiFiSecurityModes';
+import { WiFiConnectionContext } from './WiFiConnectionContext';
+import { WiFiNetwork, WiFiNetworkList } from './types';
+
+interface WiFiNetworkSelectorProps {
+ networkList: WiFiNetworkList;
+}
+
+class WiFiNetworkSelector extends Component {
+
+ static contextType = WiFiConnectionContext;
+ context!: React.ContextType;
+
+ renderNetwork = (network: WiFiNetwork) => {
+ return (
+ this.context.selectNetwork(network)}>
+
+
+ {isNetworkOpen(network) ? : }
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ render() {
+ return (
+
+ {this.props.networkList.networks.map(this.renderNetwork)}
+
+ );
+ }
+
+}
+
+export default WiFiNetworkSelector;
diff --git a/interface/src/wifi/WiFiSecurityModes.ts b/interface/src/wifi/WiFiSecurityModes.ts
new file mode 100644
index 0000000..b65c69a
--- /dev/null
+++ b/interface/src/wifi/WiFiSecurityModes.ts
@@ -0,0 +1,21 @@
+import { WiFiNetwork, WiFiEncryptionType } from "./types";
+
+export const isNetworkOpen = ({ encryption_type }: WiFiNetwork) => encryption_type === WiFiEncryptionType.WIFI_AUTH_OPEN;
+
+export const networkSecurityMode = ({ encryption_type }: WiFiNetwork) => {
+ switch (encryption_type) {
+ case WiFiEncryptionType.WIFI_AUTH_WEP:
+ case WiFiEncryptionType.WIFI_AUTH_WEP_PSK:
+ return "WEP";
+ case WiFiEncryptionType.WIFI_AUTH_WEP2_PSK:
+ return "WEP2";
+ case WiFiEncryptionType.WIFI_AUTH_WPA_WPA2_PSK:
+ return "WPA/WEP2";
+ case WiFiEncryptionType.WIFI_AUTH_WPA2_ENTERPRISE:
+ return "WEP2 Enterprise";
+ case WiFiEncryptionType.WIFI_AUTH_OPEN:
+ return "None";
+ default:
+ return "Unknown";
+ }
+}
diff --git a/interface/src/wifi/WiFiSettingsController.tsx b/interface/src/wifi/WiFiSettingsController.tsx
new file mode 100644
index 0000000..d0613f8
--- /dev/null
+++ b/interface/src/wifi/WiFiSettingsController.tsx
@@ -0,0 +1,29 @@
+import React, { Component } from 'react';
+
+import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
+import WiFiSettingsForm from './WiFiSettingsForm';
+import { WIFI_SETTINGS_ENDPOINT } from '../api';
+import { WiFiSettings } from './types';
+
+type WiFiSettingsControllerProps = RestControllerProps;
+
+class WiFiSettingsController extends Component {
+
+ componentDidMount() {
+ this.props.loadData();
+ }
+
+ render() {
+ return (
+
+ }
+ />
+
+ );
+ }
+
+}
+
+export default restController(WIFI_SETTINGS_ENDPOINT, WiFiSettingsController);
diff --git a/interface/src/wifi/WiFiSettingsForm.tsx b/interface/src/wifi/WiFiSettingsForm.tsx
new file mode 100644
index 0000000..04aea28
--- /dev/null
+++ b/interface/src/wifi/WiFiSettingsForm.tsx
@@ -0,0 +1,200 @@
+import React, { Fragment } from 'react';
+import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
+
+import { Checkbox, List, ListItem, ListItemText, ListItemAvatar, ListItemSecondaryAction } from '@material-ui/core';
+
+import Avatar from '@material-ui/core/Avatar';
+import IconButton from '@material-ui/core/IconButton';
+import LockIcon from '@material-ui/icons/Lock';
+import LockOpenIcon from '@material-ui/icons/LockOpen';
+import DeleteIcon from '@material-ui/icons/Delete';
+import SaveIcon from '@material-ui/icons/Save';
+
+import { RestFormProps, PasswordValidator, BlockFormControlLabel, FormActions, FormButton } from '../components';
+import { isIP, isHostname, optional } from '../validators';
+
+import { WiFiConnectionContext } from './WiFiConnectionContext';
+import { isNetworkOpen, networkSecurityMode } from './WiFiSecurityModes';
+import { WiFiSettings } from './types';
+
+type WiFiStatusFormProps = RestFormProps;
+
+class WiFiSettingsForm extends React.Component {
+
+ static contextType = WiFiConnectionContext;
+ context!: React.ContextType;
+
+ constructor(props: WiFiStatusFormProps, context: WiFiConnectionContext) {
+ super(props);
+
+ const { selectedNetwork } = context;
+ if (selectedNetwork) {
+ const wifiSettings: WiFiSettings = {
+ ssid: selectedNetwork.ssid,
+ password: "",
+ hostname: props.data.hostname,
+ static_ip_config: false,
+ }
+ props.setData(wifiSettings);
+ }
+ }
+
+ componentWillMount() {
+ ValidatorForm.addValidationRule('isIP', isIP);
+ ValidatorForm.addValidationRule('isHostname', isHostname);
+ ValidatorForm.addValidationRule('isOptionalIP', optional(isIP));
+ }
+
+ deselectNetworkAndLoadData = () => {
+ this.context.deselectNetwork();
+ this.props.loadData();
+ }
+
+ componentWillUnmount() {
+ this.context.deselectNetwork();
+ }
+
+ render() {
+ const { selectedNetwork, deselectNetwork } = this.context;
+ const { data, handleValueChange, saveData } = this.props;
+ return (
+
+ {
+ selectedNetwork ?
+
+
+
+
+ {isNetworkOpen(selectedNetwork) ? : }
+
+
+
+
+
+
+
+
+
+
+ :
+
+ }
+ {
+ (!selectedNetwork || !isNetworkOpen(selectedNetwork)) &&
+
+ }
+
+
+ }
+ label="Static IP Config?"
+ />
+ {
+ data.static_ip_config &&
+
+
+
+
+
+
+
+ }
+
+ } variant="contained" color="primary" type="submit">
+ Save
+
+
+
+ );
+ }
+}
+
+export default WiFiSettingsForm;
diff --git a/interface/src/wifi/WiFiStatus.ts b/interface/src/wifi/WiFiStatus.ts
new file mode 100644
index 0000000..2d3574f
--- /dev/null
+++ b/interface/src/wifi/WiFiStatus.ts
@@ -0,0 +1,41 @@
+import { Theme } from '@material-ui/core';
+import { WiFiStatus, WiFiConnectionStatus } from './types';
+
+export const isConnected = ({ status }: WiFiStatus) => status === WiFiConnectionStatus.WIFI_STATUS_CONNECTED;
+
+export const wifiStatusHighlight = ({ status }: WiFiStatus, theme: Theme) => {
+ switch (status) {
+ case WiFiConnectionStatus.WIFI_STATUS_IDLE:
+ case WiFiConnectionStatus.WIFI_STATUS_DISCONNECTED:
+ case WiFiConnectionStatus.WIFI_STATUS_NO_SHIELD:
+ return theme.palette.info.main;
+ case WiFiConnectionStatus.WIFI_STATUS_CONNECTED:
+ return theme.palette.success.main;
+ case WiFiConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
+ case WiFiConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
+ return theme.palette.error.main;
+ default:
+ return theme.palette.warning.main;
+ }
+}
+
+export const wifiStatus = ({ status }: WiFiStatus) => {
+ switch (status) {
+ case WiFiConnectionStatus.WIFI_STATUS_NO_SHIELD:
+ return "Inactive";
+ case WiFiConnectionStatus.WIFI_STATUS_IDLE:
+ return "Idle";
+ case WiFiConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
+ return "No SSID Available";
+ case WiFiConnectionStatus.WIFI_STATUS_CONNECTED:
+ return "Connected";
+ case WiFiConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
+ return "Connection Failed";
+ case WiFiConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
+ return "Connection Lost";
+ case WiFiConnectionStatus.WIFI_STATUS_DISCONNECTED:
+ return "Disconnected";
+ default:
+ return "Unknown";
+ }
+}
diff --git a/interface/src/wifi/WiFiStatusController.tsx b/interface/src/wifi/WiFiStatusController.tsx
new file mode 100644
index 0000000..2220595
--- /dev/null
+++ b/interface/src/wifi/WiFiStatusController.tsx
@@ -0,0 +1,29 @@
+import React, { Component } from 'react';
+
+import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
+import WiFiStatusForm from './WiFiStatusForm';
+import { WIFI_STATUS_ENDPOINT } from '../api';
+import { WiFiStatus } from './types';
+
+type WiFiStatusControllerProps = RestControllerProps;
+
+class WiFiStatusController extends Component {
+
+ componentDidMount() {
+ this.props.loadData();
+ }
+
+ render() {
+ return (
+
+ }
+ />
+
+ );
+ }
+
+}
+
+export default restController(WIFI_STATUS_ENDPOINT, WiFiStatusController);
diff --git a/interface/src/wifi/WiFiStatusForm.tsx b/interface/src/wifi/WiFiStatusForm.tsx
new file mode 100644
index 0000000..7d5307c
--- /dev/null
+++ b/interface/src/wifi/WiFiStatusForm.tsx
@@ -0,0 +1,117 @@
+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 DNSIcon from '@material-ui/icons/Dns';
+import WifiIcon from '@material-ui/icons/Wifi';
+import SettingsInputComponentIcon from '@material-ui/icons/SettingsInputComponent';
+import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
+import DeviceHubIcon from '@material-ui/icons/DeviceHub';
+import RefreshIcon from '@material-ui/icons/Refresh';
+
+import { RestFormProps, FormActions, FormButton, HighlightAvatar } from '../components';
+import { wifiStatus, wifiStatusHighlight, isConnected } from './WiFiStatus';
+import { WiFiStatus } from './types';
+
+type WiFiStatusFormProps = RestFormProps & WithTheme;
+
+class WiFiStatusForm extends Component {
+
+ dnsServers(status: WiFiStatus) {
+ if (!status.dns_ip_1) {
+ return "none";
+ }
+ return status.dns_ip_1 + (status.dns_ip_2 ? ',' + status.dns_ip_2 : '');
+ }
+
+ createListItems() {
+ const { data, theme } = this.props
+ return (
+
+
+
+
+
+
+
+
+
+
+ {
+ isConnected(data) &&
+
+
+
+
+
+
+
+
+
+
+
+
+ IP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+ );
+ }
+
+ render() {
+ return (
+
+
+ {this.createListItems()}
+
+
+ } variant="contained" color="secondary" onClick={this.props.loadData}>
+ Refresh
+
+
+
+ );
+ }
+
+}
+
+export default withTheme(WiFiStatusForm);
diff --git a/interface/src/wifi/types.ts b/interface/src/wifi/types.ts
new file mode 100644
index 0000000..d631051
--- /dev/null
+++ b/interface/src/wifi/types.ts
@@ -0,0 +1,56 @@
+export enum WiFiConnectionStatus {
+ WIFI_STATUS_IDLE = 0,
+ WIFI_STATUS_NO_SSID_AVAIL = 1,
+ WIFI_STATUS_CONNECTED = 3,
+ WIFI_STATUS_CONNECT_FAILED = 4,
+ WIFI_STATUS_CONNECTION_LOST = 5,
+ WIFI_STATUS_DISCONNECTED = 6,
+ WIFI_STATUS_NO_SHIELD = 255
+}
+
+export enum WiFiEncryptionType {
+ WIFI_AUTH_OPEN = 0,
+ WIFI_AUTH_WEP = 1,
+ WIFI_AUTH_WEP_PSK = 2,
+ WIFI_AUTH_WEP2_PSK = 3,
+ WIFI_AUTH_WPA_WPA2_PSK = 4,
+ WIFI_AUTH_WPA2_ENTERPRISE = 5
+}
+
+export interface WiFiStatus {
+ status: WiFiConnectionStatus;
+ local_ip: string;
+ mac_address: string;
+ rssi: number;
+ ssid: string;
+ bssid: string;
+ channel: number;
+ subnet_mask: string;
+ gateway_ip: string;
+ dns_ip_1: string;
+ dns_ip_2: string;
+}
+
+export interface WiFiSettings {
+ ssid: string;
+ password: string;
+ hostname: string;
+ static_ip_config: boolean;
+ local_ip?: string;
+ gateway_ip?: string;
+ subnet_mask?: string;
+ dns_ip_1?: string;
+ dns_ip_2?: string;
+}
+
+export interface WiFiNetworkList {
+ networks: WiFiNetwork[];
+}
+
+export interface WiFiNetwork {
+ rssi: number;
+ ssid: string;
+ bssid: string;
+ channel: number;
+ encryption_type: WiFiEncryptionType;
+}
diff --git a/interface/tsconfig.json b/interface/tsconfig.json
new file mode 100644
index 0000000..f2850b7
--- /dev/null
+++ b/interface/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react"
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/lib/framework/APSettingsService.cpp b/lib/framework/APSettingsService.cpp
new file mode 100644
index 0000000..42732f0
--- /dev/null
+++ b/lib/framework/APSettingsService.cpp
@@ -0,0 +1,83 @@
+#include
+
+APSettingsService::APSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager) :
+ _httpEndpoint(APSettings::read, APSettings::update, this, server, AP_SETTINGS_SERVICE_PATH, securityManager),
+ _fsPersistence(APSettings::read, APSettings::update, this, fs, AP_SETTINGS_FILE),
+ _dnsServer(nullptr),
+ _lastManaged(0),
+ _reconfigureAp(false) {
+ addUpdateHandler([&](const String& originId) { reconfigureAP(); }, false);
+}
+
+void APSettingsService::begin() {
+ _fsPersistence.readFromFS();
+ reconfigureAP();
+}
+
+void APSettingsService::reconfigureAP() {
+ _lastManaged = millis() - MANAGE_NETWORK_DELAY;
+ _reconfigureAp = true;
+}
+
+void APSettingsService::loop() {
+ unsigned long currentMillis = millis();
+ unsigned long manageElapsed = (unsigned long)(currentMillis - _lastManaged);
+ if (manageElapsed >= MANAGE_NETWORK_DELAY) {
+ _lastManaged = currentMillis;
+ manageAP();
+ }
+ handleDNS();
+}
+
+void APSettingsService::manageAP() {
+ WiFiMode_t currentWiFiMode = WiFi.getMode();
+ if (_state.provisionMode == AP_MODE_ALWAYS ||
+ (_state.provisionMode == AP_MODE_DISCONNECTED && WiFi.status() != WL_CONNECTED)) {
+ if (_reconfigureAp || currentWiFiMode == WIFI_OFF || currentWiFiMode == WIFI_STA) {
+ startAP();
+ }
+ } else if ((currentWiFiMode == WIFI_AP || currentWiFiMode == WIFI_AP_STA) &&
+ (_reconfigureAp || !WiFi.softAPgetStationNum())) {
+ stopAP();
+ }
+ _reconfigureAp = false;
+}
+
+void APSettingsService::startAP() {
+ Serial.println(F("Starting software access point"));
+ WiFi.softAPConfig(_state.localIP, _state.gatewayIP, _state.subnetMask);
+ WiFi.softAP(_state.ssid.c_str(), _state.password.c_str());
+ if (!_dnsServer) {
+ IPAddress apIp = WiFi.softAPIP();
+ Serial.print(F("Starting captive portal on "));
+ Serial.println(apIp);
+ _dnsServer = new DNSServer;
+ _dnsServer->start(DNS_PORT, "*", apIp);
+ }
+}
+
+void APSettingsService::stopAP() {
+ if (_dnsServer) {
+ Serial.println(F("Stopping captive portal"));
+ _dnsServer->stop();
+ delete _dnsServer;
+ _dnsServer = nullptr;
+ }
+ Serial.println(F("Stopping software access point"));
+ WiFi.softAPdisconnect(true);
+}
+
+void APSettingsService::handleDNS() {
+ if (_dnsServer) {
+ _dnsServer->processNextRequest();
+ }
+}
+
+APNetworkStatus APSettingsService::getAPNetworkStatus() {
+ WiFiMode_t currentWiFiMode = WiFi.getMode();
+ bool apActive = currentWiFiMode == WIFI_AP || currentWiFiMode == WIFI_AP_STA;
+ if (apActive && _state.provisionMode != AP_MODE_ALWAYS && WiFi.status() == WL_CONNECTED) {
+ return APNetworkStatus::LINGERING;
+ }
+ return apActive ? APNetworkStatus::ACTIVE : APNetworkStatus::INACTIVE;
+}
diff --git a/lib/framework/APSettingsService.h b/lib/framework/APSettingsService.h
new file mode 100644
index 0000000..9104615
--- /dev/null
+++ b/lib/framework/APSettingsService.h
@@ -0,0 +1,123 @@
+#ifndef APSettingsConfig_h
+#define APSettingsConfig_h
+
+#include
+#include
+#include
+
+#include
+#include
+
+#define MANAGE_NETWORK_DELAY 10000
+
+#define AP_MODE_ALWAYS 0
+#define AP_MODE_DISCONNECTED 1
+#define AP_MODE_NEVER 2
+
+#define DNS_PORT 53
+
+#ifndef FACTORY_AP_PROVISION_MODE
+#define FACTORY_AP_PROVISION_MODE AP_MODE_DISCONNECTED
+#endif
+
+#ifndef FACTORY_AP_SSID
+#define FACTORY_AP_SSID "ESP8266-React"
+#endif
+
+#ifndef FACTORY_AP_PASSWORD
+#define FACTORY_AP_PASSWORD "esp-react"
+#endif
+
+#ifndef FACTORY_AP_LOCAL_IP
+#define FACTORY_AP_LOCAL_IP "192.168.4.1"
+#endif
+
+#ifndef FACTORY_AP_GATEWAY_IP
+#define FACTORY_AP_GATEWAY_IP "192.168.4.1"
+#endif
+
+#ifndef FACTORY_AP_SUBNET_MASK
+#define FACTORY_AP_SUBNET_MASK "255.255.255.0"
+#endif
+
+#define AP_SETTINGS_FILE "/config/apSettings.json"
+#define AP_SETTINGS_SERVICE_PATH "/rest/apSettings"
+
+enum APNetworkStatus { ACTIVE = 0, INACTIVE, LINGERING };
+
+class APSettings {
+ public:
+ uint8_t provisionMode;
+ String ssid;
+ String password;
+ IPAddress localIP;
+ IPAddress gatewayIP;
+ IPAddress subnetMask;
+
+ bool operator==(const APSettings& settings) const {
+ return provisionMode == settings.provisionMode && ssid == settings.ssid && password == settings.password &&
+ localIP == settings.localIP && gatewayIP == settings.gatewayIP && subnetMask == settings.subnetMask;
+ }
+
+ static void read(APSettings& settings, JsonObject& root) {
+ root["provision_mode"] = settings.provisionMode;
+ root["ssid"] = settings.ssid;
+ root["password"] = settings.password;
+ root["local_ip"] = settings.localIP.toString();
+ root["gateway_ip"] = settings.gatewayIP.toString();
+ root["subnet_mask"] = settings.subnetMask.toString();
+ }
+
+ static StateUpdateResult update(JsonObject& root, APSettings& settings) {
+ APSettings newSettings = {};
+ newSettings.provisionMode = root["provision_mode"] | FACTORY_AP_PROVISION_MODE;
+ switch (settings.provisionMode) {
+ case AP_MODE_ALWAYS:
+ case AP_MODE_DISCONNECTED:
+ case AP_MODE_NEVER:
+ break;
+ default:
+ newSettings.provisionMode = AP_MODE_ALWAYS;
+ }
+ newSettings.ssid = root["ssid"] | FACTORY_AP_SSID;
+ newSettings.password = root["password"] | FACTORY_AP_PASSWORD;
+
+ JsonUtils::readIP(root, "local_ip", newSettings.localIP, FACTORY_AP_LOCAL_IP);
+ JsonUtils::readIP(root, "gateway_ip", newSettings.gatewayIP, FACTORY_AP_GATEWAY_IP);
+ JsonUtils::readIP(root, "subnet_mask", newSettings.subnetMask, FACTORY_AP_SUBNET_MASK);
+
+ if (newSettings == settings) {
+ return StateUpdateResult::UNCHANGED;
+ }
+ settings = newSettings;
+ return StateUpdateResult::CHANGED;
+ }
+};
+
+class APSettingsService : public StatefulService {
+ public:
+ APSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager);
+
+ void begin();
+ void loop();
+ APNetworkStatus getAPNetworkStatus();
+
+ private:
+ HttpEndpoint _httpEndpoint;
+ FSPersistence _fsPersistence;
+
+ // for the captive portal
+ DNSServer* _dnsServer;
+
+ // for the mangement delay loop
+ volatile unsigned long _lastManaged;
+ volatile boolean _reconfigureAp;
+
+ void reconfigureAP();
+ void manageAP();
+ void startAP();
+ void stopAP();
+ void handleDNS();
+};
+
+#endif // end APSettingsConfig_h
diff --git a/lib/framework/APStatus.cpp b/lib/framework/APStatus.cpp
new file mode 100644
index 0000000..5bfe300
--- /dev/null
+++ b/lib/framework/APStatus.cpp
@@ -0,0 +1,22 @@
+#include
+
+APStatus::APStatus(AsyncWebServer* server, SecurityManager* securityManager, APSettingsService* apSettingsService) :
+ _apSettingsService(apSettingsService) {
+ server->on(AP_STATUS_SERVICE_PATH,
+ HTTP_GET,
+ securityManager->wrapRequest(std::bind(&APStatus::apStatus, this, std::placeholders::_1),
+ AuthenticationPredicates::IS_AUTHENTICATED));
+}
+
+void APStatus::apStatus(AsyncWebServerRequest* request) {
+ AsyncJsonResponse* response = new AsyncJsonResponse(false, MAX_AP_STATUS_SIZE);
+ JsonObject root = response->getRoot();
+
+ root["status"] = _apSettingsService->getAPNetworkStatus();
+ root["ip_address"] = WiFi.softAPIP().toString();
+ root["mac_address"] = WiFi.softAPmacAddress();
+ root["station_num"] = WiFi.softAPgetStationNum();
+
+ response->setLength();
+ request->send(response);
+}
diff --git a/lib/framework/APStatus.h b/lib/framework/APStatus.h
new file mode 100644
index 0000000..12620b0
--- /dev/null
+++ b/lib/framework/APStatus.h
@@ -0,0 +1,31 @@
+#ifndef APStatus_h
+#define APStatus_h
+
+#ifdef ESP32
+#include
+#include
+#elif defined(ESP8266)
+#include
+#include
+#endif
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define MAX_AP_STATUS_SIZE 1024
+#define AP_STATUS_SERVICE_PATH "/rest/apStatus"
+
+class APStatus {
+ public:
+ APStatus(AsyncWebServer* server, SecurityManager* securityManager, APSettingsService* apSettingsService);
+
+ private:
+ APSettingsService* _apSettingsService;
+ void apStatus(AsyncWebServerRequest* request);
+};
+
+#endif // end APStatus_h
diff --git a/lib/framework/ArduinoJsonJWT.cpp b/lib/framework/ArduinoJsonJWT.cpp
new file mode 100644
index 0000000..8b449e1
--- /dev/null
+++ b/lib/framework/ArduinoJsonJWT.cpp
@@ -0,0 +1,144 @@
+#include "ArduinoJsonJWT.h"
+
+ArduinoJsonJWT::ArduinoJsonJWT(String secret) : _secret(secret) {
+}
+
+void ArduinoJsonJWT::setSecret(String secret) {
+ _secret = secret;
+}
+
+String ArduinoJsonJWT::getSecret() {
+ return _secret;
+}
+
+/*
+ * ESP32 uses mbedtls, ESP2866 uses bearssl.
+ *
+ * Both come with decent HMAC implmentations supporting sha256, as well as others.
+ *
+ * No need to pull in additional crypto libraries - lets use what we already have.
+ */
+String ArduinoJsonJWT::sign(String& payload) {
+ unsigned char hmacResult[32];
+ {
+#ifdef ESP32
+ mbedtls_md_context_t ctx;
+ mbedtls_md_type_t md_type = MBEDTLS_MD_SHA256;
+ mbedtls_md_init(&ctx);
+ mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(md_type), 1);
+ mbedtls_md_hmac_starts(&ctx, (unsigned char*)_secret.c_str(), _secret.length());
+ mbedtls_md_hmac_update(&ctx, (unsigned char*)payload.c_str(), payload.length());
+ mbedtls_md_hmac_finish(&ctx, hmacResult);
+ mbedtls_md_free(&ctx);
+#elif defined(ESP8266)
+ br_hmac_key_context keyCtx;
+ br_hmac_key_init(&keyCtx, &br_sha256_vtable, _secret.c_str(), _secret.length());
+ br_hmac_context hmacCtx;
+ br_hmac_init(&hmacCtx, &keyCtx, 0);
+ br_hmac_update(&hmacCtx, payload.c_str(), payload.length());
+ br_hmac_out(&hmacCtx, hmacResult);
+#endif
+ }
+ return encode((char*)hmacResult, 32);
+}
+
+String ArduinoJsonJWT::buildJWT(JsonObject& payload) {
+ // serialize, then encode payload
+ String jwt;
+ serializeJson(payload, jwt);
+ jwt = encode(jwt.c_str(), jwt.length());
+
+ // add the header to payload
+ jwt = JWT_HEADER + '.' + jwt;
+
+ // add signature
+ jwt += '.' + sign(jwt);
+
+ return jwt;
+}
+
+void ArduinoJsonJWT::parseJWT(String jwt, JsonDocument& jsonDocument) {
+ // clear json document before we begin, jsonDocument wil be null on failure
+ jsonDocument.clear();
+
+ // must have the correct header and delimiter
+ if (!jwt.startsWith(JWT_HEADER) || jwt.indexOf('.') != JWT_HEADER_SIZE) {
+ return;
+ }
+
+ // check there is a signature delimieter
+ int signatureDelimiterIndex = jwt.lastIndexOf('.');
+ if (signatureDelimiterIndex == JWT_HEADER_SIZE) {
+ return;
+ }
+
+ // check the signature is valid
+ String signature = jwt.substring(signatureDelimiterIndex + 1);
+ jwt = jwt.substring(0, signatureDelimiterIndex);
+ if (sign(jwt) != signature) {
+ return;
+ }
+
+ // decode payload
+ jwt = jwt.substring(JWT_HEADER_SIZE + 1);
+ jwt = decode(jwt);
+
+ // parse payload, clearing json document after failure
+ DeserializationError error = deserializeJson(jsonDocument, jwt);
+ if (error != DeserializationError::Ok || !jsonDocument.is()) {
+ jsonDocument.clear();
+ }
+}
+
+String ArduinoJsonJWT::encode(const char* cstr, int inputLen) {
+ // prepare encoder
+ base64_encodestate _state;
+#ifdef ESP32
+ base64_init_encodestate(&_state);
+ size_t encodedLength = base64_encode_expected_len(inputLen) + 1;
+#elif defined(ESP8266)
+ base64_init_encodestate_nonewlines(&_state);
+ size_t encodedLength = base64_encode_expected_len_nonewlines(inputLen) + 1;
+#endif
+ // prepare buffer of correct length, returning an empty string on failure
+ char* buffer = (char*)malloc(encodedLength * sizeof(char));
+ if (buffer == nullptr) {
+ return "";
+ }
+
+ // encode to buffer
+ int len = base64_encode_block(cstr, inputLen, &buffer[0], &_state);
+ len += base64_encode_blockend(&buffer[len], &_state);
+ buffer[len] = 0;
+
+ // convert to arduino string, freeing buffer
+ String value = String(buffer);
+ free(buffer);
+ buffer = nullptr;
+
+ // remove padding and convert to URL safe form
+ while (value.length() > 0 && value.charAt(value.length() - 1) == '=') {
+ value.remove(value.length() - 1);
+ }
+ value.replace('+', '-');
+ value.replace('/', '_');
+
+ // return as string
+ return value;
+}
+
+String ArduinoJsonJWT::decode(String value) {
+ // convert to standard base64
+ value.replace('-', '+');
+ value.replace('_', '/');
+
+ // prepare buffer of correct length
+ char buffer[base64_decode_expected_len(value.length()) + 1];
+
+ // decode
+ int len = base64_decode_chars(value.c_str(), value.length(), &buffer[0]);
+ buffer[len] = 0;
+
+ // return as string
+ return String(buffer);
+}
diff --git a/lib/framework/ArduinoJsonJWT.h b/lib/framework/ArduinoJsonJWT.h
new file mode 100644
index 0000000..beeedc0
--- /dev/null
+++ b/lib/framework/ArduinoJsonJWT.h
@@ -0,0 +1,37 @@
+#ifndef ArduinoJsonJWT_H
+#define ArduinoJsonJWT_H
+
+#include
+#include
+#include
+#include
+
+#ifdef ESP32
+#include
+#elif defined(ESP8266)
+#include
+#endif
+
+class ArduinoJsonJWT {
+ private:
+ String _secret;
+
+ const String JWT_HEADER = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
+ const int JWT_HEADER_SIZE = JWT_HEADER.length();
+
+ String sign(String& value);
+
+ static String encode(const char* cstr, int len);
+ static String decode(String value);
+
+ public:
+ ArduinoJsonJWT(String secret);
+
+ void setSecret(String secret);
+ String getSecret();
+
+ String buildJWT(JsonObject& payload);
+ void parseJWT(String jwt, JsonDocument& jsonDocument);
+};
+
+#endif
diff --git a/lib/framework/AuthenticationService.cpp b/lib/framework/AuthenticationService.cpp
new file mode 100644
index 0000000..84c347c
--- /dev/null
+++ b/lib/framework/AuthenticationService.cpp
@@ -0,0 +1,48 @@
+#include
+
+#if FT_ENABLED(FT_SECURITY)
+
+AuthenticationService::AuthenticationService(AsyncWebServer* server, SecurityManager* securityManager) :
+ _securityManager(securityManager),
+ _signInHandler(SIGN_IN_PATH,
+ std::bind(&AuthenticationService::signIn, this, std::placeholders::_1, std::placeholders::_2)) {
+ server->on(VERIFY_AUTHORIZATION_PATH,
+ HTTP_GET,
+ std::bind(&AuthenticationService::verifyAuthorization, this, std::placeholders::_1));
+ _signInHandler.setMethod(HTTP_POST);
+ _signInHandler.setMaxContentLength(MAX_AUTHENTICATION_SIZE);
+ server->addHandler(&_signInHandler);
+}
+
+/**
+ * Verifys that the request supplied a valid JWT.
+ */
+void AuthenticationService::verifyAuthorization(AsyncWebServerRequest* request) {
+ Authentication authentication = _securityManager->authenticateRequest(request);
+ request->send(authentication.authenticated ? 200 : 401);
+}
+
+/**
+ * Signs in a user if the username and password match. Provides a JWT to be used in the Authorization header in
+ * subsequent requests.
+ */
+void AuthenticationService::signIn(AsyncWebServerRequest* request, JsonVariant& json) {
+ if (json.is()) {
+ String username = json["username"];
+ String password = json["password"];
+ Authentication authentication = _securityManager->authenticate(username, password);
+ if (authentication.authenticated) {
+ User* user = authentication.user;
+ AsyncJsonResponse* response = new AsyncJsonResponse(false, MAX_AUTHENTICATION_SIZE);
+ JsonObject jsonObject = response->getRoot();
+ jsonObject["access_token"] = _securityManager->generateJWT(user);
+ response->setLength();
+ request->send(response);
+ return;
+ }
+ }
+ AsyncWebServerResponse* response = request->beginResponse(401);
+ request->send(response);
+}
+
+#endif // end FT_ENABLED(FT_SECURITY)
diff --git a/lib/framework/AuthenticationService.h b/lib/framework/AuthenticationService.h
new file mode 100644
index 0000000..8520223
--- /dev/null
+++ b/lib/framework/AuthenticationService.h
@@ -0,0 +1,30 @@
+#ifndef AuthenticationService_H_
+#define AuthenticationService_H_
+
+#include
+#include
+#include
+#include
+
+#define VERIFY_AUTHORIZATION_PATH "/rest/verifyAuthorization"
+#define SIGN_IN_PATH "/rest/signIn"
+
+#define MAX_AUTHENTICATION_SIZE 256
+
+#if FT_ENABLED(FT_SECURITY)
+
+class AuthenticationService {
+ public:
+ AuthenticationService(AsyncWebServer* server, SecurityManager* securityManager);
+
+ private:
+ SecurityManager* _securityManager;
+ AsyncCallbackJsonWebHandler _signInHandler;
+
+ // endpoint functions
+ void signIn(AsyncWebServerRequest* request, JsonVariant& json);
+ void verifyAuthorization(AsyncWebServerRequest* request);
+};
+
+#endif // end FT_ENABLED(FT_SECURITY)
+#endif // end SecurityManager_h
diff --git a/lib/framework/ESP8266React.cpp b/lib/framework/ESP8266React.cpp
new file mode 100644
index 0000000..5baed6d
--- /dev/null
+++ b/lib/framework/ESP8266React.cpp
@@ -0,0 +1,114 @@
+#include
+
+ESP8266React::ESP8266React(AsyncWebServer* server) :
+ _featureService(server),
+ _securitySettingsService(server, &ESPFS),
+ _wifiSettingsService(server, &ESPFS, &_securitySettingsService),
+ _wifiScanner(server, &_securitySettingsService),
+ _wifiStatus(server, &_securitySettingsService),
+ _apSettingsService(server, &ESPFS, &_securitySettingsService),
+ _apStatus(server, &_securitySettingsService, &_apSettingsService),
+#if FT_ENABLED(FT_NTP)
+ _ntpSettingsService(server, &ESPFS, &_securitySettingsService),
+ _ntpStatus(server, &_securitySettingsService),
+#endif
+#if FT_ENABLED(FT_OTA)
+ _otaSettingsService(server, &ESPFS, &_securitySettingsService),
+#endif
+#if FT_ENABLED(FT_UPLOAD_FIRMWARE)
+ _uploadFirmwareService(server, &_securitySettingsService),
+#endif
+#if FT_ENABLED(FT_MQTT)
+ _mqttSettingsService(server, &ESPFS, &_securitySettingsService),
+ _mqttStatus(server, &_mqttSettingsService, &_securitySettingsService),
+#endif
+#if FT_ENABLED(FT_SECURITY)
+ _authenticationService(server, &_securitySettingsService),
+#endif
+ _restartService(server, &_securitySettingsService),
+ _factoryResetService(server, &ESPFS, &_securitySettingsService),
+ _systemStatus(server, &_securitySettingsService) {
+#ifdef PROGMEM_WWW
+ // Serve static resources from PROGMEM
+ WWWData::registerRoutes(
+ [server, this](const String& uri, const String& contentType, const uint8_t* content, size_t len) {
+ ArRequestHandlerFunction requestHandler = [contentType, content, len](AsyncWebServerRequest* request) {
+ AsyncWebServerResponse* response = request->beginResponse_P(200, contentType, content, len);
+ response->addHeader("Content-Encoding", "gzip");
+ request->send(response);
+ };
+ server->on(uri.c_str(), HTTP_GET, requestHandler);
+ // Serving non matching get requests with "/index.html"
+ // OPTIONS get a straight up 200 response
+ if (uri.equals("/index.html")) {
+ server->onNotFound([requestHandler](AsyncWebServerRequest* request) {
+ if (request->method() == HTTP_GET) {
+ requestHandler(request);
+ } else if (request->method() == HTTP_OPTIONS) {
+ request->send(200);
+ } else {
+ request->send(404);
+ }
+ });
+ }
+ });
+#else
+ // Serve static resources from /www/
+ server->serveStatic("/js/", ESPFS, "/www/js/");
+ server->serveStatic("/css/", ESPFS, "/www/css/");
+ server->serveStatic("/fonts/", ESPFS, "/www/fonts/");
+ server->serveStatic("/app/", ESPFS, "/www/app/");
+ server->serveStatic("/favicon.ico", ESPFS, "/www/favicon.ico");
+ // Serving all other get requests with "/www/index.htm"
+ // OPTIONS get a straight up 200 response
+ server->onNotFound([](AsyncWebServerRequest* request) {
+ if (request->method() == HTTP_GET) {
+ request->send(ESPFS, "/www/index.html");
+ } else if (request->method() == HTTP_OPTIONS) {
+ request->send(200);
+ } else {
+ request->send(404);
+ }
+ });
+#endif
+
+// Disable CORS if required
+#if defined(ENABLE_CORS)
+ DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", CORS_ORIGIN);
+ DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "Accept, Content-Type, Authorization");
+ DefaultHeaders::Instance().addHeader("Access-Control-Allow-Credentials", "true");
+#endif
+}
+
+void ESP8266React::begin() {
+#ifdef ESP32
+ ESPFS.begin(true);
+#elif defined(ESP8266)
+ ESPFS.begin();
+#endif
+ _wifiSettingsService.begin();
+ _apSettingsService.begin();
+#if FT_ENABLED(FT_NTP)
+ _ntpSettingsService.begin();
+#endif
+#if FT_ENABLED(FT_OTA)
+ _otaSettingsService.begin();
+#endif
+#if FT_ENABLED(FT_MQTT)
+ _mqttSettingsService.begin();
+#endif
+#if FT_ENABLED(FT_SECURITY)
+ _securitySettingsService.begin();
+#endif
+}
+
+void ESP8266React::loop() {
+ _wifiSettingsService.loop();
+ _apSettingsService.loop();
+#if FT_ENABLED(FT_OTA)
+ _otaSettingsService.loop();
+#endif
+#if FT_ENABLED(FT_MQTT)
+ _mqttSettingsService.loop();
+#endif
+}
diff --git a/lib/framework/ESP8266React.h b/lib/framework/ESP8266React.h
new file mode 100644
index 0000000..b1daf8a
--- /dev/null
+++ b/lib/framework/ESP8266React.h
@@ -0,0 +1,122 @@
+#ifndef ESP8266React_h
+#define ESP8266React_h
+
+#include
+
+#ifdef ESP32
+#include
+#include
+#elif defined(ESP8266)
+#include
+#include
+#endif
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef PROGMEM_WWW
+#include
+#endif
+
+class ESP8266React {
+ public:
+ ESP8266React(AsyncWebServer* server);
+
+ void begin();
+ void loop();
+
+ FS* getFS() {
+ return &ESPFS;
+ }
+
+ SecurityManager* getSecurityManager() {
+ return &_securitySettingsService;
+ }
+
+#if FT_ENABLED(FT_SECURITY)
+ StatefulService* getSecuritySettingsService() {
+ return &_securitySettingsService;
+ }
+#endif
+
+ StatefulService* getWiFiSettingsService() {
+ return &_wifiSettingsService;
+ }
+
+ StatefulService* getAPSettingsService() {
+ return &_apSettingsService;
+ }
+
+#if FT_ENABLED(FT_NTP)
+ StatefulService* getNTPSettingsService() {
+ return &_ntpSettingsService;
+ }
+#endif
+
+#if FT_ENABLED(FT_OTA)
+ StatefulService* getOTASettingsService() {
+ return &_otaSettingsService;
+ }
+#endif
+
+#if FT_ENABLED(FT_MQTT)
+ StatefulService* getMqttSettingsService() {
+ return &_mqttSettingsService;
+ }
+
+ AsyncMqttClient* getMqttClient() {
+ return _mqttSettingsService.getMqttClient();
+ }
+#endif
+
+ void factoryReset() {
+ _factoryResetService.factoryReset();
+ }
+
+ private:
+ FeaturesService _featureService;
+ SecuritySettingsService _securitySettingsService;
+ WiFiSettingsService _wifiSettingsService;
+ WiFiScanner _wifiScanner;
+ WiFiStatus _wifiStatus;
+ APSettingsService _apSettingsService;
+ APStatus _apStatus;
+#if FT_ENABLED(FT_NTP)
+ NTPSettingsService _ntpSettingsService;
+ NTPStatus _ntpStatus;
+#endif
+#if FT_ENABLED(FT_OTA)
+ OTASettingsService _otaSettingsService;
+#endif
+#if FT_ENABLED(FT_UPLOAD_FIRMWARE)
+ UploadFirmwareService _uploadFirmwareService;
+#endif
+#if FT_ENABLED(FT_MQTT)
+ MqttSettingsService _mqttSettingsService;
+ MqttStatus _mqttStatus;
+#endif
+#if FT_ENABLED(FT_SECURITY)
+ AuthenticationService _authenticationService;
+#endif
+ RestartService _restartService;
+ FactoryResetService _factoryResetService;
+ SystemStatus _systemStatus;
+};
+
+#endif
diff --git a/lib/framework/ESPFS.h b/lib/framework/ESPFS.h
new file mode 100644
index 0000000..7585f02
--- /dev/null
+++ b/lib/framework/ESPFS.h
@@ -0,0 +1,7 @@
+#ifdef ESP32
+#include
+#define ESPFS SPIFFS
+#elif defined(ESP8266)
+#include
+#define ESPFS LittleFS
+#endif
diff --git a/lib/framework/ESPUtils.h b/lib/framework/ESPUtils.h
new file mode 100644
index 0000000..834459d
--- /dev/null
+++ b/lib/framework/ESPUtils.h
@@ -0,0 +1,17 @@
+#ifndef ESPUtils_h
+#define ESPUtils_h
+
+#include
+
+class ESPUtils {
+ public:
+ static String defaultDeviceValue(String prefix = "") {
+#ifdef ESP32
+ return prefix + String((unsigned long)ESP.getEfuseMac(), HEX);
+#elif defined(ESP8266)
+ return prefix + String(ESP.getChipId(), HEX);
+#endif
+ }
+};
+
+#endif // end ESPUtils
diff --git a/lib/framework/FSPersistence.h b/lib/framework/FSPersistence.h
new file mode 100644
index 0000000..9fc547b
--- /dev/null
+++ b/lib/framework/FSPersistence.h
@@ -0,0 +1,98 @@
+#ifndef FSPersistence_h
+#define FSPersistence_h
+
+#include
+#include
+
+template
+class FSPersistence {
+ public:
+ FSPersistence(JsonStateReader stateReader,
+ JsonStateUpdater stateUpdater,
+ StatefulService* statefulService,
+ FS* fs,
+ const char* filePath,
+ size_t bufferSize = DEFAULT_BUFFER_SIZE) :
+ _stateReader(stateReader),
+ _stateUpdater(stateUpdater),
+ _statefulService(statefulService),
+ _fs(fs),
+ _filePath(filePath),
+ _bufferSize(bufferSize),
+ _updateHandlerId(0) {
+ enableUpdateHandler();
+ }
+
+ void readFromFS() {
+ File settingsFile = _fs->open(_filePath, "r");
+
+ if (settingsFile) {
+ DynamicJsonDocument jsonDocument = DynamicJsonDocument(_bufferSize);
+ DeserializationError error = deserializeJson(jsonDocument, settingsFile);
+ if (error == DeserializationError::Ok && jsonDocument.is()) {
+ JsonObject jsonObject = jsonDocument.as();
+ _statefulService->updateWithoutPropagation(jsonObject, _stateUpdater);
+ settingsFile.close();
+ return;
+ }
+ settingsFile.close();
+ }
+
+ // If we reach here we have not been successful in loading the config,
+ // hard-coded emergency defaults are now applied.
+ applyDefaults();
+ }
+
+ bool writeToFS() {
+ // create and populate a new json object
+ DynamicJsonDocument jsonDocument = DynamicJsonDocument(_bufferSize);
+ JsonObject jsonObject = jsonDocument.to();
+ _statefulService->read(jsonObject, _stateReader);
+
+ // serialize it to filesystem
+ File settingsFile = _fs->open(_filePath, "w");
+
+ // failed to open file, return false
+ if (!settingsFile) {
+ return false;
+ }
+
+ // serialize the data to the file
+ serializeJson(jsonDocument, settingsFile);
+ settingsFile.close();
+ return true;
+ }
+
+ void disableUpdateHandler() {
+ if (_updateHandlerId) {
+ _statefulService->removeUpdateHandler(_updateHandlerId);
+ _updateHandlerId = 0;
+ }
+ }
+
+ void enableUpdateHandler() {
+ if (!_updateHandlerId) {
+ _updateHandlerId = _statefulService->addUpdateHandler([&](const String& originId) { writeToFS(); });
+ }
+ }
+
+ private:
+ JsonStateReader _stateReader;
+ JsonStateUpdater _stateUpdater;
+ StatefulService* _statefulService;
+ FS* _fs;
+ const char* _filePath;
+ size_t _bufferSize;
+ update_handler_id_t _updateHandlerId;
+
+ protected:
+ // We assume the updater supplies sensible defaults if an empty object
+ // is supplied, this virtual function allows that to be changed.
+ virtual void applyDefaults() {
+ DynamicJsonDocument jsonDocument = DynamicJsonDocument(_bufferSize);
+ JsonObject jsonObject = jsonDocument.as();
+ _statefulService->updateWithoutPropagation(jsonObject, _stateUpdater);
+ }
+};
+
+#endif // end FSPersistence
diff --git a/lib/framework/FactoryResetService.cpp b/lib/framework/FactoryResetService.cpp
new file mode 100644
index 0000000..9742207
--- /dev/null
+++ b/lib/framework/FactoryResetService.cpp
@@ -0,0 +1,37 @@
+#include
+
+using namespace std::placeholders;
+
+FactoryResetService::FactoryResetService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager) : fs(fs) {
+ server->on(FACTORY_RESET_SERVICE_PATH,
+ HTTP_POST,
+ securityManager->wrapRequest(std::bind(&FactoryResetService::handleRequest, this, _1),
+ AuthenticationPredicates::IS_ADMIN));
+}
+
+void FactoryResetService::handleRequest(AsyncWebServerRequest* request) {
+ request->onDisconnect(std::bind(&FactoryResetService::factoryReset, this));
+ request->send(200);
+}
+
+/**
+ * Delete function assumes that all files are stored flat, within the config directory.
+ */
+void FactoryResetService::factoryReset() {
+#ifdef ESP32
+ File root = fs->open(FS_CONFIG_DIRECTORY);
+ File file;
+ while (file = root.openNextFile()) {
+ fs->remove(file.name());
+ }
+#elif defined(ESP8266)
+ Dir configDirectory = fs->openDir(FS_CONFIG_DIRECTORY);
+ while (configDirectory.next()) {
+ String path = FS_CONFIG_DIRECTORY;
+ path.concat("/");
+ path.concat(configDirectory.fileName());
+ fs->remove(path);
+ }
+#endif
+ RestartService::restartNow();
+}
diff --git a/lib/framework/FactoryResetService.h b/lib/framework/FactoryResetService.h
new file mode 100644
index 0000000..2336e6f
--- /dev/null
+++ b/lib/framework/FactoryResetService.h
@@ -0,0 +1,32 @@
+#ifndef FactoryResetService_h
+#define FactoryResetService_h
+
+#ifdef ESP32
+#include
+#include
+#elif defined(ESP8266)
+#include
+#include
+#endif
+
+#include
+#include
+#include
+#include
+
+#define FS_CONFIG_DIRECTORY "/config"
+#define FACTORY_RESET_SERVICE_PATH "/rest/factoryReset"
+
+class FactoryResetService {
+ FS* fs;
+
+ public:
+ FactoryResetService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager);
+
+ void factoryReset();
+
+ private:
+ void handleRequest(AsyncWebServerRequest* request);
+};
+
+#endif // end FactoryResetService_h
diff --git a/lib/framework/Features.h b/lib/framework/Features.h
new file mode 100644
index 0000000..2de82c5
--- /dev/null
+++ b/lib/framework/Features.h
@@ -0,0 +1,37 @@
+#ifndef Features_h
+#define Features_h
+
+#define FT_ENABLED(feature) feature
+
+// project feature off by default
+#ifndef FT_PROJECT
+#define FT_PROJECT 0
+#endif
+
+// security feature on by default
+#ifndef FT_SECURITY
+#define FT_SECURITY 1
+#endif
+
+// mqtt feature on by default
+#ifndef FT_MQTT
+#define FT_MQTT 1
+#endif
+
+// ntp feature on by default
+#ifndef FT_NTP
+#define FT_NTP 1
+#endif
+
+// mqtt feature on by default
+#ifndef FT_OTA
+#define FT_OTA 1
+#endif
+
+// upload firmware feature off by default
+#ifndef FT_UPLOAD_FIRMWARE
+#define FT_UPLOAD_FIRMWARE 0
+#endif
+
+
+#endif
diff --git a/lib/framework/FeaturesService.cpp b/lib/framework/FeaturesService.cpp
new file mode 100644
index 0000000..095f1f2
--- /dev/null
+++ b/lib/framework/FeaturesService.cpp
@@ -0,0 +1,42 @@
+#include
+
+FeaturesService::FeaturesService(AsyncWebServer* server) {
+ server->on(FEATURES_SERVICE_PATH, HTTP_GET, std::bind(&FeaturesService::features, this, std::placeholders::_1));
+}
+
+void FeaturesService::features(AsyncWebServerRequest* request) {
+ AsyncJsonResponse* response = new AsyncJsonResponse(false, MAX_FEATURES_SIZE);
+ JsonObject root = response->getRoot();
+#if FT_ENABLED(FT_PROJECT)
+ root["project"] = true;
+#else
+ root["project"] = false;
+#endif
+#if FT_ENABLED(FT_SECURITY)
+ root["security"] = true;
+#else
+ root["security"] = false;
+#endif
+#if FT_ENABLED(FT_MQTT)
+ root["mqtt"] = true;
+#else
+ root["mqtt"] = false;
+#endif
+#if FT_ENABLED(FT_NTP)
+ root["ntp"] = true;
+#else
+ root["ntp"] = false;
+#endif
+#if FT_ENABLED(FT_OTA)
+ root["ota"] = true;
+#else
+ root["ota"] = false;
+#endif
+#if FT_ENABLED(FT_UPLOAD_FIRMWARE)
+ root["upload_firmware"] = true;
+#else
+ root["upload_firmware"] = false;
+#endif
+ response->setLength();
+ request->send(response);
+}
diff --git a/lib/framework/FeaturesService.h b/lib/framework/FeaturesService.h
new file mode 100644
index 0000000..867101e
--- /dev/null
+++ b/lib/framework/FeaturesService.h
@@ -0,0 +1,29 @@
+#ifndef FeaturesService_h
+#define FeaturesService_h
+
+#include
+
+#ifdef ESP32
+#include
+#include
+#elif defined(ESP8266)
+#include
+#include
+#endif
+
+#include
+#include