From 20bd06016bea1fcbeea4bd9a94fd3836881e4d03 Mon Sep 17 00:00:00 2001 From: Chris Pickett Date: Sat, 31 Mar 2018 16:28:13 -0500 Subject: [PATCH] Initial commit --- .gitignore | 61 +++++++++++++++++ LICENSE | 21 ++++++ MMM-ScreenLogic.js | 167 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 53 ++++++++++++++ node_helper.js | 66 ++++++++++++++++++ package.json | 12 ++++ screenlogic.css | 7 ++ screenshot.png | Bin 0 -> 13464 bytes 8 files changed, 387 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MMM-ScreenLogic.js create mode 100644 README.md create mode 100644 node_helper.js create mode 100644 package.json create mode 100644 screenlogic.css create mode 100644 screenshot.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c245cd3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# 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 + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# package lock +package-lock.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b442934 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MMM-ScreenLogic.js b/MMM-ScreenLogic.js new file mode 100644 index 0000000..9411aaa --- /dev/null +++ b/MMM-ScreenLogic.js @@ -0,0 +1,167 @@ +poolData = {}; + +Module.register("MMM-ScreenLogic",{ + defaults: { + showPoolTemp: true, + showSpaTemp: true, + showPH: true, + showOrp: true, + showSaltLevel: true, + showSaturation: true, + colored: true, + coldTemp: 84, + hotTemp: 90, + columns: 3, + contentClass: "light" + }, + + start: function() { + this.sendSocketNotification('SCREENLOGIC_CONFIG', this.config); + this.sendSocketNotification('SCREENLOGIC_UPDATE'); + }, + + getStyles: function() { + return ["screenlogic.css"]; + }, + + getDom: function() { + if (!poolData.status) { + var wrapper = document.createElement("div"); + wrapper.innerHTML = 'Loading...'; + wrapper.className += "dimmed light small"; + + return wrapper; + } else { + var table = document.createElement('table'); + table.className = "small"; + if (this.config.colored) { + table.className += " colored"; + } + + var contents = []; + + var row = document.createElement('tr'); + table.appendChild(row); + + if (this.config.showPoolTemp) { + var className = ""; + if (poolData.status.currentTemp[0] <= this.config.coldTemp) { + className += " cold-temp"; + } else if (poolData.status.currentTemp[0] >= this.config.hotTemp) { + className += " hot-temp"; + } + + contents.push({ + header: "Pool temp", + data: poolData.status.currentTemp[0] + "°" + (!isPoolActive(poolData.status) ? " (last)" : ""), + class: this.config.contentClass + className + }); + } + if (this.config.showSpaTemp) { + var className = ""; + if (poolData.status.currentTemp[1] <= this.config.coldTemp) { + className = " cold-temp"; + } else if (poolData.status.currentTemp[1] >= this.config.hotTemp) { + className = " hot-temp"; + } + + contents.push({ + header: "Spa temp", + data: poolData.status.currentTemp[1] + "°" + (!isSpaActive(poolData.status) ? " (last)" : ""), + class: this.config.contentClass + className + }); + } + if (this.config.showPH) { + contents.push({ + header: "pH", + data: poolData.status.pH, + class: this.config.contentClass + }); + } + if (this.config.showOrp) { + contents.push({ + header: "ORP", + data: poolData.status.orp, + class: this.config.contentClass + }); + } + if (this.config.showSaltLevel) { + contents.push({ + header: "Salt PPM", + data: poolData.status.saltPPM, + class: this.config.contentClass + }); + } + if (this.config.showSaturation) { + contents.push({ + header: "Saturation", + data: poolData.status.saturation, + class: this.config.contentClass + }); + } + + var headerRow = document.createElement('tr'); + var contentRow = document.createElement('tr'); + table.appendChild(headerRow); + table.appendChild(contentRow); + + var cols = 0; + for (var item in contents) { + var headerCell = document.createElement('th'); + headerCell.innerHTML = contents[item].header; + headerRow.appendChild(headerCell); + + var contentCell = document.createElement('td'); + contentCell.innerHTML = contents[item].data; + contentCell.className = contents[item].class; + contentRow.appendChild(contentCell); + + cols++; + if (cols % this.config.columns === 0) { + headerRow = document.createElement('tr'); + contentRow = document.createElement('tr'); + table.appendChild(headerRow); + table.appendChild(contentRow); + } + } + + return table; + } + }, + + socketNotificationReceived: function(notification, payload) { + if (notification === 'SCREENLOGIC_RESULT') { + poolData = payload; + this.updateDom(); + } + } +}); + +const SPA_CIRCUIT_ID = 500; +const POOL_CIRCUIT_ID = 505; + +function isPoolActive(status) { + for (var i = 0; i < status.circuitArray.length; i++) { + if (status.circuitArray[i].id === POOL_CIRCUIT_ID) { + return status.circuitArray[i].state === 1; + } + } +} + +function hasSpa(status) { + for (var i = 0; i < status.circuitArray.length; i++) { + if (status.circuitArray[i].id === SPA_CIRCUIT_ID) { + return true; + } + } + + return false; +} + +function isSpaActive(status) { + for (var i = 0; i < status.circuitArray.length; i++) { + if (status.circuitArray[i].id === SPA_CIRCUIT_ID) { + return status.circuitArray[i].state === 1; + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b902ea3 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# MMM-ScreenLogic +A MagicMirror² module used to get real-time values of crypto currencies. Tested with MagicMirror² v2.2.2 server, Chrome 65 on Windows 10 and Midori 0.4.3 on a Raspberry Pi Zero W with Raspbian Jessie. Tested with a Pentair ScreenLogic system running version 5.2 Build 736.0 Rel. + +## Installation +1. Navigate into your MagicMirror's `modules` folder and execute `git clone https://github.com/parnic/MMM-ScreenLogic.git`. +2. `cd MMM-ScreenLogic` +3. Execute `npm install` to install the node dependencies. +4. Add the module inside `config.js` placing it where you prefer. + +## Config +|Option|Type|Description|Default| +|---|---|---|---| +|`serverAddress`|String|The IPv4 address of a ScreenLogic unit to connect to. If not set, the system will search for a unit to connect to. If set, `serverPort` must also be set.| | +|`serverPort`|Integer|The port of a ScreenLogic unit to connect to (usually 80). If not set, the system will search for a unit to connect to. If set, `serverAddress` must also be set.| | +|`showPoolTemp`|Boolean|Whether you'd like to show pool temperature or not.|`true`| +|`showSpaTemp`|Boolean|Whether you'd like to show spa temperature or not.|`true`| +|`showPH`|Boolean|Whether you'd like to show pH level or not.|`true`| +|`showOrp`|Boolean|Whether you'd like to show ORP level or not.|`true`| +|`showSaltLevel`|Boolean|Whether you'd like to show salt level (in PPM) or not.|`true`| +|`showSaturation`|Boolean|Whether you'd like to show saturation/balance or not.|`true`| +|`colored`|Boolean|Whether you'd like colored output or not.|`true`| +|`coldTemp`|Integer|Show the temperature colored blue if it's at or below this level for pool/spa (requires option `colored`). This is in whatever scale your system is set to (Fahrenheit/Celsius).|`84`| +|`hotTemp`|Integer|Show the temperature colored red if it's at or above this level for pool/spa (requires option `colored`). This is in whatever scale your system is set to (Fahrenheit/Celsius).|`90`| +|`columns`|Integer|How many columns to use to display the data before starting a new row.|`3`| +|`contentClass`|String|The CSS class used to display content values (beneath the header).|`"light"`| + +Here is an example of an entry in config.js +``` +{ + module: 'MMM-ScreenLogic', + header: 'Pool info', + position: 'top_left', + config: { + showSpaTemp: false, + columns: 2, + contentClass: 'thin' + } +}, +``` + +## Screenshot +#### With color +![Screenshot with color](/screenshot.png?raw=true "colored: true") + +## Notes +Pull requests are very welcome! If you'd like to see any additional functionality, don't hesitate to let me know. + +This module only works with ScreenLogic controllers on the local network via either a UDP broadcast on 255.255.255.255 or a direct connection if you've specified an address and port in the configuration. + +The data is updated every 30 minutes. + +## Libraries +This uses a Node.JS library I created for interfacing with ScreenLogic controllers over the network: node-screenlogic, so feel free to check that out for more information. diff --git a/node_helper.js b/node_helper.js new file mode 100644 index 0000000..ffca67c --- /dev/null +++ b/node_helper.js @@ -0,0 +1,66 @@ +var NodeHelper = require('node_helper'); +var lastResult = {}; + +module.exports = NodeHelper.create({ + start: function() { + var self = this; + setInterval(function() { + self.doUpdate() + }, 30 * 60 * 1000); + }, + + doUpdate: function() { + var self = this; + getPoolData(this.config, function(poolData) { + lastResult = poolData; + self.sendSocketNotification('SCREENLOGIC_RESULT', poolData); + }); + }, + + socketNotificationReceived: function(notification, payload) { + if (notification === 'SCREENLOGIC_CONFIG') { + this.config = payload; + } + if (notification === 'SCREENLOGIC_UPDATE') { + this.doUpdate(); + } + } +}); + +const ScreenLogic = require('node-screenlogic'); + +function getPoolData(config, cb) { + if (typeof config === 'undefined' || !config.serverAddress || !config.serverPort) { + findServer(cb); + } else { + populateSystemData(new ScreenLogic.UnitConnection(config.serverPort, config.serverAddress), cb); + } +} + +function findServer(cb) { + var finder = new ScreenLogic.FindUnits(); + finder.on('serverFound', function(server) { + finder.close(); + populateSystemData(new ScreenLogic.UnitConnection(server), cb); + }); + + finder.search(); +} + +function populateSystemData(unit, cb) { + var poolData = {}; + + unit.on('loggedIn', function() { + unit.getControllerConfig(); + }).on('controllerConfig', function(config) { + poolData.degStr = config.degC ? 'C' : 'F'; + unit.getPoolStatus(); + }).on('poolStatus', function(status) { + poolData.status = status; + + unit.close(); + cb(poolData); + }); + + unit.connect(); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9d8b4b3 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "magic-mirror-module-screenlogic", + "version": "1.0.0", + "description": "Show data from Pentair ScreenLogic systems", + "main": "MMM-ScreenLogic.js", + "author": "Chris Pickett", + "repository": "https://github.com/parnic/MMM-ScreenLogic.git", + "license": "MIT", + "dependencies": { + "node-screenlogic": "~1.0.1" + } +} diff --git a/screenlogic.css b/screenlogic.css new file mode 100644 index 0000000..cf930b0 --- /dev/null +++ b/screenlogic.css @@ -0,0 +1,7 @@ +.MMM-ScreenLogic table.colored .cold-temp { + color: #BCDDFF; +} + +.MMM-ScreenLogic table.colored .hot-temp { + color: #FF8E99; +} diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..79bbd3b6b9594b25fb3a5b27a2a50475184e9399 GIT binary patch literal 13464 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