Initial Commit

This commit is contained in:
Patrick McDonagh
2018-04-03 18:09:54 -05:00
commit 6f7381c75d
27 changed files with 7825 additions and 0 deletions

43
.eslintrc.js Normal file
View File

@@ -0,0 +1,43 @@
module.exports = {
"env": {
"browser": true,
"es6": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended"
],
"parser": "babel-eslint",
"parserOptions": {
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
},
"sourceType": "module"
},
"plugins": [
"react"
],
"rules": {
"indent": [
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"always"
],
"no-console": "off",
"react/prop-types": [0],
"no-case-declarations": "off"
}
};

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
#
*-lock.json
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules
jspm_packages
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
dist/*

23
README.md Normal file
View File

@@ -0,0 +1,23 @@
# node-ethernet-ip-electron
A basic electron app to read values from a PLC.
![mainWindow](readingTag.gif)
## Install
``` bash
# Clone the repository
$ git clone https://github.com/patrickjmcd/node-ethernet-ip-electron
# Go into the repository
$ cd node-ethernet-ip-electron
# Install dependencies
$ npm install
```
## Develop
Just run this command to start developing with hot reloading.
``` bash
$ npm start
```

6293
app/build/bundle.js Normal file

File diff suppressed because one or more lines are too long

56
app/build/main.css Normal file
View File

@@ -0,0 +1,56 @@
.react-vis-magic-css-import-rule{display:inherit}.rv-treemap{font-size:12px;position:relative}.rv-treemap__leaf{overflow:hidden;position:absolute}.rv-treemap__leaf--circle{align-items:center;border-radius:100%;display:flex;justify-content:center}.rv-treemap__leaf__content{overflow:hidden;padding:10px;text-overflow:ellipsis}.rv-xy-plot{color:#c3c3c3;position:relative}.rv-xy-plot canvas{pointer-events:none}.rv-xy-plot .rv-xy-canvas{pointer-events:none;position:absolute}.rv-xy-plot__inner{display:block}.rv-xy-plot__axis__line{fill:none;stroke-width:2px;stroke:#e6e6e9}.rv-xy-plot__axis__tick__line{stroke:#e6e6e9}.rv-xy-plot__axis__tick__text{fill:#6b6b76;font-size:11px}.rv-xy-plot__axis__title text{fill:#6b6b76;font-size:11px}.rv-xy-plot__grid-lines__line{stroke:#e6e6e9}.rv-xy-plot__circular-grid-lines__line{fill-opacity:0;stroke:#e6e6e9}.rv-xy-plot__circular-grid-lines__line{fill-opacity:0;stroke:#e6e6e9}.rv-xy-plot__series,.rv-xy-plot__series path{pointer-events:all}.rv-xy-plot__series--line{fill:none;stroke:#000;stroke-width:2px}.rv-crosshair{position:absolute;font-size:11px;pointer-events:none}.rv-crosshair__line{background:#47d3d9;width:1px}.rv-crosshair__inner{position:absolute;text-align:left;top:0}.rv-crosshair__inner__content{border-radius:4px;background:#3a3a48;color:#fff;font-size:12px;padding:7px 10px;box-shadow:0 2px 4px rgba(0,0,0,0.5)}.rv-crosshair__inner--left{right:4px}.rv-crosshair__inner--right{left:4px}.rv-crosshair__title{font-weight:bold;white-space:nowrap}.rv-crosshair__item{white-space:nowrap}.rv-hint{position:absolute;pointer-events:none}.rv-hint__content{border-radius:4px;padding:7px 10px;font-size:12px;background:#3a3a48;box-shadow:0 2px 4px rgba(0,0,0,0.5);color:#fff;text-align:left;white-space:nowrap}.rv-discrete-color-legend{box-sizing:border-box;overflow-y:auto;font-size:12px}.rv-discrete-color-legend.horizontal{white-space:nowrap}.rv-discrete-color-legend-item{color:#3a3a48;border-radius:1px;padding:9px 10px}.rv-discrete-color-legend-item.horizontal{display:inline-block}.rv-discrete-color-legend-item.horizontal .rv-discrete-color-legend-item__title{margin-left:0;display:block}.rv-discrete-color-legend-item__color{background:#dcdcdc;display:inline-block;height:2px;vertical-align:middle;width:14px}.rv-discrete-color-legend-item__title{margin-left:10px}.rv-discrete-color-legend-item.disabled{color:#b8b8b8}.rv-discrete-color-legend-item.clickable{cursor:pointer}.rv-discrete-color-legend-item.clickable:hover{background:#f9f9f9}.rv-search-wrapper{display:flex;flex-direction:column}.rv-search-wrapper__form{flex:0}.rv-search-wrapper__form__input{width:100%;color:#a6a6a5;border:1px solid #e5e5e4;padding:7px 10px;font-size:12px;box-sizing:border-box;border-radius:2px;margin:0 0 9px;outline:0}.rv-search-wrapper__contents{flex:1;overflow:auto}.rv-continuous-color-legend{font-size:12px}.rv-continuous-color-legend .rv-gradient{height:4px;border-radius:2px;margin-bottom:5px}.rv-continuous-size-legend{font-size:12px}.rv-continuous-size-legend .rv-bubbles{text-align:justify;overflow:hidden;margin-bottom:5px;width:100%}.rv-continuous-size-legend .rv-bubble{background:#d8d9dc;display:inline-block;vertical-align:bottom}.rv-continuous-size-legend .rv-spacer{display:inline-block;font-size:0;line-height:0;width:100%}.rv-legend-titles{height:16px;position:relative}.rv-legend-titles__left,.rv-legend-titles__right,.rv-legend-titles__center{position:absolute;white-space:nowrap;overflow:hidden}.rv-legend-titles__center{display:block;text-align:center;width:100%}.rv-legend-titles__right{right:0}.rv-radial-chart .rv-xy-plot__series--label{pointer-events:none}
/*
* Global constants goes here
*/
:root {
--primary-color: #42b983;
}
/*
* Global CSS goes here, it requires to use :global before each style
*/
html {
height: 100%;
}
body {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
margin: auto;
}
#app {
color: #2c3e50;
font-family: Source Sans Pro, Helvetica, sans-serif;
height: 100%;
width:100%;
}
#app p {
text-align: justify;
}
.hello {
color: var(--primary-color);
}
#permissives-table tbody tr td {
text-align: center;
}
#permissives-table tbody tr td button {
width: 95%;
}
#permissives-table thead tr th {
text-align: center;
}
#settings-button {
margin: auto 15px;
}
.control-button {
width: 95%;
}

16
app/index.html Normal file
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">
<title>Max Water System</title>
<!-- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.98.1/css/materialize.min.css">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<link rel="stylesheet" href="build/main.css">
</head>
<body>
<div id="app"></div>
<script src="build/bundle.js"></script>
</body>
</html>

View File

@@ -0,0 +1,30 @@
import { ipcRenderer } from "electron";
export const SET_PLC_IPADDRESS = "SET_PLC_IPADDRESS";
export const INITIALIZE_PLC = "INITIALIZE_PLC";
export const PLC_DATA_RECEIVED = "PLC_DATA_RECEIVED";
export function setPlcIpAddress(ipAddress){
return {
type: SET_PLC_IPADDRESS,
payload: ipAddress
};
}
export function ipcPlcInitializeSend(ipAddress, tagList){
ipcRenderer.send("plc:initialize", ipAddress, tagList);
return {
type: INITIALIZE_PLC,
payload: true
};
}
export function ipcPlcDetailsReceived(event, plcData){
console.log("action creator got PLC data", plcData);
return {
type: PLC_DATA_RECEIVED,
payload: plcData
};
}

View File

@@ -0,0 +1,46 @@
import { ipcRenderer } from "electron";
export const IPC_TAGUPDATE = "IPC_TAGUPDATE";
export const IPC_TAGSYNC = "IPC_TAGSYNC";
export const STORE_NEW_TAG = "STORE_NEW_TAG";
export const DELETE_TAG = "DELETE_TAG";
export const WRITE_TAG = "WRITE_TAG";
export function ipcTagUpdate(event, tag){
return {
type: IPC_TAGUPDATE,
payload: tag.state.tag
};
}
export function ipcTagSync(ipAddress, tagList){
ipcRenderer.send("tag:sync", ipAddress, tagList);
return {
type: IPC_TAGSYNC,
payload: true
};
}
export function storeNewTag(tag){
return {
type: STORE_NEW_TAG,
payload: tag
};
}
export function deleteTag(tagName){
return {
type: DELETE_TAG,
payload: tagName
};
}
export function writeTag(tagName, value){
ipcRenderer.send("tag:write", tagName, value);
return {
type: WRITE_TAG,
payload: true
};
}

BIN
app/src/assets/electron.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
app/src/assets/react.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
app/src/assets/webpack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -0,0 +1,260 @@
import React, { Component } from "react";
import { connect } from "react-redux";
import { writeTag } from "../actions/actions_tags";
class Controls extends Component {
constructor(props){
super(props);
this.state = {
setpoints: {}
};
}
writeStart(){
this.props.writeTag("cmd_Start", true);
}
writeStop(){
this.props.writeTag("cmd_Stop", true);
}
onSetpointChange = (event, parName) => {
const { value } = event.target;
this.setState({setpoints: {...this.state.setpoints, [parName]: value}});
}
onSetpointSubmit = (event, parName) => {
event.preventDefault();
switch(parName){
case "flowrate":
this.props.writeTag("cfg_PID_FlowSP", this.state.setpoints.flowrate);
break;
case "fluidlevel":
this.props.writeTag("cfg_PID_FluidLevelSP", this.state.setpoints.fluidlevel);
break;
case "tubingpressure":
this.props.writeTag("cfg_PID_TubingPressureSP", this.state.setpoints.tubingpressure);
break;
case "frequency":
this.props.writeTag("cfg_PID_ManualSP", this.state.setpoints.frequency);
break;
}
}
onPIDParameterSelect = (e, parName) => {
event.preventDefault();
switch(parName){
case "flowrate":
this.props.writeTag("cfg_PID_Flow", true);
break;
case "fluidlevel":
this.props.writeTag("cfg_PID_FluidLevel", true);
break;
case "tubingpressure":
this.props.writeTag("cfg_PID_TubingPressure", true);
break;
case "frequency":
this.props.writeTag("cfg_PID_Manual", true);
break;
}
}
componentDidMount(){
if (this.props.tags){
this.setState({
setpoints: {
flowrate: this.props.tags.cfg_PID_FlowSP.value,
fluidlevel: this.props.tags.cfg_PID_FluidLevelSP.value,
tubingpressure: this.props.tags.cfg_PID_TubingPressureSP.value,
frequency: this.props.tags.cfg_PID_ManualSP.value,
}
});
}
}
render(){
if (!this.props.tags){
return(
<div>
<h1>Loading...</h1>
</div>
);
}
if (!this.props.tags.Device_Status_INT){
return(
<div>
<h1>Loading...</h1>
</div>
);
}
const startBtnClass = this.props.tags.Device_Status_INT.value === 4 ? "btn btn-success control-button" : "btn btn-secondary disabled control-button";
const stopBtnClass = this.props.tags.Device_Status_INT.value !== 4 ? "btn btn-danger control-button" : "btn btn-secondary disabled control-button";
return (
<div className="container" style={{textAlign: "center"}}>
<h1>Controls</h1>
<table className="table">
<tbody>
<tr>
<td>
<button
className={startBtnClass}
onClick={() => this.writeStart()}
>Start</button>
</td>
<td>
<button
className={stopBtnClass}
onClick={() => this.writeStop()}
>Stop</button>
</td>
</tr>
</tbody>
</table>
<hr />
<h3>Control Setpoints</h3>
<table className="table">
<tbody>
<tr>
<td>
<button
className={this.props.tags.sts_PID_Control.value === 0 ? "btn btn-success disabled" : "btn btn-light"}
onClick={(e) => this.onPIDParameterSelect(e, "flowrate")}
>
Flow Rate
</button>
</td>
<td>
<button
className={this.props.tags.sts_PID_Control.value === 1 ? "btn btn-success disabled" : "btn btn-light"}
onClick={(e) => this.onPIDParameterSelect(e, "fluidlevel")}
>
Fluid Level
</button>
</td>
<td>
<button
className={this.props.tags.sts_PID_Control.value === 2 ? "btn btn-success disabled" : "btn btn-light"}
onClick={(e) => this.onPIDParameterSelect(e, "tubingpressure")}
>
Tubing Pressure
</button>
</td>
<td>
<button
className={this.props.tags.sts_PID_Control.value === 3 ? "btn btn-success disabled" : "btn btn-light"}
onClick={(e) => this.onPIDParameterSelect(e, "frequency")}
>
Manual Frequency
</button>
</td>
</tr>
<tr>
<td>
<input
value={this.state.setpoints.flowrate}
onChange={(e) => this.onSetpointChange(e, "flowrate")}
className="form-control"
type="number"
/>
</td>
<td>
<input
value={this.state.setpoints.fluidlevel}
onChange={(e) => this.onSetpointChange(e, "fluidlevel")}
className="form-control"
type="number"
/>
</td>
<td>
<input
value={this.state.setpoints.tubingpressure}
onChange={(e) => this.onSetpointChange(e, "tubingpressure")}
className="form-control"
type="number"
/>
</td>
<td>
<input
value={this.state.setpoints.frequency}
onChange={(e) => this.onSetpointChange(e, "frequency")}
className="form-control"
type="number"
/>
</td>
</tr>
<tr>
<td>
<button
onClick={(e) => this.onSetpointSubmit(e, "flowrate")}
className="btn btn-primary"
>
Update
</button>
</td>
<td>
<button
onClick={(e) => this.onSetpointSubmit(e, "fluidlevel")}
className="btn btn-primary"
>
Update
</button>
</td>
<td>
<button
onClick={(e) => this.onSetpointSubmit(e, "tubingpressure")}
className="btn btn-primary"
>
Update
</button>
</td>
<td>
<button
onClick={(e) => this.onSetpointSubmit(e, "frequency")}
className="btn btn-primary"
>
Update
</button>
</td>
</tr>
</tbody>
</table>
</div>
);
}
}
function mapStateToProps(state){
return {
tags: state.tags
};
}
export default connect(mapStateToProps, { writeTag })(Controls);

View File

@@ -0,0 +1,76 @@
import React, { Component } from "react";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import FontAwesomeIcon from "@fortawesome/react-fontawesome";
import { faTint, faCog } from "@fortawesome/fontawesome-pro-regular";
class Header extends Component {
renderRunningState(){
if(!this.props.tags ){
return <span></span>;
}
if (!this.props.tags.Device_Status_INT){
return <span></span>;
}
let deviceStatus;
let deviceClass;
switch(this.props.tags.Device_Status_INT.value){
case 0:
deviceStatus = "Running";
deviceClass = "btn btn-success";
break;
case 1:
deviceStatus = "Pumped Off";
deviceClass = "btn btn-warning";
break;
case 2:
deviceStatus = "Alarmed";
deviceClass = "btn btn-danger";
break;
case 3:
deviceStatus = "Locked Out";
deviceClass = "btn btn-danger";
break;
case 4:
deviceStatus = "Stopped";
deviceClass = "btn btn-secondary";
break;
default:
deviceStatus = "Unknown";
deviceClass = "btn btn-info";
}
return <button className={deviceClass + " disabled"}>{deviceStatus}</button>;
}
render(){
return (
<nav className="navbar navbar-light navbar-expand-sm bg-light">
<Link to="/" className="navbar-brand">
<FontAwesomeIcon icon={faTint} /> Max Water System
</Link>
<div className="navbar-nav">
<Link className="nav-item nav-link" to="/controls">Control</Link>
<Link className="nav-item nav-link" to="/permissives">Permissives</Link>
<Link className="nav-item nav-link" to="/alltags">All Tags</Link>
</div>
<div className="navbar-nav ml-auto">
<span className="navbar-text">{this.renderRunningState()}</span>
<Link className="nav-item nav-link" to="/settings" id="settings-button"><FontAwesomeIcon icon={faCog} /></Link>
</div>
</nav>
);
}
}
function mapStateToProps(state){
return {
tags: state.tags
};
}
export default connect(mapStateToProps)(Header);

101
app/src/components/Main.js Normal file
View File

@@ -0,0 +1,101 @@
import React, { Component } from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { FlexibleWidthXYPlot, LineSeries, VerticalGridLines, HorizontalGridLines, XAxis, YAxis, DiscreteColorLegend } from "react-vis";
import "../../../node_modules/react-vis/dist/style.css";
class Main extends Component {
getChartValues(values){
return _.map(values, (val) => {
return {x: val.timestamp, y: val.value};
});
}
render(){
if (!this.props.tagHistory){
return(
<div className="container">
<h1>Loading...</h1>
</div>
);
}
return (
<div className="container">
<h3>Process Values</h3>
<FlexibleWidthXYPlot
height={300}
xType="time"
>
<VerticalGridLines />
<HorizontalGridLines />
<XAxis />
<YAxis />
<LineSeries
data={this.getChartValues(this.props.tagHistory.val_IntakePressure)}
/>
<LineSeries
data={this.getChartValues(this.props.tagHistory.val_Flowmeter)}
/>
<LineSeries
data={this.getChartValues(this.props.tagHistory.val_FluidLevel)}
/>
</FlexibleWidthXYPlot>
<DiscreteColorLegend
items={[
{title: "Intake Pressure"},
{title: "Flowmeter"},
{title: "Fluid Level"}
]}
orientation="horizontal"
/>
<hr />
<h3>VFD Data</h3>
<FlexibleWidthXYPlot
height={300}
xType="time"
>
<VerticalGridLines />
<HorizontalGridLines />
<XAxis />
<YAxis />
<LineSeries
data={this.getChartValues(this.props.tagHistory.VFD_OutCurrent)}
/>
<LineSeries
data={this.getChartValues(this.props.tagHistory.VFD_SpeedFdbk)}
/>
<LineSeries
data={this.getChartValues(this.props.tagHistory.VFD_OutPower)}
/>
<LineSeries
data={this.getChartValues(this.props.tagHistory.VFD_Temp)}
/>
</FlexibleWidthXYPlot>
<DiscreteColorLegend
items={[
{title: "VFD Current"},
{title: "VFD Speed Feedback"},
{title: "VFD Output Power"},
{title: "VFD Temp"}
]}
orientation="horizontal"
/>
</div>
);
}
}
function mapStateToProps(state){
return {
tags: state.tags,
tagHistory: state.tagHistory
};
}
export default connect(mapStateToProps)(Main);

View File

@@ -0,0 +1,74 @@
import React, { Component } from "react";
import { connect } from "react-redux";
class Permissives extends Component {
renderTagButton = (tagName, description) => {
if (!this.props.tags[tagName]){
return(
<button
className="btn disabled"
>
{description}
</button>
);
}
const btnClassName = this.props.tags[tagName].value ? "btn btn-success" : "btn btn-danger";
return <button className={btnClassName + " disabled"}>{description}</button>;
}
render(){
if (!this.props.tags){
return (
<div>
<h2>Loading...</h2>
</div>
);
}
return(
<div>
<table id="permissives-table" className="table">
<thead>
<tr>
<th>{this.renderTagButton("sp_ALL", "START")}</th>
<th>{this.renderTagButton("rp_ALL", "RUN")}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{this.renderTagButton("sp_Flowmeter", "Flowmeter")}</td>
<td>{this.renderTagButton("rp_Flowmeter", "Flowmeter")}</td>
</tr>
<tr>
<td>{this.renderTagButton("sp_FluidLevel", "Fluid Level")}</td>
<td>{this.renderTagButton("rp_FluidLevel", "Fluid Level")}</td>
</tr>
<tr>
<td>{this.renderTagButton("sp_IntakePressure", "Intake Pressure")}</td>
<td>{this.renderTagButton("rp_IntakePressure", "Intake Pressure")}</td>
</tr>
<tr>
<td>{this.renderTagButton("sp_IntakeTemperature", "Intake Temperature")}</td>
<td>{this.renderTagButton("rp_IntakeTemperature", "Intake Temperature")}</td>
</tr>
</tbody>
</table>
</div>
);
}
}
function mapStateToProps(state){
return {
tags: state.tags
};
}
export default connect(mapStateToProps)(Permissives);

View File

@@ -0,0 +1,155 @@
import React, { Component } from "react";
import { connect } from "react-redux";
import _ from "lodash";
import { setPlcIpAddress, ipcPlcInitializeSend } from "../actions/actions_plc";
import { storeNewTag, deleteTag } from "../actions/actions_tags";
class Settings extends Component {
constructor(props){
super(props);
this.state = {
ipAddress: "",
newTag: ""
};
}
getTagList = () => {
const { tags } = this.props;
let tableMiddle = _.map(tags, (tag) => {
return (<tr key={tag.name}>
<td>{tag.name}</td>
<td>
<button
className="btn red"
onClick={(e) => this.onDeleteClick(e, tag.name)}
><i className="material-icons">clear</i></button></td>
</tr>);
});
return (
<table>
<thead>
<tr>
<th>Tag</th>
<th></th>
</tr>
</thead>
<tbody>
{tableMiddle}
</tbody>
</table>
);
}
componentWillMount(){
console.log(this.props.plc);
if (this.props.plc && this.props.plc.ipAddress){
console.log(this.props.plc.ipAddress);
this.setState({ipAddress: this.props.plc.ipAddress});
}
}
onDeleteClick = (e, tagName) =>{
e.preventDefault();
this.props.deleteTag(tagName);
}
onIpAddressInputChange = (event) => {
this.setState({ipAddress: event.target.value});
}
sendIpAddress = (e) => {
e.preventDefault();
this.props.setPlcIpAddress(this.state.ipAddress);
}
onNewTagChange = (e) => {
this.setState({newTag: e.target.value});
}
onNewTagSubmit = (e) => {
e.preventDefault();
this.props.storeNewTag(this.state.newTag);
this.setState({newTag: ""});
}
onSave = (e) => {
console.log(this.props);
e.preventDefault();
this.props.ipcPlcInitializeSend(this.props.plc.ipAddress, this.props.tags);
this.props.history.push("/");
}
render() {
const ipAddressBtnClass = ((this.state.ipAddress === this.props.plc.ipAddress) || (this.state.ipAddress.length === 0)) ? "btn disabled right" : "btn right";
const initializeBtnClass = (this.props.plc.ipAddress && _.map(this.props.tags, (t)=>t).length > 0) ? "btn" : "btn disabled";
return (
<div style={styles.container}>
<ul className="collection with-header">
<li className="collection-header">
Settings
</li>
<form>
<li className="collection-item">
<p>IP Address</p>
<input
value={this.state.ipAddress}
placeholder="PLC IP Address"
onChange={this.onIpAddressInputChange}
/>
<button
className={ipAddressBtnClass}
onClick={(e) => this.sendIpAddress(e)}>
Set IP Address
</button>
</li>
</form>
<form>
<li className="collection-item">
<h4>Tag List</h4>
{this.getTagList()}
<input
value={this.state.newTag}
onChange={this.onNewTagChange}
placeholder="New Tag Name..."
/>
<button
className="btn"
onClick={(e) => this.onNewTagSubmit(e)}
>Add Tag</button>
</li>
<li className="collection-item right">
<button
className={initializeBtnClass}
onClick={(e) => this.onSave(e)}
>Save</button>
</li>
</form>
</ul>
</div>
);
}
}
const styles = {
container: {
display: "flex",
flexDirection: "column"
},
pointer: {
cursor: "pointer"
}
};
function mapStateToProps(state){
return{
tags: state.tags,
plc: state.plc
};
}
export default connect(mapStateToProps, { setPlcIpAddress, storeNewTag, ipcPlcInitializeSend, deleteTag })(Settings);

View File

@@ -0,0 +1,130 @@
import React, { Component } from "react";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import _ from "lodash";
import Gauge from "react-svg-gauge";
import { writeTag } from "../actions/actions_tags";
class TagsIndex extends Component {
constructor(props){
super(props);
this.state = {writes: {}};
}
renderTagsList(){
return _.map(this.props.tags, (t) => {
return (<tr key={t.name}>
<td>{t.name}</td>
<td>{Math.round(t.value * 100) / 100}</td>
<td><input
onChange={(e) => this.onTagWriteFieldChanged(e, t.name)}
/></td>
<td><button
className="waves-effect waves-light btn"
onClick={() => this.onWriteButtonClick(t.name)}
>Write</button></td>
</tr>);
});
}
renderTagsGauges(){
return _.map(this.props.tags, (tag) => {
return (<div className="col s4" key={tag.name}>
<Gauge value={Math.round(tag.value * 100) / 100}
width={200}
height={150}
label={tag.name}
valueLabelStyle={styles.valueLabel}
topLabelStyle={styles.topLabel}
minMaxLabelStyle={styles.minMaxLabel} />
</div>);
});
}
onWriteButtonClick = (tagName) => {
console.log(tagName, this.state.writes[tagName]);
this.props.writeTag(tagName, this.state.writes[tagName]);
}
onTagWriteFieldChanged = (e, tagName) => {
console.log(tagName, e.target.value);
this.setState({writes: {...this.state.writes, [tagName]: e.target.value}});
}
render() {
let PLC = "PLC";
if (this.props.plc.details){
PLC = this.props.plc.details.name;
}
if (!this.props.tags || _.map(this.props.tags, (t)=> t ).length === 0) {
return (
<div style={styles.container}>
<h3>
No Tags.
</h3>
<h4>Add some in <Link to="/settings">Settings</Link>.</h4>
</div>
);
}
return (
<div style={styles.container}>
<h2>Tags for {this.props.plc.ipAddress}</h2>
<h3>{PLC}</h3>
<table className="table">
<thead>
<tr>
<th>Tag Name</th>
<th>Value</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{ this.renderTagsList()}
</tbody>
</table>
</div>
);
}
}
const styles = {
container: {
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-around",
textAlign: "center"
},
buttonContainer: {
display: "flex",
flexDirection: "column"
},
button: {
marginBottom: "15px"
},
valueLabel: {
fontSize: "1.5em"
},
topLabel: {
fontSize: "1.35em"
},
minMaxLabel: {
fontSize: "0.85em"
}
};
function mapStateToProps(state){
return {
tags: state.tags,
plc: state.plc
};
}
export default connect(mapStateToProps, { writeTag })(TagsIndex);

53
app/src/global.css Normal file
View File

@@ -0,0 +1,53 @@
/*
* Global constants goes here
*/
:root {
--primary-color: #42b983;
}
/*
* Global CSS goes here, it requires to use :global before each style
*/
:global html {
height: 100%;
}
:global body {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
margin: auto;
}
:global #app {
color: #2c3e50;
font-family: Source Sans Pro, Helvetica, sans-serif;
height: 100%;
width:100%;
}
:global #app p {
text-align: justify;
}
:global .hello {
color: var(--primary-color);
}
:global #permissives-table tbody tr td {
text-align: center;
}
:global #permissives-table tbody tr td button {
width: 95%;
}
:global #permissives-table thead tr th {
text-align: center;
}
:global #settings-button {
margin: auto 15px;
}
:global .control-button {
width: 95%;
}

15
app/src/reducers/index.js Normal file
View File

@@ -0,0 +1,15 @@
import { combineReducers } from "redux";
import TagsReducer from "./reducer_tags";
import PlcReducer from "./reducer_plc";
import TagHistoryReducer from "./reducer_taghistory";
const rootReducer = combineReducers({
tags: TagsReducer,
tagHistory: TagHistoryReducer,
plc: PlcReducer
});
export default rootReducer;

View File

@@ -0,0 +1,15 @@
import { SET_PLC_IPADDRESS, PLC_DATA_RECEIVED } from "../actions/actions_plc";
export default function(state = {}, action){
switch(action.type){
case SET_PLC_IPADDRESS:
return { ...state, ipAddress: action.payload };
case PLC_DATA_RECEIVED:
console.log(action.payload);
return { ...state, ...action.payload };
default:
return state;
}
}

View File

@@ -0,0 +1,29 @@
import _ from "lodash";
import { IPC_TAGUPDATE } from "../actions/actions_tags";
import { historyTags } from "../renderer_process";
export default function(state = {}, action){
switch (action.type) {
case IPC_TAGUPDATE:
const { name, value } = action.payload;
if (historyTags.includes(name)){
const thisEntry = {
value,
timestamp: new Date()
};
let tagHistory = [ thisEntry ];
if (state[name]){
tagHistory = _.take(_.concat(tagHistory, state[name]), 500);
}
return { ...state, [name]: tagHistory};
} else {
return state;
}
default:
return state;
}
}

View File

@@ -0,0 +1,21 @@
import _ from "lodash";
import { IPC_TAGUPDATE, STORE_NEW_TAG, DELETE_TAG } from "../actions/actions_tags";
export default function(state = {}, action){
switch (action.type) {
case IPC_TAGUPDATE:
const { name, value } = action.payload;
return { ...state, [name]: { name, value }};
case STORE_NEW_TAG:
const newTagName = action.payload;
return { ...state, [newTagName]: { name: newTagName }};
case DELETE_TAG:
return _.omit(state, action.payload);
default:
return state;
}
}

View File

@@ -0,0 +1,45 @@
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { createStore, applyMiddleware } from "redux";
import createIpc from "redux-electron-ipc";
import { BrowserRouter, Route, Switch } from "react-router-dom";
import reducers from "./reducers";
import TagsIndex from "./components/TagsIndex";
import Settings from "./components/Settings";
import Header from "./components/Header";
import Permissives from "./components/Permissives";
import Main from "./components/Main";
import Controls from "./components/Controls";
import { ipcTagUpdate } from "./actions/actions_tags";
import { ipcPlcDetailsReceived } from "./actions/actions_plc";
export const { history_tags: historyTags } = require("../../tagList.json");
const ipc = createIpc({
"tag:valueupdate": ipcTagUpdate,
"plc:connected": ipcPlcDetailsReceived
});
const createStoreWithMiddleware = applyMiddleware(ipc)(createStore);
ReactDOM.render(
<Provider store={createStoreWithMiddleware(reducers)}>
<BrowserRouter>
<div>
<Header />
<Switch>
<Route path="/settings" component={Settings} />
<Route path="/permissives" component={Permissives} />
<Route path="/alltags" component={TagsIndex} />
<Route path="/controls" component={Controls} />
<Route path="/" component={Main} />
</Switch>
</div>
</BrowserRouter>
</Provider>
, document.querySelector("#app"));

74
main_process.js Normal file
View File

@@ -0,0 +1,74 @@
// Basic init
const electron = require("electron");
const { Controller, Tag } = require("ethernet-ip");
const _ = require("lodash");
const { app, BrowserWindow, ipcMain } = electron;
// To avoid being garbage collected
let mainWindow;
let PLC;
const tagList = require("./tagList.json");
app.on("ready", () => {
mainWindow = new BrowserWindow({});
mainWindow.loadURL(`file://${__dirname}/app/index.html`);
initPLC("10.20.4.36", tagList.scan_list);
});
function writeTag(tagName, tagValue){
const thisTag = new Tag(tagName);
PLC.readTag(thisTag).then(() => {
thisTag.value = tagValue;
PLC.writeTag(thisTag);
});
}
function initPLC(ipAddress, tagList){
PLC = new Controller();
const setupTags = new Promise ((resolve) =>{
resolve(_.map(tagList, (tag) => {
PLC.subscribe(new Tag( tag ));
}));
});
setupTags.then(()=>{
PLC.connect(ipAddress, 0).then( () => {
const properties = { ...PLC.properties, ipAddress};
mainWindow.webContents.send("plc:connected", properties);
PLC.scan().catch((err) => {
console.log(err);
});
}).catch((err) => {
console.log(err);
});
PLC.forEach( (tag) => {
tag.on("Initialized", (tag) => {
// console.log("main_process: Initialized", tag.name, tag.value);
mainWindow.webContents.send("tag:valueupdate", tag);
});
tag.on("Changed", (tag) => {
// console.log("main_process: Changed", tag.name, tag.value);
mainWindow.webContents.send("tag:valueupdate", tag);
});
});
});
}
ipcMain.on("plc:initialize", (event, ipAddress, tagList) =>{
// console.log("plc:initialize", ipAddress, tagList);
initPLC(ipAddress, tagList);
});
ipcMain.on("tag:write", (event, tagName, value) => {
// console.log("tag:write", tagName, value);
writeTag(tagName, value);
});

63
package.json Normal file
View File

@@ -0,0 +1,63 @@
{
"name": "maxwatersystem-electron",
"version": "1.1.0",
"description": "Electron-based HMI Program",
"main": "main_process.js",
"scripts": {
"bundle": "webpack --mode development",
"wpackserve": "webpack --mode development -w",
"serve": "electron .",
"start": "npm-run-all --parallel wpackserve serve",
"pack": "electron-builder --dir",
"dist": "electron-builder -mwl"
},
"author": "Patrick J. McDonagh",
"repository": "HenryPump/MaxWaterSystem-Electron",
"license": "MIT",
"devDependencies": {
"babel-core": "^6.26.0",
"babel-eslint": "^8.2.2",
"babel-loader": "^7.1.4",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-0": "^6.24.1",
"css-loader": "^0.28.10",
"electron": "^2.0.0-beta.5",
"electron-builder": "^20.8.1",
"electron-reload": "^1.2.2",
"eslint": "^4.19.1",
"eslint-plugin-react": "^7.7.0",
"file-loader": "^1.1.10",
"mini-css-extract-plugin": "^0.4.0",
"npm-run-all": "^4.1.2",
"webpack": "^4.1.1",
"webpack-cli": "^2.0.11"
},
"dependencies": {
"@fortawesome/fontawesome": "^1.1.5",
"@fortawesome/fontawesome-pro-regular": "^5.0.9",
"@fortawesome/fontawesome-pro-webfonts": "^1.0.5",
"@fortawesome/react-fontawesome": "0.0.18",
"ethernet-ip": "^1.1.4",
"lodash": "^4.17.5",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-redux": "^5.0.7",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"react-svg-gauge": "^1.0.7",
"react-vis": "^1.9.2",
"redux": "^3.7.2",
"redux-electron-ipc": "^1.1.12",
"victory": "^0.25.7"
},
"build": {
"appId": "com.henrypump.maxwatersystem",
"mac": {
"target": "default"
},
"win": {
"target": "portable"
}
}
}

105
tagList.json Normal file
View File

@@ -0,0 +1,105 @@
{
"scan_list":
[
"alarm_ESTOP",
"alarm_Flowmeter",
"alarm_FluidLevel",
"alarm_IntakePressure",
"alarm_IntakeTemperature",
"alarm_Lockout",
"alarm_MinSpeed",
"alarm_TubingPressure",
"alarm_VFD",
"cfg_CostPerkWh",
"cfg_CurrentLimitMultiplier",
"cfg_DHSensorDistToIntake",
"cfg_DHSensorPressureOffset",
"cfg_FluidSpecificGravity",
"cfg_MinSpeedSecondsBeforeFault",
"cfg_PID_Flow",
"cfg_PID_FlowSP",
"cfg_PID_FluidLevel",
"cfg_PID_FluidLevelSP",
"cfg_PID_Manual",
"cfg_PID_ManualSP",
"cfg_PID_TubingPressure",
"cfg_PID_TubingPressureSP",
"cmd_Start",
"cmd_Stop",
"Device_Status_INT",
"Downhole_Sensor_Status_INT",
"Flow_Total_LastMonth",
"Flow_Total_Lifetime",
"Flow_Total_ThisMonth",
"rp_ALL",
"rp_Flowmeter",
"rp_FluidLevel",
"rp_IntakePressure",
"rp_IntakeTemperature",
"rp_MinSpeed",
"rp_TubingPressure",
"rp_VFD",
"sp_ALL",
"sp_Flowmeter",
"sp_FluidLevel",
"sp_IntakePressure",
"sp_IntakeTemperature",
"sp_Time",
"sp_TubingPressure",
"sp_VFD",
"sts_CurrentVFDFaultCode",
"sts_NoAlarms",
"sts_PID_Control",
"sts_PumpOff",
"sts_RestartAllowed",
"sts_TrueAlarm",
"sts_WaitingToRestart",
"time_RunTime_Hours",
"time_TotalSecondsUntilStartup",
"val_Flowmeter",
"val_Flowmeter_BarrelsPerDay",
"val_FluidLevel",
"val_IntakePressure",
"val_IntakeTemperature",
"val_TubingPressure",
"VFD_MotorNPAmps",
"VFD_MotorNPHertz",
"VFD_MotorNPHorsepower",
"VFD_MotorNPOLFactor",
"VFD_MotorNPRPM",
"VFD_MotorNPVolts",
"VFD_MotorPoles",
"VFD_OutCurrent",
"VFD_OutPower",
"VFD_PWMFrequency",
"VFD_SpeedFdbk",
"VFD_SpeedRef",
"VFD_Temp"
],
"history_tags":
[
"val_Flowmeter",
"val_Flowmeter_BarrelsPerDay",
"val_FluidLevel",
"val_IntakePressure",
"val_IntakeTemperature",
"val_TubingPressure",
"VFD_OutCurrent",
"VFD_OutPower",
"VFD_SpeedFdbk",
"VFD_SpeedRef",
"VFD_Temp"
],
"alarm_tags":
[
"alarm_ESTOP",
"alarm_Flowmeter",
"alarm_FluidLevel",
"alarm_IntakePressure",
"alarm_IntakeTemperature",
"alarm_Lockout",
"alarm_MinSpeed",
"alarm_TubingPressure",
"alarm_VFD"
]
}

60
webpack.config.js Normal file
View File

@@ -0,0 +1,60 @@
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
watch: false,
target: "electron-main",
entry: ["./app/src/renderer_process.js", "./app/src/global.css"],
output: {
path: __dirname + "/app/build",
publicPath: "build/",
filename: "bundle.js"
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: "babel-loader",
exclude: /node_modules/,
options: {
presets: [ "es2015", "react", "stage-0" ]
}
},
// {
// test: /\.css$/,
// loader: ExtractTextPlugin.extract({
// loader: "css-loader",
// options: {
// modules: true
// }
// })
// },
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
"css-loader"
]
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: "file-loader",
query: {
name: "[name].[ext]?[hash]"
}
}
]
},
plugins: [
new MiniCssExtractPlugin({
filename: "main.css",
chunkFilename: "[id].css"
})
],
resolve: {
extensions: [".js", ".json", ".jsx"]
}
};