initial commit of C++ back end and react front end

This commit is contained in:
rjwats@gmail.com
2018-02-26 00:11:31 +00:00
commit 63a639eb22
87 changed files with 14975 additions and 0 deletions

View File

@ -0,0 +1,29 @@
const CompressionPlugin = require("compression-webpack-plugin");
const ManifestPlugin = require('webpack-manifest-plugin');
const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');
const path = require('path');
const fs = require('fs');
module.exports = function override(config, env) {
if (env === "production") {
// rename the ouput file, we need it's path to be short, for SPIFFS
config.output.filename = 'js/[name].[chunkhash:4].js';
// disable sourcemap for production build
config.devtool = false;
// take out the manifest and service worker
config.plugins = config.plugins.filter(plugin => !(plugin instanceof ManifestPlugin));
config.plugins = config.plugins.filter(plugin => !(plugin instanceof SWPrecacheWebpackPlugin));
// add compression plugin, compress javascript, html and css
config.plugins.push(new CompressionPlugin({
asset: "[path].gz[query]",
algorithm: "gzip",
test: /\.(js|html|css)$/,
deleteOriginalAssets: true
}));
}
return config;
}

10960
interface/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
interface/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "fresh",
"version": "0.1.0",
"private": true,
"dependencies": {
"compression-webpack-plugin": "^1.1.8",
"material-ui": "^1.0.0-beta.32",
"material-ui-icons": "^1.0.0-beta.17",
"moment": "^2.20.1",
"prop-types": "^15.6.0",
"react": "^16.2.0",
"react-autosuggest": "^9.3.3",
"react-dom": "^16.2.0",
"react-form-validator-core": "^0.3.0",
"react-material-ui-form-validator": "^2.0.0-beta.4",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"react-scripts": "1.0.17"
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build && rm -rf ../data/www && cp -r build ../data/www",
"test": "react-app-rewired test --env=jsdom",
"eject": "react-scripts eject"
},
"devDependencies": {
"react-app-rewired": "^1.4.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -0,0 +1,12 @@
{
"name":"ESP8266 React",
"icons":[
{
"src":"/app/icon.png",
"sizes":"48x48 72x72 96x96 128x128 256x256"
}
],
"start_url":"/",
"display":"fullscreen",
"orientation":"any"
}

View File

@ -0,0 +1,22 @@
/* Just supporting latin due to size constrains on the esp chip */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/ro-li.w2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src: local('Roboto'), local('Roboto-Regular'), url(../fonts/ro-re.w2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/ro-me.w2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="%PUBLIC_URL%/css/roboto.css">
<link rel="manifest" href="%PUBLIC_URL%/app/manifest.json">
<title>ESP8266 React</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

54
interface/src/App.js Normal file
View File

@ -0,0 +1,54 @@
import React, { Component } from 'react';
import AppRouting from './AppRouting';
import JssProvider from 'react-jss/lib/JssProvider';
import { create } from 'jss';
import Reboot from 'material-ui/Reboot';
import blueGrey from 'material-ui/colors/blueGrey';
import indigo from 'material-ui/colors/indigo';
import orange from 'material-ui/colors/orange';
import red from 'material-ui/colors/red';
import green from 'material-ui/colors/green';
import {
MuiThemeProvider,
createMuiTheme,
createGenerateClassName,
jssPreset,
} from 'material-ui/styles';
// Our theme
const theme = createMuiTheme({
palette: {
primary: indigo,
secondary: blueGrey,
highlight_idle: blueGrey[900],
highlight_warn: orange[500],
highlight_error: red[500],
highlight_success: green[500],
},
});
// JSS instance
const jss = create(jssPreset());
// Class name generator.
const generateClassName = createGenerateClassName();
class App extends Component {
render() {
return (
<JssProvider jss={jss} generateClassName={generateClassName}>
<MuiThemeProvider theme={theme}>
<Reboot />
<AppRouting />
</MuiThemeProvider>
</JssProvider>
)
}
}
export default App

View File

@ -0,0 +1,25 @@
import React, { Component } from 'react';
import { Route, Redirect, Switch } from 'react-router';
// containers
import WiFiConfiguration from './containers/WiFiConfiguration';
import NTPConfiguration from './containers/NTPConfiguration';
import OTAConfiguration from './containers/OTAConfiguration';
import APConfiguration from './containers/APConfiguration';
class AppRouting extends Component {
render() {
return (
<Switch>
<Route exact path="/wifi-configuration" component={WiFiConfiguration} />
<Route exact path="/ap-configuration" component={APConfiguration} />
<Route exact path="/ntp-configuration" component={NTPConfiguration} />
<Route exact path="/ota-configuration" component={OTAConfiguration} />
<Redirect to="/wifi-configuration" />
</Switch>
)
}
}
export default AppRouting;

View File

@ -0,0 +1,189 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from 'material-ui/styles';
import Drawer from 'material-ui/Drawer';
import AppBar from 'material-ui/AppBar';
import Toolbar from 'material-ui/Toolbar';
import Typography from 'material-ui/Typography';
import IconButton from 'material-ui/IconButton';
import Hidden from 'material-ui/Hidden';
import Divider from 'material-ui/Divider';
import { Link } from 'react-router-dom';
import List, { ListItem, ListItemIcon, ListItemText } from 'material-ui/List';
import MenuIcon from 'material-ui-icons/Menu';
import WifiIcon from 'material-ui-icons/Wifi';
import SystemUpdateIcon from 'material-ui-icons/SystemUpdate';
import AccessTimeIcon from 'material-ui-icons/AccessTime';
import SettingsInputAntennaIcon from 'material-ui-icons/SettingsInputAntenna';
const drawerWidth = 250;
const styles = theme => ({
root: {
zIndex: 1,
width: '100%',
height: '100%',
},
toolbar: {
paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit,
[theme.breakpoints.up('md')]: {
paddingLeft: theme.spacing.unit * 3,
paddingRight: theme.spacing.unit * 3,
}
},
appFrame: {
position: 'relative',
display: 'flex',
width: '100%',
height: '100%',
},
appBar: {
position: 'absolute',
marginLeft: drawerWidth,
[theme.breakpoints.up('md')]: {
width: `calc(100% - ${drawerWidth}px)`,
},
},
navIconHide: {
[theme.breakpoints.up('md')]: {
display: 'none',
},
},
drawerPaper: {
width: drawerWidth,
height: '100%',
[theme.breakpoints.up('md')]: {
width: drawerWidth,
position:'fixed',
left:0,
top:0,
overflow:'auto'
},
},
content: {
backgroundColor: theme.palette.background.default,
width:"100%",
marginTop: 56,
[theme.breakpoints.up('md')]: {
paddingLeft: drawerWidth
},
[theme.breakpoints.up('sm')]: {
height: 'calc(100% - 64px)',
marginTop: 64,
},
},
});
class MenuAppBar extends React.Component {
state = {
mobileOpen: false,
};
handleDrawerToggle = () => {
this.setState({ mobileOpen: !this.state.mobileOpen });
};
render() {
const { classes, theme, children, sectionTitle } = this.props;
const drawer = (
<div>
<Toolbar>
<Typography variant="title" color="primary">
ESP8266 React
</Typography>
<Divider absolute />
</Toolbar>
<Divider />
<List>
<ListItem button component={Link} to='/wifi-configuration'>
<ListItemIcon>
<WifiIcon />
</ListItemIcon>
<ListItemText primary="WiFi Configuration" />
</ListItem>
<ListItem button component={Link} to='/ap-configuration'>
<ListItemIcon>
<SettingsInputAntennaIcon />
</ListItemIcon>
<ListItemText primary="AP Configuration" />
</ListItem>
<ListItem button component={Link} to='/ntp-configuration'>
<ListItemIcon>
<AccessTimeIcon />
</ListItemIcon>
<ListItemText primary="NTP Configuration" />
</ListItem>
<ListItem button component={Link} to='/ota-configuration'>
<ListItemIcon>
<SystemUpdateIcon />
</ListItemIcon>
<ListItemText primary="OTA Configuration" />
</ListItem>
</List>
</div>
);
return (
<div className={classes.root}>
<div className={classes.appFrame}>
<AppBar className={classes.appBar}>
<Toolbar className={classes.toolbar} disableGutters={true}>
<IconButton
color="inherit"
aria-label="open drawer"
onClick={this.handleDrawerToggle}
className={classes.navIconHide}
>
<MenuIcon />
</IconButton>
<Typography variant="title" color="inherit" noWrap>
{sectionTitle}
</Typography>
</Toolbar>
</AppBar>
<Hidden mdUp>
<Drawer
variant="temporary"
anchor={theme.direction === 'rtl' ? 'right' : 'left'}
open={this.state.mobileOpen}
classes={{
paper: classes.drawerPaper,
}}
onClose={this.handleDrawerToggle}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
>
{drawer}
</Drawer>
</Hidden>
<Hidden smDown implementation="css">
<Drawer
variant="permanent"
open
classes={{
paper: classes.drawerPaper,
}}
>
{drawer}
</Drawer>
</Hidden>
<main className={classes.content}>
{children}
</main>
</div>
</div>
);
}
}
MenuAppBar.propTypes = {
classes: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
sectionTitle: PropTypes.string.isRequired,
};
export default withStyles(styles, { withTheme: true })(MenuAppBar);

View File

@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import Paper from 'material-ui/Paper';
import { withStyles } from 'material-ui/styles';
import Typography from 'material-ui/Typography';
const styles = theme => ({
content: {
padding: theme.spacing.unit * 2,
margin: theme.spacing.unit * 2,
}
});
function SectionContent(props) {
const { children, classes, title } = props;
return (
<Paper className={classes.content}>
<Typography variant="display1">
{title}
</Typography>
{children}
</Paper>
);
}
SectionContent.propTypes = {
classes: PropTypes.object.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired,
title: PropTypes.string.isRequired
};
export default withStyles(styles)(SectionContent);

View File

@ -0,0 +1,74 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from 'material-ui/styles';
import Snackbar from 'material-ui/Snackbar';
import IconButton from 'material-ui/IconButton';
import CloseIcon from 'material-ui-icons/Close';
const styles = theme => ({
close: {
width: theme.spacing.unit * 4,
height: theme.spacing.unit * 4,
},
});
class SnackbarNotification extends React.Component {
state = {
open: false,
message: null
};
raiseNotification = (message) => {
this.setState({ open: true, message:message });
};
handleClose = (event, reason) => {
if (reason === 'clickaway') {
return;
}
this.setState({ open: false });
};
componentWillReceiveProps(nextProps){
if (nextProps.notificationRef){
nextProps.notificationRef(this.raiseNotification);
}
}
render() {
const { classes } = this.props;
return (
<Snackbar
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
open={this.state.open}
autoHideDuration={6000}
onClose={this.handleClose}
SnackbarContentProps={{
'aria-describedby': 'message-id',
}}
message={<span id="message-id">{this.state.message}</span>}
action={
<IconButton
key="close"
aria-label="Close"
color="inherit"
className={classes.close}
onClick={this.handleClose}
>
<CloseIcon />
</IconButton>
}
/>
);
}
}
SnackbarNotification.propTypes = {
classes: PropTypes.object.isRequired,
notificationRef: PropTypes.func.isRequired,
};
export default withStyles(styles)(SnackbarNotification);

View File

@ -0,0 +1,28 @@
export const ENDPOINT_ROOT = "";
export const NTP_STATUS_PATH = "/ntpStatus";
export const NTP_STATUS_ENDPOINT = ENDPOINT_ROOT + NTP_STATUS_PATH;
export const NTP_SETTINGS_PATH = "/ntpSettings";
export const NTP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + NTP_SETTINGS_PATH;
export const AP_SETTINGS_PATH = "/apSettings";
export const AP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + AP_SETTINGS_PATH;
export const AP_STATUS_PATH = "/apStatus";
export const AP_STATUS_ENDPOINT = ENDPOINT_ROOT + AP_STATUS_PATH;
export const SCAN_NETWORKS_PATH = "/scanNetworks";
export const SCAN_NETWORKS_ENDPOINT = ENDPOINT_ROOT + SCAN_NETWORKS_PATH;
export const LIST_NETWORKS_PATH = "/listNetworks";
export const LIST_NETWORKS_ENDPOINT = ENDPOINT_ROOT + LIST_NETWORKS_PATH;
export const WIFI_SETTINGS_PATH = "/wifiSettings";
export const WIFI_SETTINGS_ENDPOINT = ENDPOINT_ROOT + WIFI_SETTINGS_PATH;
export const WIFI_STATUS_PATH = "/wifiStatus";
export const WIFI_STATUS_ENDPOINT = ENDPOINT_ROOT + WIFI_STATUS_PATH;
export const OTA_SETTINGS_PATH = "/otaSettings";
export const OTA_SETTINGS_ENDPOINT = ENDPOINT_ROOT + OTA_SETTINGS_PATH;

View File

@ -0,0 +1,4 @@
export const IDLE = "idle";
export const SUCCESS = "success";
export const ERROR = "error";
export const WARN = "warn";

View File

@ -0,0 +1,32 @@
import * as Highlight from '../constants/Highlight';
export const NTP_TIME_NOT_SET = 0;
export const NTP_TIME_NEEDS_SYNC = 1;
export const NTP_TIME_SET = 2;
export const isSynchronized = ntpStatus => ntpStatus && (ntpStatus.status === NTP_TIME_NEEDS_SYNC || ntpStatus.status === NTP_TIME_SET);
export const ntpStatusHighlight = ntpStatus => {
switch (ntpStatus.status){
case NTP_TIME_SET:
return Highlight.SUCCESS;
case NTP_TIME_NEEDS_SYNC:
return Highlight.WARN;
case NTP_TIME_NOT_SET:
default:
return Highlight.ERROR;
}
}
export const ntpStatus = ntpStatus => {
switch (ntpStatus.status){
case NTP_TIME_SET:
return "Synchronized";
case NTP_TIME_NEEDS_SYNC:
return "Synchronization required";
case NTP_TIME_NOT_SET:
return "Time not set"
default:
return "Unknown";
}
}

View File

@ -0,0 +1,4 @@
import moment from 'moment';
export const TIME_AND_DATE = 'DD/MM/YYYY HH:mm:ss';
export const unixTimeToTimeAndDate = unixTime => moment.unix(unixTime).format(TIME_AND_DATE);

View File

@ -0,0 +1,5 @@
export const WIFI_AP_MODE_ALWAYS = 0;
export const WIFI_AP_MODE_DISCONNECTED = 1;
export const WIFI_AP_NEVER = 2;
export const isAPEnabled = apMode => apMode === WIFI_AP_MODE_ALWAYS || apMode === WIFI_AP_MODE_DISCONNECTED;

View File

@ -0,0 +1,44 @@
import * as Highlight from '../constants/Highlight';
export const WIFI_STATUS_IDLE = 0;
export const WIFI_STATUS_NO_SSID_AVAIL = 1;
export const WIFI_STATUS_CONNECTED = 3;
export const WIFI_STATUS_CONNECT_FAILED = 4;
export const WIFI_STATUS_CONNECTION_LOST = 5;
export const WIFI_STATUS_DISCONNECTED = 6;
export const isConnected = wifiStatus => wifiStatus && wifiStatus.status === WIFI_STATUS_CONNECTED;
export const connectionStatusHighlight = wifiStatus => {
switch (wifiStatus.status){
case WIFI_STATUS_IDLE:
case WIFI_STATUS_DISCONNECTED:
return Highlight.IDLE;
case WIFI_STATUS_CONNECTED:
return Highlight.SUCCESS;
case WIFI_STATUS_CONNECT_FAILED:
case WIFI_STATUS_CONNECTION_LOST:
return Highlight.ERROR;
default:
return Highlight.WARN;
}
}
export const connectionStatus = wifiStatus => {
switch (wifiStatus.status){
case WIFI_STATUS_IDLE:
return "Idle";
case WIFI_STATUS_NO_SSID_AVAIL:
return "No SSID Available";
case WIFI_STATUS_CONNECTED:
return "Connected";
case WIFI_STATUS_CONNECT_FAILED:
return "Connection Failed";
case WIFI_STATUS_CONNECTION_LOST:
return "Connection Lost";
case WIFI_STATUS_DISCONNECTED:
return "Disconnected";
default:
return "Unknown";
}
}

View File

@ -0,0 +1,23 @@
export const WIFI_SECURITY_WPA_TKIP = 2;
export const WIFI_SECURITY_WEP = 5;
export const WIFI_SECURITY_WPA_CCMP = 4;
export const WIFI_SECURITY_NONE = 7;
export const WIFI_SECURITY_AUTO = 8;
export const isNetworkOpen = selectedNetwork => selectedNetwork && selectedNetwork.encryption_type === WIFI_SECURITY_NONE;
export const networkSecurityMode = selectedNetwork => {
switch (selectedNetwork.encryption_type){
case WIFI_SECURITY_WPA_TKIP:
case WIFI_SECURITY_WPA_CCMP:
return "WPA";
case WIFI_SECURITY_WEP:
return "WEP";
case WIFI_SECURITY_AUTO:
return "Auto";
case WIFI_SECURITY_NONE:
return "None";
default:
return "Unknown";
}
}

View File

@ -0,0 +1,48 @@
import React, { Component } from 'react';
import Tabs, { Tab } from 'material-ui/Tabs';
import MenuAppBar from '../components/MenuAppBar';
import APSettings from './APSettings';
import APStatus from './APStatus';
class APConfiguration extends Component {
constructor(props) {
super(props);
this.state = {
selectedTab: "apStatus",
selectedNetwork: null
};
this.selectNetwork = this.selectNetwork.bind(this);
this.deselectNetwork = this.deselectNetwork.bind(this);
}
selectNetwork(network) {
this.setState({ selectedTab: "wifiSettings", selectedNetwork:network });
}
deselectNetwork(network) {
this.setState({ selectedNetwork:null });
}
handleTabChange = (event, selectedTab) => {
this.setState({ selectedTab });
};
render() {
const { selectedTab } = this.state;
return (
<MenuAppBar sectionTitle="AP Configuration">
<Tabs value={selectedTab} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" fullWidth centered scrollable>
<Tab value="apStatus" label="AP Status" />
<Tab value="apSettings" label="AP Settings" />
</Tabs>
{selectedTab === "apStatus" && <APStatus fullDetails={true} />}
{selectedTab === "apSettings" && <APSettings />}
</MenuAppBar>
)
}
}
export default APConfiguration;

View File

@ -0,0 +1,70 @@
import React, { Component } from 'react';
import { AP_SETTINGS_ENDPOINT } from '../constants/Endpoints';
import SectionContent from '../components/SectionContent';
import SnackbarNotification from '../components/SnackbarNotification';
import APSettingsForm from '../forms/APSettingsForm';
import { simpleGet } from '../helpers/SimpleGet';
import { simplePost } from '../helpers/SimplePost';
class APSettings extends Component {
constructor(props) {
super(props);
this.state = {
apSettings:null,
apSettingsFetched: false,
errorMessage:null
};
this.setState = this.setState.bind(this);
this.loadAPSettings = this.loadAPSettings.bind(this);
this.saveAPSettings = this.saveAPSettings.bind(this);
}
componentDidMount() {
this.loadAPSettings();
}
loadAPSettings() {
simpleGet(
AP_SETTINGS_ENDPOINT,
this.setState,
this.raiseNotification,
"apSettings",
"apSettingsFetched"
);
}
saveAPSettings(e) {
simplePost(
AP_SETTINGS_ENDPOINT,
this.state,
this.setState,
this.raiseNotification,
"apSettings",
"apSettingsFetched"
);
}
wifiSettingValueChange = name => event => {
const { apSettings } = this.state;
apSettings[name] = event.target.value;
this.setState({apSettings});
};
render() {
const { apSettingsFetched, apSettings, errorMessage } = this.state;
return (
<SectionContent title="AP Settings">
<SnackbarNotification notificationRef={(raiseNotification)=>this.raiseNotification = raiseNotification} />
<APSettingsForm apSettingsFetched={apSettingsFetched} apSettings={apSettings} errorMessage={errorMessage}
onSubmit={this.saveAPSettings} onReset={this.loadAPSettings} handleValueChange={this.wifiSettingValueChange} />
</SectionContent>
)
}
}
export default APSettings;

View File

@ -0,0 +1,165 @@
import React, { Component } from 'react';
import { withStyles } from 'material-ui/styles';
import Button from 'material-ui/Button';
import { LinearProgress } from 'material-ui/Progress';
import Typography from 'material-ui/Typography';
import List, { ListItem, ListItemText } from 'material-ui/List';
import Avatar from 'material-ui/Avatar';
import Divider from 'material-ui/Divider';
import SettingsInputAntennaIcon from 'material-ui-icons/SettingsInputAntenna';
import DeviceHubIcon from 'material-ui-icons/DeviceHub';
import ComputerIcon from 'material-ui-icons/Computer';
import SnackbarNotification from '../components/SnackbarNotification'
import SectionContent from '../components/SectionContent'
import * as Highlight from '../constants/Highlight';
import { AP_STATUS_ENDPOINT } from '../constants/Endpoints';
import { simpleGet } from '../helpers/SimpleGet';
const styles = theme => ({
["apStatus_" + Highlight.SUCCESS]: {
backgroundColor: theme.palette.highlight_success
},
["apStatus_" + Highlight.IDLE]: {
backgroundColor: theme.palette.highlight_idle
},
fetching: {
margin: theme.spacing.unit * 4,
textAlign: "center"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
}
});
class APStatus extends Component {
constructor(props) {
super(props);
this.state = {
status:null,
fetched: false,
errorMessage:null
};
this.setState = this.setState.bind(this);
this.loadAPStatus = this.loadAPStatus.bind(this);
this.raiseNotification=this.raiseNotification.bind(this);
}
componentDidMount() {
this.loadAPStatus();
}
loadAPStatus() {
simpleGet(
AP_STATUS_ENDPOINT,
this.setState,
this.raiseNotification
);
}
raiseNotification(errorMessage) {
this.snackbarNotification(errorMessage);
}
apStatusHighlight(status){
return status.active ? Highlight.SUCCESS : Highlight.IDLE;
}
apStatus(status){
return status.active ? "Active" : "Inactive";
}
// active, ip_address, mac_address, station_num
renderAPStatus(status, fullDetails, classes){
const listItems = [];
listItems.push(
<ListItem key="ap_status">
<Avatar className={classes["apStatus_" + this.apStatusHighlight(status)]}>
<SettingsInputAntennaIcon />
</Avatar>
<ListItemText primary="Status" secondary={this.apStatus(status)} />
</ListItem>
);
listItems.push(<Divider key="ap_status_divider" inset component="li" />);
listItems.push(
<ListItem key="ip_address">
<Avatar>IP</Avatar>
<ListItemText primary="IP Address" secondary={status.ip_address} />
</ListItem>
);
listItems.push(<Divider key="ip_address_divider" inset component="li" />);
listItems.push(
<ListItem key="mac_address">
<Avatar>
<DeviceHubIcon />
</Avatar>
<ListItemText primary="MAC Address" secondary={status.mac_address} />
</ListItem>
);
listItems.push(<Divider key="mac_address_divider" inset component="li" />);
listItems.push(
<ListItem key="station_num">
<Avatar>
<ComputerIcon />
</Avatar>
<ListItemText primary="AP Clients" secondary={status.station_num} />
</ListItem>
);
listItems.push(<Divider key="mac_address_divider" inset component="li" />);
return (
<div>
<List>
{listItems}
</List>
<Button variant="raised" color="secondary" className={classes.button} onClick={this.loadAPStatus}>
Refresh
</Button>
</div>
);
}
render() {
const { status, fetched, errorMessage } = this.state;
const { classes, fullDetails } = this.props;
return (
<SectionContent title="AP Status">
<SnackbarNotification notificationRef={(snackbarNotification)=>this.snackbarNotification = snackbarNotification} />
{
!fetched ?
<div>
<LinearProgress className={classes.fetching}/>
<Typography variant="display1" className={classes.fetching}>
Loading...
</Typography>
</div>
:
status ? this.renderAPStatus(status, fullDetails, classes)
:
<div>
<Typography variant="display1" className={classes.fetching}>
{errorMessage}
</Typography>
<Button variant="raised" color="secondary" className={classes.button} onClick={this.loadAPStatus}>
Refresh
</Button>
</div>
}
</SectionContent>
)
}
}
export default withStyles(styles)(APStatus);

View File

@ -0,0 +1,37 @@
import React, { Component } from 'react';
import MenuAppBar from '../components/MenuAppBar';
import NTPSettings from './NTPSettings';
import NTPStatus from './NTPStatus';
import Tabs, { Tab } from 'material-ui/Tabs';
class NTPConfiguration extends Component {
constructor(props) {
super(props);
this.state = {
selectedTab: "ntpStatus"
};
}
handleTabChange = (event, selectedTab) => {
this.setState({ selectedTab });
};
render() {
const { selectedTab } = this.state;
return (
<MenuAppBar sectionTitle="NTP Configuration">
<Tabs value={selectedTab} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" fullWidth centered scrollable>
<Tab value="ntpStatus" label="NTP Status" />
<Tab value="ntpSettings" label="NTP Settings" />
</Tabs>
{selectedTab === "ntpStatus" && <NTPStatus />}
{selectedTab === "ntpSettings" && <NTPSettings />}
</MenuAppBar>
)
}
}
export default NTPConfiguration

View File

@ -0,0 +1,70 @@
import React, { Component } from 'react';
import { NTP_SETTINGS_ENDPOINT } from '../constants/Endpoints';
import SectionContent from '../components/SectionContent';
import SnackbarNotification from '../components/SnackbarNotification';
import NTPSettingsForm from '../forms/NTPSettingsForm';
import { simpleGet } from '../helpers/SimpleGet';
import { simplePost } from '../helpers/SimplePost';
class NTPSettings extends Component {
constructor(props) {
super(props);
this.state = {
ntpSettings:{},
ntpSettingsFetched: false,
errorMessage:null
};
this.setState = this.setState.bind(this);
this.loadNTPSettings = this.loadNTPSettings.bind(this);
this.saveNTPSettings = this.saveNTPSettings.bind(this);
}
componentDidMount() {
this.loadNTPSettings();
}
loadNTPSettings() {
simpleGet(
NTP_SETTINGS_ENDPOINT,
this.setState,
this.raiseNotification,
"ntpSettings",
"ntpSettingsFetched"
);
}
saveNTPSettings(e) {
simplePost(
NTP_SETTINGS_ENDPOINT,
this.state,
this.setState,
this.raiseNotification,
"ntpSettings",
"ntpSettingsFetched"
);
}
ntpSettingValueChange = name => event => {
const { ntpSettings } = this.state;
ntpSettings[name] = event.target.value;
this.setState({ntpSettings});
};
render() {
const { ntpSettingsFetched, ntpSettings, errorMessage } = this.state;
return (
<SectionContent title="NTP Settings">
<SnackbarNotification notificationRef={(raiseNotification)=>this.raiseNotification = raiseNotification} />
<NTPSettingsForm ntpSettingsFetched={ntpSettingsFetched} ntpSettings={ntpSettings} errorMessage={errorMessage}
onSubmit={this.saveNTPSettings} onReset={this.loadNTPSettings} handleValueChange={this.ntpSettingValueChange} />
</SectionContent>
)
}
}
export default NTPSettings;

View File

@ -0,0 +1,189 @@
import React, { Component } from 'react';
import { withStyles } from 'material-ui/styles';
import Button from 'material-ui/Button';
import { LinearProgress } from 'material-ui/Progress';
import Typography from 'material-ui/Typography';
import List, { ListItem, ListItemText } from 'material-ui/List';
import Avatar from 'material-ui/Avatar';
import Divider from 'material-ui/Divider';
import SwapVerticalCircleIcon from 'material-ui-icons/SwapVerticalCircle';
import AccessTimeIcon from 'material-ui-icons/AccessTime';
import DNSIcon from 'material-ui-icons/Dns';
import TimerIcon from 'material-ui-icons/Timer';
import UpdateIcon from 'material-ui-icons/Update';
import AvTimerIcon from 'material-ui-icons/AvTimer';
import { isSynchronized, ntpStatusHighlight, ntpStatus } from '../constants/NTPStatus';
import * as Highlight from '../constants/Highlight';
import { unixTimeToTimeAndDate } from '../constants/TimeFormat';
import { NTP_STATUS_ENDPOINT } from '../constants/Endpoints';
import SnackbarNotification from '../components/SnackbarNotification';
import SectionContent from '../components/SectionContent';
import { simpleGet } from '../helpers/SimpleGet';
import moment from 'moment';
const styles = theme => ({
["ntpStatus_" + Highlight.SUCCESS]: {
backgroundColor: theme.palette.highlight_success
},
["ntpStatus_" + Highlight.ERROR]: {
backgroundColor: theme.palette.highlight_error
},
["ntpStatus_" + Highlight.WARN]: {
backgroundColor: theme.palette.highlight_warn
},
fetching: {
margin: theme.spacing.unit * 4,
textAlign: "center"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
}
});
class NTPStatus extends Component {
constructor(props) {
super(props);
this.state = {
status:null,
fetched: false,
errorMessage:null
};
this.setState = this.setState.bind(this);
this.loadNTPStatus = this.loadNTPStatus.bind(this);
this.raiseNotification=this.raiseNotification.bind(this);
}
componentDidMount() {
this.loadNTPStatus();
}
loadNTPStatus() {
simpleGet(
NTP_STATUS_ENDPOINT,
this.setState,
this.raiseNotification
);
}
raiseNotification(errorMessage) {
this.snackbarNotification(errorMessage);
}
renderNTPStatus(status, fullDetails, classes){
const listItems = [];
listItems.push(
<ListItem key="ntp_status">
<Avatar className={classes["ntpStatus_" + ntpStatusHighlight(status)]}>
<UpdateIcon />
</Avatar>
<ListItemText primary="Status" secondary={ntpStatus(status)} />
</ListItem>
);
listItems.push(<Divider key="ntp_status_divider" inset component="li" />);
if (isSynchronized(status)) {
listItems.push(
<ListItem key="time_now">
<Avatar>
<AccessTimeIcon />
</Avatar>
<ListItemText primary="Time Now" secondary={unixTimeToTimeAndDate(status.now)} />
</ListItem>
);
listItems.push(<Divider key="time_now_divider" inset component="li" />);
}
listItems.push(
<ListItem key="last_sync">
<Avatar>
<SwapVerticalCircleIcon />
</Avatar>
<ListItemText primary="Last Sync" secondary={status.last_sync > 0 ? unixTimeToTimeAndDate(status.last_sync) : "never" } />
</ListItem>
);
listItems.push(<Divider key="last_sync_divider" inset component="li" />);
listItems.push(
<ListItem key="ntp_server">
<Avatar>
<DNSIcon />
</Avatar>
<ListItemText primary="NTP Server" secondary={status.server} />
</ListItem>
);
listItems.push(<Divider key="ntp_server_divider" inset component="li" />);
listItems.push(
<ListItem key="sync_interval">
<Avatar>
<TimerIcon />
</Avatar>
<ListItemText primary="Sync Interval" secondary={moment.duration(status.interval, 'seconds').humanize()} />
</ListItem>
);
listItems.push(<Divider key="sync_interval_divider" inset component="li" />);
listItems.push(
<ListItem key="uptime">
<Avatar>
<AvTimerIcon />
</Avatar>
<ListItemText primary="Uptime" secondary={moment.duration(status.uptime, 'seconds').humanize()} />
</ListItem>
);
return (
<div>
<List>
{listItems}
</List>
<Button variant="raised" color="secondary" className={classes.button} onClick={this.loadNTPStatus}>
Refresh
</Button>
</div>
);
}
render() {
const { status, fetched, errorMessage } = this.state;
const { classes, fullDetails } = this.props;
return (
<SectionContent title="NTP Status">
<SnackbarNotification notificationRef={(snackbarNotification)=>this.snackbarNotification = snackbarNotification} />
{
!fetched ?
<div>
<LinearProgress className={classes.fetching}/>
<Typography variant="display1" className={classes.fetching}>
Loading...
</Typography>
</div>
:
status ? this.renderNTPStatus(status, fullDetails, classes)
:
<div>
<Typography variant="display1" className={classes.fetching}>
{errorMessage}
</Typography>
<Button variant="raised" color="secondary" className={classes.button} onClick={this.loadNTPStatus}>
Refresh
</Button>
</div>
}
</SectionContent>
)
}
}
export default withStyles(styles)(NTPStatus);

View File

@ -0,0 +1,15 @@
import React, { Component } from 'react';
import MenuAppBar from '../components/MenuAppBar';
import OTASettings from './OTASettings';
class OTAConfiguration extends Component {
render() {
return (
<MenuAppBar sectionTitle="OTA Configuration">
<OTASettings />
</MenuAppBar>
)
}
}
export default OTAConfiguration

View File

@ -0,0 +1,77 @@
import React, { Component } from 'react';
import { OTA_SETTINGS_ENDPOINT } from '../constants/Endpoints';
import SectionContent from '../components/SectionContent';
import SnackbarNotification from '../components/SnackbarNotification';
import OTASettingsForm from '../forms/OTASettingsForm';
import { simpleGet } from '../helpers/SimpleGet';
import { simplePost } from '../helpers/SimplePost';
class OTASettings extends Component {
constructor(props) {
super(props);
this.state = {
otaSettings:null,
otaSettingsFetched: false,
errorMessage:null
};
this.setState = this.setState.bind(this);
this.loadOTASettings = this.loadOTASettings.bind(this);
this.saveOTASettings = this.saveOTASettings.bind(this);
}
componentDidMount() {
this.loadOTASettings();
}
loadOTASettings() {
simpleGet(
OTA_SETTINGS_ENDPOINT,
this.setState,
this.raiseNotification,
"otaSettings",
"otaSettingsFetched"
);
}
saveOTASettings(e) {
simplePost(
OTA_SETTINGS_ENDPOINT,
this.state,
this.setState,
this.raiseNotification,
"otaSettings",
"otaSettingsFetched"
);
}
otaSettingValueChange = name => event => {
const { otaSettings } = this.state;
otaSettings[name] = event.target.value;
this.setState({otaSettings});
};
otaSettingCheckboxChange = name => event => {
const { otaSettings } = this.state;
otaSettings[name] = event.target.checked;
this.setState({otaSettings});
}
render() {
const { otaSettingsFetched, otaSettings, errorMessage } = this.state;
return (
<SectionContent title="OTA Settings">
<SnackbarNotification notificationRef={(raiseNotification)=>this.raiseNotification = raiseNotification} />
<OTASettingsForm otaSettingsFetched={otaSettingsFetched} otaSettings={otaSettings} errorMessage={errorMessage}
onSubmit={this.saveOTASettings} onReset={this.loadOTASettings} handleValueChange={this.otaSettingValueChange}
handleCheckboxChange={this.otaSettingCheckboxChange} />
</SectionContent>
)
}
}
export default OTASettings;

View File

@ -0,0 +1,53 @@
import React, { Component } from 'react';
import Tabs, { Tab } from 'material-ui/Tabs';
import MenuAppBar from '../components/MenuAppBar';
import WiFiNetworkScanner from './WiFiNetworkScanner';
import WiFiSettings from './WiFiSettings';
import WiFiStatus from './WiFiStatus';
class WiFiConfiguration extends Component {
constructor(props) {
super(props);
this.state = {
selectedTab: "wifiStatus",
selectedNetwork: null
};
this.selectNetwork = this.selectNetwork.bind(this);
this.deselectNetwork = this.deselectNetwork.bind(this);
}
// TODO - slightly inapproperate use of callback ref possibly.
selectNetwork(network) {
this.setState({ selectedTab: "wifiSettings", selectedNetwork:network });
}
// deselects the network after the settings component mounts.
deselectNetwork(network) {
this.setState({ selectedNetwork:null });
}
handleTabChange = (event, selectedTab) => {
this.setState({ selectedTab });
};
render() {
const { selectedTab } = this.state;
return (
<MenuAppBar sectionTitle="WiFi Configuration">
<Tabs value={selectedTab} onChange={this.handleTabChange} indicatorColor="primary" textColor="primary" fullWidth centered scrollable>
<Tab value="wifiStatus" label="WiFi Status" />
<Tab value="networkScanner" label="Network Scanner" />
<Tab value="wifiSettings" label="WiFi Settings" />
</Tabs>
{selectedTab === "wifiStatus" && <WiFiStatus fullDetails={true} />}
{selectedTab === "networkScanner" && <WiFiNetworkScanner selectNetwork={this.selectNetwork} />}
{selectedTab === "wifiSettings" && <WiFiSettings deselectNetwork={this.deselectNetwork} selectedNetwork={this.state.selectedNetwork} />}
</MenuAppBar>
)
}
}
export default WiFiConfiguration;

View File

@ -0,0 +1,118 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { SCAN_NETWORKS_ENDPOINT, LIST_NETWORKS_ENDPOINT } from '../constants/Endpoints';
import SectionContent from '../components/SectionContent';
import WiFiNetworkSelector from '../forms/WiFiNetworkSelector';
const NUM_POLLS = 10
const POLLING_FREQUENCY = 500
const RETRY_EXCEPTION_TYPE = "retry"
class WiFiNetworkScanner extends Component {
constructor(props) {
super(props);
this.pollCount = 0;
this.state = {
scanningForNetworks: true,
errorMessage:null,
networkList: null
};
this.pollNetworkList = this.pollNetworkList.bind(this);
this.requestNetworkScan = this.requestNetworkScan.bind(this);
}
componentDidMount() {
this.scanNetworks();
}
requestNetworkScan() {
const { scanningForNetworks } = this.state;
if (!scanningForNetworks) {
this.scanNetworks();
}
}
scanNetworks() {
this.pollCount = 0;
this.setState({scanningForNetworks:true, networkList: null, errorMessage:null});
fetch(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.setState({scanningForNetworks:false, networkList: null, 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,network2) {
if (network1.rssi < network2.rssi)
return 1;
if (network1.rssi > network2.rssi)
return -1;
return 0;
}
pollNetworkList() {
fetch(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 timley manner.");
}
}
throw Error("Device returned unexpected response code: " + response.status);
})
.then(json => {
json.networks.sort(this.compareNetworks)
this.setState({scanningForNetworks:false, networkList: json, errorMessage:null})
})
.catch(error => {
console.log(error.message);
if (error.name !== RETRY_EXCEPTION_TYPE) {
this.setState({scanningForNetworks:false, networkList: null, errorMessage:error.message});
}
});
}
render() {
const { scanningForNetworks, networkList, errorMessage } = this.state;
return (
<SectionContent title="Network Scanner">
<WiFiNetworkSelector scanningForNetworks={scanningForNetworks}
networkList={networkList}
errorMessage={errorMessage}
requestNetworkScan={this.requestNetworkScan}
selectNetwork={this.props.selectNetwork}
/>
</SectionContent>
)
}
}
WiFiNetworkScanner.propTypes = {
selectNetwork: PropTypes.func.isRequired
};
export default (WiFiNetworkScanner);

View File

@ -0,0 +1,102 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { WIFI_SETTINGS_ENDPOINT } from '../constants/Endpoints';
import SectionContent from '../components/SectionContent';
import SnackbarNotification from '../components/SnackbarNotification';
import WiFiSettingsForm from '../forms/WiFiSettingsForm';
import { simpleGet } from '../helpers/SimpleGet';
import { simplePost } from '../helpers/SimplePost';
class WiFiSettings extends Component {
constructor(props) {
super(props);
this.state = {
wifiSettingsFetched: false,
wifiSettings:{},
selectedNetwork: null,
errorMessage:null
};
this.setState = this.setState.bind(this);
this.loadWiFiSettings = this.loadWiFiSettings.bind(this);
this.saveWiFiSettings = this.saveWiFiSettings.bind(this);
this.deselectNetwork = this.deselectNetwork.bind(this);
}
componentDidMount() {
const { selectedNetwork, deselectNetwork } = this.props;
if (selectedNetwork){
var wifiSettings = {
ssid:selectedNetwork.ssid,
password:"",
hostname:"esp8266-react",
static_ip_config:false,
}
this.setState({wifiSettingsFetched:true, wifiSettings, selectedNetwork, errorMessage:null});
deselectNetwork();
}else {
this.loadWiFiSettings();
}
}
loadWiFiSettings() {
this.deselectNetwork();
simpleGet(
WIFI_SETTINGS_ENDPOINT,
this.setState,
this.raiseNotification,
"wifiSettings",
"wifiSettingsFetched"
);
}
saveWiFiSettings(e) {
simplePost(
WIFI_SETTINGS_ENDPOINT,
this.state,
this.setState,
this.raiseNotification,
"wifiSettings",
"wifiSettingsFetched"
);
}
deselectNetwork(nextNetwork) {
this.setState({selectedNetwork:null});
}
wifiSettingValueChange = name => event => {
const { wifiSettings } = this.state;
wifiSettings[name] = event.target.value;
this.setState({wifiSettings});
};
wifiSettingCheckboxChange = name => event => {
const { wifiSettings } = this.state;
wifiSettings[name] = event.target.checked;
this.setState({wifiSettings});
}
render() {
const { wifiSettingsFetched, wifiSettings, errorMessage, selectedNetwork } = this.state;
return (
<SectionContent title="WiFi Settings">
<SnackbarNotification notificationRef={(raiseNotification)=>this.raiseNotification = raiseNotification} />
<WiFiSettingsForm wifiSettingsFetched={wifiSettingsFetched} wifiSettings={wifiSettings} errorMessage={errorMessage} selectedNetwork={selectedNetwork} deselectNetwork={this.deselectNetwork}
onSubmit={this.saveWiFiSettings} onReset={this.loadWiFiSettings} handleValueChange={this.wifiSettingValueChange} handleCheckboxChange={this.wifiSettingCheckboxChange} />
</SectionContent>
)
}
}
WiFiSettings.propTypes = {
deselectNetwork: PropTypes.func,
selectedNetwork: PropTypes.object
};
export default WiFiSettings;

View File

@ -0,0 +1,188 @@
import React, { Component } from 'react';
import { withStyles } from 'material-ui/styles';
import Button from 'material-ui/Button';
import { LinearProgress } from 'material-ui/Progress';
import Typography from 'material-ui/Typography';
import SnackbarNotification from '../components/SnackbarNotification';
import SectionContent from '../components/SectionContent';
import { WIFI_STATUS_ENDPOINT } from '../constants/Endpoints';
import { isConnected, connectionStatus, connectionStatusHighlight } from '../constants/WiFiConnectionStatus';
import * as Highlight from '../constants/Highlight';
import { simpleGet } from '../helpers/SimpleGet';
import List, { ListItem, ListItemText } from 'material-ui/List';
import Avatar from 'material-ui/Avatar';
import Divider from 'material-ui/Divider';
import WifiIcon from 'material-ui-icons/Wifi';
import DNSIcon from 'material-ui-icons/Dns';
import SettingsInputComponentIcon from 'material-ui-icons/SettingsInputComponent';
import SettingsInputAntennaIcon from 'material-ui-icons/SettingsInputAntenna';
const styles = theme => ({
["wifiStatus_" + Highlight.IDLE]: {
backgroundColor: theme.palette.highlight_idle
},
["wifiStatus_" + Highlight.SUCCESS]: {
backgroundColor: theme.palette.highlight_success
},
["wifiStatus_" + Highlight.ERROR]: {
backgroundColor: theme.palette.highlight_error
},
["wifiStatus_" + Highlight.WARN]: {
backgroundColor: theme.palette.highlight_warn
},
fetching: {
margin: theme.spacing.unit * 4,
textAlign: "center"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
}
});
class WiFiStatus extends Component {
constructor(props) {
super(props);
this.state = {
status:null,
fetched: false,
errorMessage:null
};
this.setState = this.setState.bind(this);
this.loadWiFiStatus = this.loadWiFiStatus.bind(this);
}
componentDidMount() {
this.loadWiFiStatus();
}
dnsServers(status) {
if (!status.dns_ip_1){
return "none";
}
return status.dns_ip_1 + (status.dns_ip_2 ? ','+status.dns_ip_2 : '');
}
loadWiFiStatus() {
simpleGet(
WIFI_STATUS_ENDPOINT,
this.setState,
this.raiseNotification
);
}
renderWiFiStatus(status, fullDetails, classes) {
const listItems = [];
listItems.push(
<ListItem key="connection_status">
<Avatar className={classes["wifiStatus_" + connectionStatusHighlight(status)]}>
<WifiIcon />
</Avatar>
<ListItemText primary="Connection Status" secondary={connectionStatus(status)} />
</ListItem>
);
if (fullDetails && isConnected(status)) {
listItems.push(<Divider key="connection_status_divider" inset component="li" />);
listItems.push(
<ListItem key="ssid">
<Avatar>
<SettingsInputAntennaIcon />
</Avatar>
<ListItemText primary="SSID" secondary={status.ssid} />
</ListItem>
);
listItems.push(<Divider key="ssid_divider" inset component="li" />);
listItems.push(
<ListItem key="ip_address">
<Avatar>IP</Avatar>
<ListItemText primary="IP Address" secondary={status.local_ip} />
</ListItem>
);
listItems.push(<Divider key="ip_address_divider" inset component="li" />);
listItems.push(
<ListItem key="subnet_mask">
<Avatar>#</Avatar>
<ListItemText primary="Subnet Mask" secondary={status.subnet_mask} />
</ListItem>
);
listItems.push(<Divider key="subnet_mask_divider" inset component="li" />);
listItems.push(
<ListItem key="gateway_ip">
<Avatar>
<SettingsInputComponentIcon />
</Avatar>
<ListItemText primary="Gateway IP" secondary={status.gateway_ip ? status.gateway_ip : "none"} />
</ListItem>
);
listItems.push(<Divider key="gateway_ip_divider" inset component="li" />);
listItems.push(
<ListItem key="dns_server_ip">
<Avatar>
<DNSIcon />
</Avatar>
<ListItemText primary="DNS Server IP" secondary={this.dnsServers(status)} />
</ListItem>
);
}
return (
<div>
<List>
{listItems}
</List>
<Button variant="raised" color="secondary" className={classes.button} onClick={this.loadWiFiStatus}>
Refresh
</Button>
</div>
);
}
render() {
const { status, fetched, errorMessage } = this.state;
const { classes, fullDetails } = this.props;
return (
<SectionContent title="WiFi Status">
<SnackbarNotification notificationRef={(raiseNotification)=>this.raiseNotification = raiseNotification} />
{
!fetched ?
<div>
<LinearProgress className={classes.fetching}/>
<Typography variant="display1" className={classes.fetching}>
Loading...
</Typography>
</div>
:
status ? this.renderWiFiStatus(status, fullDetails, classes)
:
<div>
<Typography variant="display1" className={classes.fetching}>
{errorMessage}
</Typography>
<Button variant="raised" color="secondary" className={classes.button} onClick={this.loadWiFiStatus}>
Refresh
</Button>
</div>
}
</SectionContent>
)
}
}
export default withStyles(styles)(WiFiStatus);

View File

@ -0,0 +1,124 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from 'material-ui/styles';
import Button from 'material-ui/Button';
import { LinearProgress } from 'material-ui/Progress';
import Typography from 'material-ui/Typography';
import { MenuItem } from 'material-ui/Menu';
import { TextValidator, ValidatorForm, SelectValidator } from 'react-material-ui-form-validator';
import {isAPEnabled} from '../constants/WiFiAPModes';
const styles = theme => ({
loadingSettings: {
margin: theme.spacing.unit,
},
loadingSettingsDetails: {
margin: theme.spacing.unit * 4,
textAlign: "center"
},
textField: {
width: "100%"
},
selectField:{
width: "100%",
marginTop: theme.spacing.unit * 2,
marginBottom: theme.spacing.unit
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
}
});
class APSettingsForm extends React.Component {
render() {
const { classes, apSettingsFetched, apSettings, errorMessage, handleValueChange, onSubmit, onReset } = this.props;
return (
<div>
{
!apSettingsFetched ?
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails}/>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
: apSettings ?
<ValidatorForm onSubmit={onSubmit} ref="APSettingsForm">
<SelectValidator name="provision_mode" label="Provide Access Point..." value={apSettings.provision_mode} className={classes.selectField}
onChange={handleValueChange('provision_mode')}>
<MenuItem value={0}>Always</MenuItem>
<MenuItem value={1}>When WiFi Disconnected</MenuItem>
<MenuItem value={2}>Never</MenuItem>
</SelectValidator>
{
isAPEnabled(apSettings.provision_mode) &&
[
<TextValidator key="ssid"
validators={['required', 'matchRegexp:^.{0,32}$']}
errorMessages={['Access Point SSID is required', 'Access Point SSID must be 32 characeters or less']}
name="ssid"
label="Access Point SSID"
className={classes.textField}
value={apSettings.ssid}
onChange={handleValueChange('ssid')}
margin="normal"
/>,
<TextValidator key="password"
validators={['required', 'matchRegexp:^.{0,64}$']}
errorMessages={['Access Point Password is required', 'Access Point Password must be 64 characters or less']}
name="password"
label="Access Point Password"
className={classes.textField}
value={apSettings.password}
onChange={handleValueChange('password')}
margin="normal"
/>
]
}
<Button variant="raised" color="primary" className={classes.button} type="submit">
Save
</Button>
<Button variant="raised" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</ValidatorForm>
:
<div className={classes.loadingSettings}>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="raised" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</div>
}
</div>
);
}
}
APSettingsForm.propTypes = {
classes: PropTypes.object.isRequired,
apSettingsFetched: PropTypes.bool.isRequired,
apSettings: PropTypes.object,
errorMessage: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
handleValueChange: PropTypes.func.isRequired
};
export default withStyles(styles)(APSettingsForm);

View File

@ -0,0 +1,113 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from 'material-ui/styles';
import Button from 'material-ui/Button';
import { LinearProgress } from 'material-ui/Progress';
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import Typography from 'material-ui/Typography';
import isIP from '../validators/isIP';
import isHostname from '../validators/isHostname';
import or from '../validators/or';
const styles = theme => ({
loadingSettings: {
margin: theme.spacing.unit,
},
loadingSettingsDetails: {
margin: theme.spacing.unit * 4,
textAlign: "center"
},
textField: {
width: "100%"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
}
});
class NTPSettingsForm extends React.Component {
componentWillMount() {
ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
}
render() {
const { classes, ntpSettingsFetched, ntpSettings, errorMessage, handleValueChange, onSubmit, onReset } = this.props;
return (
<div>
{
!ntpSettingsFetched ?
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails}/>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
: ntpSettings ?
<ValidatorForm onSubmit={onSubmit}>
<TextValidator
validators={['required', 'isIPOrHostname']}
errorMessages={['Server is required', "Not a valid IP address or hostname"]}
name="server"
label="Server"
className={classes.textField}
value={ntpSettings.server}
onChange={handleValueChange('server')}
margin="normal"
/>
<TextValidator
validators={['required','isNumber','minNumber:60','maxNumber:86400']}
errorMessages={['Interval is required','Interval must be a number','Must be at least 60 seconds',"Must not be more than 86400 seconds (24 hours)"]}
name="interval"
label="Interval (Seconds)"
className={classes.textField}
value={ntpSettings.interval}
type="number"
onChange={handleValueChange('interval')}
margin="normal"
/>
<Button variant="raised" color="primary" className={classes.button} type="submit">
Save
</Button>
<Button variant="raised" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</ValidatorForm>
:
<div className={classes.loadingSettings}>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="raised" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</div>
}
</div>
);
}
}
NTPSettingsForm.propTypes = {
classes: PropTypes.object.isRequired,
ntpSettingsFetched: PropTypes.bool.isRequired,
ntpSettings: PropTypes.object,
errorMessage: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
handleValueChange: PropTypes.func.isRequired,
};
export default withStyles(styles)(NTPSettingsForm);

View File

@ -0,0 +1,132 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from 'material-ui/styles';
import Button from 'material-ui/Button';
import Switch from 'material-ui/Switch';
import { LinearProgress } from 'material-ui/Progress';
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import Typography from 'material-ui/Typography';
import { FormControlLabel } from 'material-ui/Form';
import isIP from '../validators/isIP';
import isHostname from '../validators/isHostname';
import or from '../validators/or';
const styles = theme => ({
loadingSettings: {
margin: theme.spacing.unit,
},
loadingSettingsDetails: {
margin: theme.spacing.unit * 4,
textAlign: "center"
},
switchControl: {
width: "100%",
marginTop: theme.spacing.unit * 2,
marginBottom: theme.spacing.unit
},
textField: {
width: "100%"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
}
});
class OTASettingsForm extends React.Component {
componentWillMount() {
ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
}
render() {
const { classes, otaSettingsFetched, otaSettings, errorMessage, handleValueChange, handleCheckboxChange, onSubmit, onReset } = this.props;
return (
<div>
{
!otaSettingsFetched ?
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails}/>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
: otaSettings ?
<ValidatorForm onSubmit={onSubmit}>
<FormControlLabel className={classes.switchControl}
control={
<Switch
checked={otaSettings.enabled}
onChange={handleCheckboxChange('enabled')}
value="enabled"
/>
}
label="Enable OTA Updates?"
/>
<TextValidator
validators={['required', 'isNumber', 'minNumber:1025', 'maxNumber:65535']}
errorMessages={['Port is required', "Must be a number", "Must be greater than 1024 ", "Max value is 65535"]}
name="port"
label="Port"
className={classes.textField}
value={otaSettings.port}
type="number"
onChange={handleValueChange('port')}
margin="normal"
/>
<TextValidator key="password"
validators={['required', 'matchRegexp:^.{0,64}$']}
errorMessages={['OTA Password is required', 'OTA Point Password must be 64 characters or less']}
name="password"
label="Password"
className={classes.textField}
value={otaSettings.password}
onChange={handleValueChange('password')}
margin="normal"
/>
<Button variant="raised" color="primary" className={classes.button} type="submit">
Save
</Button>
<Button variant="raised" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</ValidatorForm>
:
<div className={classes.loadingSettings}>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="raised" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</div>
}
</div>
);
}
}
OTASettingsForm.propTypes = {
classes: PropTypes.object.isRequired,
otaSettingsFetched: PropTypes.bool.isRequired,
otaSettings: PropTypes.object,
errorMessage: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
handleValueChange: PropTypes.func.isRequired,
handleCheckboxChange: PropTypes.func.isRequired,
};
export default withStyles(styles)(OTASettingsForm);

View File

@ -0,0 +1,102 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withStyles } from 'material-ui/styles';
import Button from 'material-ui/Button';
import { LinearProgress } from 'material-ui/Progress';
import Typography from 'material-ui/Typography';
import { isNetworkOpen, networkSecurityMode } from '../constants/WiFiSecurityModes';
import List, { ListItem, ListItemText, ListItemIcon, ListItemAvatar } from 'material-ui/List';
import Avatar from 'material-ui/Avatar';
import Badge from 'material-ui/Badge';
import WifiIcon from 'material-ui-icons/Wifi';
import LockIcon from 'material-ui-icons/Lock';
import LockOpenIcon from 'material-ui-icons/LockOpen';
const styles = theme => ({
scanningProgress: {
margin: theme.spacing.unit * 4,
textAlign: "center"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
}
});
class WiFiNetworkSelector extends Component {
constructor(props) {
super(props);
this.renderNetwork = this.renderNetwork.bind(this);
}
renderNetwork(network) {
return ([
<ListItem key={network.ssid} button onClick={() => this.props.selectNetwork(network)}>
<ListItemAvatar>
<Avatar>
{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={"Security: "+ networkSecurityMode(network) + ", Ch: " + network.channel}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + "db"}>
<WifiIcon />
</Badge>
</ListItemIcon>
</ListItem>
]
);
}
render() {
const { classes, scanningForNetworks, networkList, errorMessage, requestNetworkScan } = this.props;
return (
<div>
{
scanningForNetworks ?
<div>
<LinearProgress className={classes.scanningProgress}/>
<Typography variant="display1" className={classes.scanningProgress}>
Scanning...
</Typography>
</div>
:
networkList ?
<List>
{networkList.networks.map(this.renderNetwork)}
</List>
:
<div>
<Typography variant="display1" className={classes.scanningProgress}>
{errorMessage}
</Typography>
</div>
}
<Button variant="raised" color="secondary" className={classes.button} onClick={requestNetworkScan} disabled={scanningForNetworks}>
Scan again...
</Button>
</div>
);
}
}
WiFiNetworkSelector.propTypes = {
classes: PropTypes.object.isRequired,
selectNetwork: PropTypes.func.isRequired,
scanningForNetworks: PropTypes.bool.isRequired,
errorMessage: PropTypes.string,
networkList: PropTypes.object,
requestNetworkScan: PropTypes.func.isRequired
};
export default withStyles(styles)(WiFiNetworkSelector);

View File

@ -0,0 +1,237 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from 'material-ui/styles';
import Button from 'material-ui/Button';
import { LinearProgress } from 'material-ui/Progress';
import Checkbox from 'material-ui/Checkbox';
import { FormControlLabel } from 'material-ui/Form';
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import Typography from 'material-ui/Typography';
import List, { ListItem, ListItemText, ListItemSecondaryAction, ListItemAvatar } from 'material-ui/List';
import { isNetworkOpen, networkSecurityMode } from '../constants/WiFiSecurityModes';
import Avatar from 'material-ui/Avatar';
import IconButton from 'material-ui/IconButton';
import LockIcon from 'material-ui-icons/Lock';
import LockOpenIcon from 'material-ui-icons/LockOpen';
import DeleteIcon from 'material-ui-icons/Delete';
import isIP from '../validators/isIP';
import isHostname from '../validators/isHostname';
import optional from '../validators/optional';
const styles = theme => ({
loadingSettings: {
margin: theme.spacing.unit,
},
loadingSettingsDetails: {
margin: theme.spacing.unit * 4,
textAlign: "center"
},
textField: {
width: "100%"
},
checkboxControl: {
width: "100%"
},
button: {
marginRight: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
}
});
class WiFiSettingsForm extends React.Component {
componentWillMount() {
ValidatorForm.addValidationRule('isIP', isIP);
ValidatorForm.addValidationRule('isHostname', isHostname);
ValidatorForm.addValidationRule('isOptionalIP', optional(isIP));
}
renderSelectedNetwork() {
const { selectedNetwork, deselectNetwork } = this.props;
return (
<List>
<ListItem key={selectedNetwork.ssid}>
<ListItemAvatar>
<Avatar>
{isNetworkOpen(selectedNetwork) ? <LockOpenIcon /> : <LockIcon />}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={selectedNetwork.ssid}
secondary={"Security: "+ networkSecurityMode(selectedNetwork) + ", Ch: " + selectedNetwork.channel}
/>
<ListItemSecondaryAction>
<IconButton aria-label="Manual Config" onClick={deselectNetwork}>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</List>
);
}
render() {
const { classes, formRef, wifiSettingsFetched, wifiSettings, errorMessage, selectedNetwork, handleValueChange, handleCheckboxChange, onSubmit, onReset } = this.props;
return (
<div ref={formRef}>
{
!wifiSettingsFetched ?
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails}/>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
Loading...
</Typography>
</div>
: wifiSettings ?
<ValidatorForm onSubmit={onSubmit} ref="WiFiSettingsForm">
{
selectedNetwork ? this.renderSelectedNetwork() :
<TextValidator
validators={['required', 'matchRegexp:^.{0,32}$']}
errorMessages={['SSID is required', 'SSID must be 32 characeters or less']}
name="ssid"
label="SSID"
className={classes.textField}
value={wifiSettings.ssid}
onChange={handleValueChange('ssid')}
margin="normal"
/>
}
{
!isNetworkOpen(selectedNetwork) &&
<TextValidator
validators={['matchRegexp:^.{0,64}$']}
errorMessages={['Password must be 64 characters or less']}
name="password"
label="Password"
className={classes.textField}
value={wifiSettings.password}
onChange={handleValueChange('password')}
margin="normal"
/>
}
<TextValidator
validators={['required', 'isHostname']}
errorMessages={['Hostname is required', "Not a valid hostname"]}
name="hostname"
label="Hostname"
className={classes.textField}
value={wifiSettings.hostname}
onChange={handleValueChange('hostname')}
margin="normal"
/>
<FormControlLabel className={classes.checkboxControl}
control={
<Checkbox
value="static_ip_config"
checked={wifiSettings.static_ip_config}
onChange={handleCheckboxChange("static_ip_config")}
/>
}
label="Static IP Config?"
/>
{
wifiSettings.static_ip_config &&
[
<TextValidator key="local_ip"
validators={['required', 'isIP']}
errorMessages={['Local IP is required', 'Must be an IP address']}
name="local_ip"
label="Local IP"
className={classes.textField}
value={wifiSettings.local_ip}
onChange={handleValueChange('local_ip')}
margin="normal"
/>,
<TextValidator key="gateway_ip"
validators={['required', 'isIP']}
errorMessages={['Gateway IP is required', 'Must be an IP address']}
name="gateway_ip"
label="Gateway"
className={classes.textField}
value={wifiSettings.gateway_ip}
onChange={handleValueChange('gateway_ip')}
margin="normal"
/>,
<TextValidator key="subnet_mask"
validators={['required', 'isIP']}
errorMessages={['Subnet mask is required', 'Must be an IP address']}
name="subnet_mask"
label="Subnet"
className={classes.textField}
value={wifiSettings.subnet_mask}
onChange={handleValueChange('subnet_mask')}
margin="normal"
/>,
<TextValidator key="dns_ip_1"
validators={['isOptionalIP']}
errorMessages={['Must be an IP address']}
name="dns_ip_1"
label="DNS IP #1"
className={classes.textField}
value={wifiSettings.dns_ip_1}
onChange={handleValueChange('dns_ip_1')}
margin="normal"
/>,
<TextValidator key="dns_ip_2"
validators={['isOptionalIP']}
errorMessages={['Must be an IP address']}
name="dns_ip_2"
label="DNS IP #2"
className={classes.textField}
value={wifiSettings.dns_ip_2}
onChange={handleValueChange('dns_ip_2')}
margin="normal"
/>
]
}
<Button variant="raised" color="primary" className={classes.button} type="submit">
Save
</Button>
<Button variant="raised" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</ValidatorForm>
:
<div className={classes.loadingSettings}>
<Typography variant="display1" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="raised" color="secondary" className={classes.button} onClick={onReset}>
Reset
</Button>
</div>
}
</div>
);
}
}
WiFiSettingsForm.propTypes = {
classes: PropTypes.object.isRequired,
wifiSettingsFetched: PropTypes.bool.isRequired,
wifiSettings: PropTypes.object,
errorMessage: PropTypes.string,
deselectNetwork: PropTypes.func,
selectedNetwork: PropTypes.object,
onSubmit: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
handleValueChange: PropTypes.func.isRequired,
handleCheckboxChange: PropTypes.func.isRequired
};
export default withStyles(styles)(WiFiSettingsForm);

View File

@ -0,0 +1,35 @@
/**
* Executes a get request for an endpoint, updating the local state of the calling
* component. The calling component must bind setState before using this
* function.
*
* This is designed for re-use in simple situations, we arn't using redux here!
*/
export const simpleGet = (
endpointUrl,
setState,
raiseNotification = null,
dataKey="status",
fetchedKey="fetched",
errorMessageKey = "errorMessage"
) => {
setState({
[dataKey]:null,
[fetchedKey]: false,
[errorMessageKey]:null
});
fetch(endpointUrl)
.then(response => {
if (response.status === 200) {
return response.json();
}
throw Error("Invalid status code: " + response.status);
})
.then(json => {setState({[dataKey]: json, [fetchedKey]:true})})
.catch(error =>{
if (raiseNotification) {
raiseNotification("Problem fetching. " + error.message);
}
setState({[dataKey]: null, [fetchedKey]:true, [errorMessageKey]:error.message});
});
}

View File

@ -0,0 +1,38 @@
/**
* Executes a post request for saving data to an endpoint, updating the local
* state with the response. The calling component must bind setState before
* using this function.
*
* This is designed for re-use in simple situations, we arn't using redux here!
*/
export const simplePost = (
endpointUrl,
state,
setState,
raiseNotification = null,
dataKey="settings",
fetchedKey="fetched",
errorMessageKey = "errorMessage"
) => {
setState({[fetchedKey]: false});
fetch(endpointUrl, {
method: 'POST',
body: JSON.stringify(state[dataKey]),
headers: new Headers({
'Content-Type': 'application/json'
})
})
.then(response => {
if (response.status === 200) {
return response.json();
}
throw Error("Invalid status code: " + response.status);
})
.then(json => {
raiseNotification("Changes successfully applied.");
setState({[dataKey]: json, [fetchedKey]:true});
}).catch(error => {
raiseNotification("Problem saving. " + error.message);
setState({[dataKey]: null, [fetchedKey]:true, [errorMessageKey]:error.message});
});
}

5
interface/src/history.js Normal file
View File

@ -0,0 +1,5 @@
import { createBrowserHistory } from 'history';
export default createBrowserHistory({
/* pass a configuration object here if needed */
})

16
interface/src/index.js Normal file
View File

@ -0,0 +1,16 @@
import React from 'react';
import { render } from 'react-dom';
import history from './history';
import { Router, Route, Redirect, Switch } from 'react-router';
import App from './App';
render((
<Router history={history}>
<Switch>
<Redirect exact from='/' to='/home'/>
<Route path="/" component={App} />
</Switch>
</Router>
), document.getElementById("root"))

View File

@ -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) {
return hostnameLengthRegex.test(hostname) && hostnamePatternRegex.test(hostname);
}

View File

@ -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) {
return ipAddressRegexp.test(ipAddress);
}

View File

@ -0,0 +1 @@
export default validator => value => !value || validator(value);

View File

@ -0,0 +1 @@
export default (validator1, validator2) => value => validator1(value) || validator2(value);