├── package.json ├── web ├── src │ ├── app │ │ ├── pages │ │ │ ├── settings │ │ │ │ ├── SettingsPage.scss │ │ │ │ ├── SettingsPage.html │ │ │ │ └── SettingsPage.js │ │ │ ├── BasePage.js │ │ │ └── liveData │ │ │ │ ├── LiveDataPage.scss │ │ │ │ ├── LiveDataPage.html │ │ │ │ └── LiveDataPage.js │ │ ├── components │ │ │ ├── confirmation │ │ │ │ ├── Confirmation.html │ │ │ │ ├── Confirmation.scss │ │ │ │ └── Confirmation.js │ │ │ └── menu │ │ │ │ ├── Menu.scss │ │ │ │ └── Menu.js │ │ ├── mixins │ │ │ ├── NativeEventsMixin.js │ │ │ ├── EventsMixin.js │ │ │ └── NotifyUpgradableMixin.js │ │ ├── Router.js │ │ └── utils │ │ │ ├── Formatter.js │ │ │ └── WebSocketWrapper.js │ ├── statics │ │ ├── favicon.ico │ │ ├── icons-192.png │ │ ├── icons-512.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── service-worker.js │ │ ├── manifest.json │ │ └── icons-vector.svg │ ├── images │ │ ├── back.svg │ │ ├── reset.svg │ │ ├── reload.svg │ │ ├── reboot.svg │ │ ├── save.svg │ │ ├── app-spinner.svg │ │ ├── settings.svg │ │ └── menu.svg │ ├── svgo.config.js │ ├── index.js │ └── main.scss ├── package.json └── webpack.config.js ├── data ├── main.js.gz ├── main.css.gz ├── favicon.ico.gz ├── icons-192.png ├── icons-512.png ├── index.html.gz ├── favicon-16x16.png ├── favicon-32x32.png ├── manifest.json.gz ├── icons-vector.svg.gz └── service-worker.js.gz ├── env.example.json ├── .gitignore ├── README.md ├── .vscode ├── extensions.json └── settings.json ├── include ├── NodeData.h ├── Cfg.h ├── Display.h ├── TimeSync.h ├── Network.h ├── Module.h ├── Pzem.h ├── Settings.h ├── Mqtt.h └── WebServer.h ├── src ├── TimeSync.cpp ├── Module.cpp ├── Display.cpp ├── Network.cpp ├── Settings.cpp ├── Pzem.cpp ├── main.cpp ├── Mqtt.cpp └── WebServer.cpp ├── LICENSE ├── lib └── README └── platformio.ini /package.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /web/src/app/pages/settings/SettingsPage.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/main.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/data/main.js.gz -------------------------------------------------------------------------------- /data/main.css.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/data/main.css.gz -------------------------------------------------------------------------------- /data/favicon.ico.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/data/favicon.ico.gz -------------------------------------------------------------------------------- /data/icons-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/data/icons-192.png -------------------------------------------------------------------------------- /data/icons-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/data/icons-512.png -------------------------------------------------------------------------------- /data/index.html.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/data/index.html.gz -------------------------------------------------------------------------------- /env.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "otaHost": "192.168.0.128", 3 | "otaPwd": "OtaPassword" 4 | } -------------------------------------------------------------------------------- /data/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/data/favicon-16x16.png -------------------------------------------------------------------------------- /data/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/data/favicon-32x32.png -------------------------------------------------------------------------------- /data/manifest.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/data/manifest.json.gz -------------------------------------------------------------------------------- /data/icons-vector.svg.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/data/icons-vector.svg.gz -------------------------------------------------------------------------------- /data/service-worker.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/data/service-worker.js.gz -------------------------------------------------------------------------------- /web/src/statics/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/web/src/statics/favicon.ico -------------------------------------------------------------------------------- /web/src/statics/icons-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/web/src/statics/icons-192.png -------------------------------------------------------------------------------- /web/src/statics/icons-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/web/src/statics/icons-512.png -------------------------------------------------------------------------------- /web/src/statics/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/web/src/statics/favicon-16x16.png -------------------------------------------------------------------------------- /web/src/statics/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strange-v/PowerMonitor/HEAD/web/src/statics/favicon-32x32.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pio 2 | .vscode/.browse.c_cpp.db* 3 | .vscode/c_cpp_properties.json 4 | .vscode/launch.json 5 | .vscode/ipch 6 | env.json 7 | node_modules 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PowerMonitor 2 | Yet another device that monitors power, current, voltage, frequency, power factor, and energy. The live data is available on a screen or mobile-friendly web app. 3 | 4 | More details on [hackaday.io](https://hackaday.io/project/183312-power-monitoring). 5 | -------------------------------------------------------------------------------- /web/src/images/back.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/app/components/confirmation/Confirmation.html: -------------------------------------------------------------------------------- 1 |
2 |
{Title}
3 |
{Text}
4 |
5 |
{No}
6 |
{Yes}
7 |
8 |
-------------------------------------------------------------------------------- /web/src/svgo.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | { 4 | name: 'preset-default', 5 | params: { 6 | overrides: { 7 | convertShapeToPath: false, 8 | }, 9 | }, 10 | }, 11 | ], 12 | }; -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "platformio.platformio-ide" 6 | ], 7 | "unwantedRecommendations": [ 8 | "ms-vscode.cpptools-extension-pack" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /include/NodeData.h: -------------------------------------------------------------------------------- 1 | #ifndef NODE_DATA_h 2 | #define NODE_DATA_h 3 | #include 4 | 5 | struct NodeData 6 | { 7 | float voltage; 8 | bool voltageWarn; 9 | float frequency; 10 | float power; 11 | bool powerWarn; 12 | float current; 13 | bool currentWarn; 14 | float energy; 15 | float pf; 16 | uint32_t uptime; 17 | }; 18 | 19 | #endif -------------------------------------------------------------------------------- /src/TimeSync.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | void initTime() 4 | { 5 | configTime(0, 0, moduleSettings.ntpServer); 6 | isTimeSynchronized(); 7 | } 8 | 9 | bool isTimeSynchronized() 10 | { 11 | time_t now; 12 | time(&now); 13 | 14 | tm local; 15 | localtime_r(&now, &local); 16 | 17 | return local.tm_year > (2016 - 1900); 18 | } 19 | -------------------------------------------------------------------------------- /web/src/images/reset.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/images/reload.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /include/Cfg.h: -------------------------------------------------------------------------------- 1 | #ifndef CFG_h 2 | #define CFG_h 3 | #include 4 | 5 | namespace Cfg 6 | { 7 | const uint8_t pinSCL = 15; 8 | const uint8_t pinSDA = 4; 9 | const uint8_t pinRX = 14; 10 | const uint8_t pinTX = 17; 11 | 12 | const uint8_t screenBrightness = 1; 13 | 14 | const char name[] = "Power Monitor"; 15 | const char manufacturer[] = "Just Testing"; 16 | const char model[] = "PM1"; 17 | const char version[] = "1.0.0"; 18 | } 19 | #endif -------------------------------------------------------------------------------- /include/Display.h: -------------------------------------------------------------------------------- 1 | #ifndef DISPLAY_h 2 | #define DISPLAY_h 3 | 4 | #include 5 | extern "C" 6 | { 7 | #include "freertos/FreeRTOS.h" 8 | #include "freertos/timers.h" 9 | } 10 | #include 11 | #include 12 | 13 | #define EVENT_UPDATE_DISPLAY (1 << 10) 14 | 15 | extern EventGroupHandle_t eg; 16 | extern U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2; 17 | extern NodeData currentData; 18 | 19 | void taskUpdateDisplay(void *pvParameters); 20 | 21 | #endif -------------------------------------------------------------------------------- /include/TimeSync.h: -------------------------------------------------------------------------------- 1 | #ifndef TIME_SYNC_h 2 | #define TIME_SYNC_h 3 | 4 | extern "C" 5 | { 6 | #include "freertos/FreeRTOS.h" 7 | #include "freertos/timers.h" 8 | } 9 | #include 10 | #include 11 | #include 12 | 13 | 14 | extern EventGroupHandle_t eg; 15 | extern TimerHandle_t tResetEnergy; 16 | extern TimerHandle_t tHandleChartCalcs; 17 | extern Settings moduleSettings; 18 | extern bool ethConnected; 19 | 20 | void initTime(); 21 | bool isTimeSynchronized(); 22 | 23 | #endif -------------------------------------------------------------------------------- /include/Network.h: -------------------------------------------------------------------------------- 1 | #ifndef NETWORK_h 2 | #define NETWORK_h 3 | 4 | extern "C" 5 | { 6 | #include "freertos/FreeRTOS.h" 7 | #include "freertos/timers.h" 8 | } 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | 16 | extern bool ethConnected; 17 | extern Settings moduleSettings; 18 | 19 | void WiFiEvent(WiFiEvent_t event); 20 | void conectNetworkTimerHandler(); 21 | void initOta(); 22 | void taskHandleOta(void *pvParameters); 23 | 24 | #endif -------------------------------------------------------------------------------- /web/src/images/reboot.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/images/save.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /include/Module.h: -------------------------------------------------------------------------------- 1 | #ifndef MODULE_h 2 | #define MODULE_h 3 | 4 | #include 5 | #include 6 | 7 | #define TaskStack10K 10000 8 | #define TaskStack15K 15000 9 | #define Priority1 1 10 | #define Priority2 2 11 | #define Priority3 3 12 | #define Priority4 4 13 | #define Priority5 5 14 | #define Core0 0 15 | #define Core1 1 16 | #define QUEUE_RECEIVE_DELAY 10 17 | #define TICKS_TO_WAIT0 0 18 | #define TICKS_TO_WAIT12 12 19 | 20 | #ifdef TELNET_DEBUG 21 | #include 22 | extern ESPTelnet telnet; 23 | #endif 24 | 25 | void debugPrint(const char* text); 26 | void debugPrintf(const char *format, ...); 27 | void debugPrint(const IPAddress ip); 28 | double round2(double value); 29 | 30 | #endif 31 | -------------------------------------------------------------------------------- /web/src/statics/service-worker.js: -------------------------------------------------------------------------------- 1 | const cacheName = '{{version}}'; 2 | const contentToCache = [ 3 | '/power/', 4 | '/power/index.html', 5 | '/power/main.js', 6 | '/power/main.css', 7 | '/power/favicon.ico', 8 | '/power/favicon-16x16.png', 9 | '/power/favicon-32x32.png', 10 | '/power/icons-192.png', 11 | '/power/icons-512.png', 12 | ]; 13 | 14 | self.addEventListener('install', (e) => { 15 | console.log('[Service Worker] Install'); 16 | e.waitUntil((async () => { 17 | const cache = await caches.open(cacheName); 18 | console.log('[Service Worker] Caching all: app shell and content'); 19 | await cache.addAll(contentToCache); 20 | })()); 21 | }); 22 | 23 | self.addEventListener('fetch', (e) => {}); 24 | -------------------------------------------------------------------------------- /web/src/statics/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Power Monitor", 3 | "name": "Power Monitor", 4 | "icons": [ 5 | { 6 | "src": "./icons-vector.svg", 7 | "type": "image/svg+xml", 8 | "sizes": "512x512" 9 | }, 10 | { 11 | "src": "./icons-192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "./icons-512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": "/power/", 22 | "background_color": "#1f1f1f", 23 | "display": "standalone", 24 | "scope": "/power/", 25 | "theme_color": "#1f1f1f", 26 | "description": "Allows seeing live data from the power monitoring device and configure it" 27 | } -------------------------------------------------------------------------------- /web/src/app/components/menu/Menu.scss: -------------------------------------------------------------------------------- 1 | ul.menu { 2 | display: none; 3 | position: absolute; 4 | background-color: #424242; 5 | padding: 0; 6 | margin: 0; 7 | font-weight: 300; 8 | font-size: .9em; 9 | border-radius: 5px; 10 | 11 | &.shown { 12 | display: block; 13 | } 14 | li { 15 | list-style: none; 16 | white-space: nowrap; 17 | 18 | &:hover { 19 | background-color: #212121; 20 | } 21 | a { 22 | display: block; 23 | padding: 4px 16px; 24 | cursor: pointer; 25 | } 26 | .icon { 27 | margin-right: 8px; 28 | vertical-align: middle; 29 | } 30 | a { 31 | color: inherit; 32 | text-decoration: none; 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "numeric": "cpp", 4 | "bitset": "cpp", 5 | "algorithm": "cpp", 6 | "ctime": "cpp", 7 | "sstream": "cpp", 8 | "regex": "cpp", 9 | "random": "cpp", 10 | "*.tpp": "cpp", 11 | "array": "cpp", 12 | "*.tcc": "cpp", 13 | "string": "cpp", 14 | "vector": "cpp", 15 | "exception": "cpp", 16 | "string_view": "cpp", 17 | "functional": "cpp", 18 | "iomanip": "cpp", 19 | "istream": "cpp", 20 | "limits": "cpp", 21 | "ostream": "cpp", 22 | "streambuf": "cpp", 23 | "fstream": "cpp", 24 | "iosfwd": "cpp", 25 | "new": "cpp", 26 | "system_error": "cpp", 27 | "tuple": "cpp", 28 | "type_traits": "cpp", 29 | "typeinfo": "cpp" 30 | } 31 | } -------------------------------------------------------------------------------- /include/Pzem.h: -------------------------------------------------------------------------------- 1 | #ifndef PZEM_h 2 | #define PZEM_h 3 | 4 | #include 5 | extern "C" 6 | { 7 | #include "freertos/FreeRTOS.h" 8 | #include "freertos/timers.h" 9 | } 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | 20 | #define EVENT_RETRIEVE_DATA (1 << 1) 21 | #define EVENT_RESET_ENERGY (1 << 2) 22 | 23 | extern EventGroupHandle_t eg; 24 | extern SemaphoreHandle_t semaPzem; 25 | extern PZEM004Tv30 pzem; 26 | extern TimerHandle_t tResetEnergy; 27 | 28 | extern NodeData currentData; 29 | extern Settings moduleSettings; 30 | 31 | void taskRetrieveData(void *pvParameters); 32 | void taskResetEnergy(void *pvParameters); 33 | void resetEnergyTimerHandler(); 34 | bool resetEnergy(); 35 | 36 | #endif -------------------------------------------------------------------------------- /web/src/app/components/confirmation/Confirmation.scss: -------------------------------------------------------------------------------- 1 | .app-dialog { 2 | position: absolute; 3 | top: 0; 4 | bottom: 0; 5 | left: 0; 6 | right: 0; 7 | padding: 0 12px; 8 | background: rgba($color: #000000, $alpha: .8); 9 | 10 | .dialog { 11 | margin: 0% auto 0 auto; 12 | padding: 16px; 13 | background-color: #424242; 14 | border-radius: 5px; 15 | max-width: 400px; 16 | 17 | .title { 18 | font-size: 1.2em; 19 | margin-bottom: 16px; 20 | } 21 | .text { 22 | margin-bottom: 16px; 23 | } 24 | .actions { 25 | display: flex; 26 | justify-content: flex-end; 27 | 28 | .button { 29 | padding: 0 16px; 30 | color: #bb86fc; 31 | cursor: pointer; 32 | } 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/Module.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | void debugPrint(const char *text) 4 | { 5 | #ifdef SERIAL_DEBUG 6 | Serial.println(text); 7 | #endif 8 | #ifdef TELNET_DEBUG 9 | telnet.println(text); 10 | #endif 11 | } 12 | 13 | void debugPrintf(const char *format, ...) 14 | { 15 | va_list args; 16 | va_start(args, format); 17 | 18 | #if defined(SERIAL_DEBUG) || defined(TELNET_DEBUG) 19 | char buffer[128]; 20 | vsnprintf(buffer, sizeof(buffer), format, args); 21 | #endif 22 | #ifdef SERIAL_DEBUG 23 | Serial.print(buffer); 24 | #endif 25 | #ifdef TELNET_DEBUG 26 | telnet.println(buffer); 27 | #endif 28 | va_end(args); 29 | } 30 | 31 | void debugPrint(const IPAddress ip) 32 | { 33 | #ifdef SERIAL_DEBUG 34 | Serial.println(ip); 35 | #endif 36 | #ifdef TELNET_DEBUG 37 | telnet.println(ip); 38 | #endif 39 | } 40 | 41 | double round2(double value) 42 | { 43 | return (int)(value * 100 + 0.5) / 100.0; 44 | } -------------------------------------------------------------------------------- /web/src/app/mixins/NativeEventsMixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | addListener(el, event, callback, scope) { 3 | if (this._listeners === undefined) 4 | this._listeners = []; 5 | 6 | if (typeof el === 'function') 7 | this._listeners.push(el); 8 | else 9 | this._listeners.push(this.subscribe(el, event, callback, scope)); 10 | }, 11 | 12 | subscribe(el, event, callback, scope) { 13 | scope = scope || this; 14 | 15 | if (typeof el === 'string') 16 | el = document.getElementById(el); 17 | 18 | const fn = callback.bind(this); 19 | el.addEventListener(event, fn, false); 20 | 21 | return () => el.removeEventListener(event, fn, false); 22 | }, 23 | 24 | removeAllListeners() { 25 | if (this._listeners) { 26 | this._listeners.forEach(listener => listener()); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /web/src/images/app-spinner.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 12 | 13 | 14 | 22 | 23 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "watch": "webpack-dev-server --mode development --hot", 9 | "dev": "webpack --mode development", 10 | "build": "webpack --mode production" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "compression-webpack-plugin": "^9.2.0", 16 | "copy-webpack-plugin": "^10.2.0", 17 | "css-loader": "^6.5.1", 18 | "html-loader": "^3.1.0", 19 | "html-webpack-plugin": "^5.5.0", 20 | "ini": "^2.0.0", 21 | "mini-css-extract-plugin": "^2.4.5", 22 | "mini-svg-data-uri": "^1.4.3", 23 | "remove-files-webpack-plugin": "^1.5.0", 24 | "sass": "^1.46.0", 25 | "sass-loader": "^12.4.0", 26 | "style-loader": "^3.3.1", 27 | "svgo-loader": "^3.0.0", 28 | "webpack": "^5.65.0", 29 | "webpack-cli": "^4.9.1", 30 | "webpack-dev-server": "^4.7.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /web/src/app/mixins/EventsMixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | on(eventName, handler, scope) { 3 | if (!this._eventHandlers) this._eventHandlers = {}; 4 | if (!this._eventHandlers[eventName]) { 5 | this._eventHandlers[eventName] = []; 6 | } 7 | this._eventHandlers[eventName].push({ handler, scope }); 8 | }, 9 | 10 | un(eventName, handler, scope) { 11 | let handlers = this._eventHandlers?.[eventName]; 12 | if (!handlers) return; 13 | for (let i = 0; i < handlers.length; i++) { 14 | if (handlers[i].handler === handler && handlers[i].scope === scope) { 15 | handlers.splice(i--, 1); 16 | } 17 | } 18 | }, 19 | 20 | trigger(eventName, ...args) { 21 | if (!this._eventHandlers?.[eventName]) return; 22 | 23 | this._eventHandlers[eventName].forEach(cfg => cfg.handler.apply(cfg.scope || this, args)); 24 | }, 25 | 26 | destroy() { 27 | this._eventHandlers = null; 28 | } 29 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 strange_v 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /include/Settings.h: -------------------------------------------------------------------------------- 1 | #ifndef SETTINGS_h 2 | #define SETTINGS_h 3 | 4 | #include 5 | #include 6 | 7 | #define START_ADDR 0 8 | #define SIGNATURE 0x123455F0 9 | #define DEFAULT_PORT 1883 10 | #define DEFAULT_HA_PREFIX "homeassistant" 11 | #define DEFAULT_HA_NODE_ID "pm1" 12 | #define DEFAULT_NTP_SERVER "pool.ntp.org" 13 | #define DEFAULT_OTA_PWD "123456" 14 | 15 | struct Settings 16 | { 17 | uint32_t signature; 18 | 19 | uint16_t voltageMin; 20 | uint16_t voltageMax; 21 | uint16_t powerMax; 22 | uint16_t currentMax; 23 | 24 | bool enableMqtt; 25 | char mqttHost[32]; 26 | uint16_t mqttPort; 27 | char mqttUser[32]; 28 | char mqttPassword[32]; 29 | char mqttTopic[32]; 30 | 31 | char ntpServer[32]; 32 | uint16_t requestDataInterval; 33 | char otaPassword[32]; 34 | uint32_t lastEnergyReset; 35 | float prevEnergy; 36 | 37 | bool enableHomeAssistant; 38 | char haDiscoveryPrefix[16]; 39 | char haNodeId[16]; 40 | }; 41 | 42 | Settings getSettings(); 43 | void saveSettings(Settings newSettings); 44 | Settings _getDefaultSettings(); 45 | 46 | #endif -------------------------------------------------------------------------------- /lib/README: -------------------------------------------------------------------------------- 1 | 2 | This directory is intended for project specific (private) libraries. 3 | PlatformIO will compile them to static libraries and link into executable file. 4 | 5 | The source code of each library should be placed in a an own separate directory 6 | ("lib/your_library_name/[here are source files]"). 7 | 8 | For example, see a structure of the following two libraries `Foo` and `Bar`: 9 | 10 | |--lib 11 | | | 12 | | |--Bar 13 | | | |--docs 14 | | | |--examples 15 | | | |--src 16 | | | |- Bar.c 17 | | | |- Bar.h 18 | | | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html 19 | | | 20 | | |--Foo 21 | | | |- Foo.c 22 | | | |- Foo.h 23 | | | 24 | | |- README --> THIS FILE 25 | | 26 | |- platformio.ini 27 | |--src 28 | |- main.c 29 | 30 | and a contents of `src/main.c`: 31 | ``` 32 | #include 33 | #include 34 | 35 | int main (void) 36 | { 37 | ... 38 | } 39 | 40 | ``` 41 | 42 | PlatformIO Library Dependency Finder will find automatically dependent 43 | libraries scanning project source files. 44 | 45 | More information about PlatformIO Library Dependency Finder 46 | - https://docs.platformio.org/page/librarymanager/ldf.html 47 | -------------------------------------------------------------------------------- /web/src/images/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/app/pages/BasePage.js: -------------------------------------------------------------------------------- 1 | import notifyUpgradableMixin from "../mixins/NotifyUpgradableMixin"; 2 | import nativeEventsMixin from "../mixins/NativeEventsMixin"; 3 | 4 | export default class BasePage { 5 | loading = true; 6 | 7 | init(html) { 8 | Object.assign(this, notifyUpgradableMixin); 9 | Object.assign(this, nativeEventsMixin); 10 | 11 | document.getElementById('app').innerHTML = html; 12 | 13 | this.checkForUpdate(); 14 | } 15 | 16 | destroy() { 17 | this.removeAllListeners(); 18 | document.getElementById('app').innerHTML = ''; 19 | } 20 | 21 | redirectTo(page) { 22 | window.location.hash = page; 23 | } 24 | 25 | showContent() { 26 | if (this.loading) { 27 | this.loading = false; 28 | document.getElementById('content').style.display = 'block'; 29 | document.getElementById('app-spinner').style.display = 'none'; 30 | } 31 | } 32 | 33 | showLoading() { 34 | if (!this.loading) { 35 | this.loading = true; 36 | document.getElementById('content').style.display = 'none'; 37 | document.getElementById('app-spinner').style.display = 'block'; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /include/Mqtt.h: -------------------------------------------------------------------------------- 1 | #ifndef MQTT_h 2 | #define MQTT_h 3 | 4 | #include 5 | extern "C" 6 | { 7 | #include "freertos/FreeRTOS.h" 8 | #include "freertos/timers.h" 9 | } 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | 18 | extern EventGroupHandle_t eg; 19 | extern QueueHandle_t qMqtt; 20 | extern AsyncMqttClient mqtt; 21 | extern TimerHandle_t tConectMqtt; 22 | extern bool ethConnected; 23 | extern NodeData currentData; 24 | extern Settings moduleSettings; 25 | 26 | struct MqttMessage 27 | { 28 | char topic[64]; 29 | char data[512]; 30 | size_t len; 31 | bool retain; 32 | }; 33 | 34 | void configureMqtt(); 35 | void connectToMqttTimerHandler(); 36 | void taskSendMqttMessages(void *pvParameters); 37 | void onMqttConnect(bool sessionPresent); 38 | void onMqttDisconnect(AsyncMqttClientDisconnectReason reason); 39 | void queueMqttDiscoveryMessages(); 40 | MqttMessage composeMqttMessage(NodeData data); 41 | MqttMessage composeMqttDiscoveryMessage(const char *name, const char *unit, const char *stateClass, const char *tpl); 42 | uint16_t _mqttPublish(const char *topic, const char *data, size_t length, bool retain = false); 43 | 44 | #endif -------------------------------------------------------------------------------- /src/Display.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | void taskUpdateDisplay(void *pvParameters) 4 | { 5 | for (;;) 6 | { 7 | if (!xEventGroupWaitBits(eg, EVENT_UPDATE_DISPLAY, pdTRUE, pdTRUE, portMAX_DELAY)) 8 | continue; 9 | 10 | char buf[32]; 11 | uint8_t y = 0; 12 | 13 | u8g2.clearBuffer(); 14 | u8g2.setFont(u8g2_font_fub11_tf); 15 | 16 | sprintf(buf, "%.0f V", currentData.voltage); 17 | y = 13; 18 | u8g2.drawStr(u8g2.getWidth() - u8g2.getStrWidth(buf), y, buf); 19 | u8g2.drawStr(0, y, "Voltage"); 20 | 21 | if (currentData.power < 1000) 22 | sprintf(buf, "%.1f W", currentData.power); 23 | else 24 | sprintf(buf, "%.0f W", currentData.power); 25 | y = 30; 26 | u8g2.drawStr(u8g2.getWidth() - u8g2.getStrWidth(buf), y, buf); 27 | u8g2.drawStr(0, y, "Power"); 28 | 29 | sprintf(buf, "%.2f A", currentData.current); 30 | y = 47; 31 | u8g2.drawStr(u8g2.getWidth() - u8g2.getStrWidth(buf), y, buf); 32 | u8g2.drawStr(0, y, "Current"); 33 | 34 | sprintf(buf, "%.3f kWh", currentData.energy); 35 | y = 64; 36 | u8g2.drawStr((u8g2.getWidth() - u8g2.getStrWidth(buf)) / 2, y, buf); 37 | 38 | u8g2.sendBuffer(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /platformio.ini: -------------------------------------------------------------------------------- 1 | ; PlatformIO Project Configuration File 2 | ; 3 | ; Build options: build flags, source filter 4 | ; Upload options: custom upload port, speed and extra flags 5 | ; Library options: dependencies, extra library storages 6 | ; Advanced options: extra scripting 7 | ; 8 | ; Please visit documentation for the other options and examples 9 | ; https://docs.platformio.org/page/projectconf.html 10 | 11 | [env] 12 | platform = espressif32@^4.3.0 13 | board = esp32dev 14 | framework = arduino 15 | monitor_speed = 115200 16 | extra_scripts = 17 | pre:../env.py 18 | lib_deps = 19 | mandulaj/PZEM-004T-v30 @ ^1.1.2 20 | olikraus/U8g2 @ ^2.32.6 21 | esphome/ESPAsyncWebServer-esphome @ ^2.1.0 22 | bblanchon/ArduinoJson @ ^6.18.5 23 | ottowinter/AsyncMqttClient-esphome @ ^0.8.6 24 | rlogiacco/CircularBuffer @ ^1.3.3 25 | https://github.com/strange-v/ESPTelnet 26 | build_flags = 27 | -D ETH_PHY_TYPE=ETH_PHY_LAN8720 28 | -D ETH_PHY_ADDR=1 29 | -D ETH_PHY_POWER=16 30 | -D ETH_CLK_MODE=ETH_CLOCK_GPIO0_IN 31 | 32 | [env:LOCAL] 33 | build_flags = 34 | ${env.build_flags} 35 | -D SERIAL_DEBUG 36 | 37 | [env:REMOTE] 38 | build_flags = 39 | ${env.build_flags} 40 | -D TELNET_DEBUG 41 | upload_protocol = espota 42 | upload_port = 127.0.0.1 43 | upload_flags = 44 | --auth=123456 45 | -------------------------------------------------------------------------------- /web/src/app/Router.js: -------------------------------------------------------------------------------- 1 | export default class Router extends EventTarget { 2 | constructor() { 3 | super(); 4 | 5 | this._route = null; 6 | window.addEventListener('popstate', () => { 7 | if (this.getRoute() !== this._route) { 8 | this._route = this.getRoute(); 9 | this._dispatchChangeEvent(); 10 | } 11 | }); 12 | } 13 | 14 | init() { 15 | this._route = this.getRoute(); 16 | if (this._route == '') 17 | this.setRoute('/'); 18 | else 19 | this._dispatchChangeEvent(); 20 | } 21 | 22 | on(event, method, scope) { 23 | scope = scope === undefined ? this : scope; 24 | 25 | this.addEventListener(event, e => { 26 | method.bind(scope)(this, e.detail); 27 | }); 28 | } 29 | 30 | setRoute(route) { 31 | window.location.hash = route; 32 | this._route = route; 33 | } 34 | 35 | getRoute() { 36 | return window.location.hash.substring(1); 37 | } 38 | 39 | _dispatchChangeEvent() { 40 | this.dispatchEvent(new CustomEvent('change', { 41 | bubbles: true, 42 | cancelable: false, 43 | detail: { 44 | route: this._route 45 | } 46 | })); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Network.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | void WiFiEvent(WiFiEvent_t event) 4 | { 5 | switch (event) 6 | { 7 | case ARDUINO_EVENT_ETH_START: 8 | debugPrint("ETH Started"); 9 | ETH.setHostname("ESP32-PM1"); 10 | break; 11 | case ARDUINO_EVENT_ETH_CONNECTED: 12 | debugPrint("ETH Connected"); 13 | break; 14 | case ARDUINO_EVENT_ETH_GOT_IP: 15 | debugPrint("IP: "); 16 | debugPrint(ETH.localIP()); 17 | 18 | ethConnected = true; 19 | initOta(); 20 | #ifdef TELNET_DEBUG 21 | telnet.begin(); 22 | #endif 23 | initTime(); 24 | connectToMqttTimerHandler(); 25 | break; 26 | case ARDUINO_EVENT_ETH_DISCONNECTED: 27 | debugPrint("ETH Disconnected"); 28 | ethConnected = false; 29 | break; 30 | case ARDUINO_EVENT_ETH_STOP: 31 | debugPrint("ETH Stopped"); 32 | ethConnected = false; 33 | break; 34 | default: 35 | break; 36 | } 37 | } 38 | 39 | void conectNetworkTimerHandler() 40 | { 41 | if (!ethConnected) 42 | { 43 | ETH.begin(); 44 | } 45 | } 46 | 47 | void initOta() 48 | { 49 | ArduinoOTA.setPassword(moduleSettings.otaPassword); 50 | ArduinoOTA.begin(); 51 | } 52 | 53 | void taskHandleOta(void *pvParameters) 54 | { 55 | for (;;) 56 | { 57 | if (ethConnected) 58 | ArduinoOTA.handle(); 59 | 60 | delay(2000); 61 | } 62 | } -------------------------------------------------------------------------------- /src/Settings.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | Settings getSettings() 4 | { 5 | Settings settings; 6 | EEPROM.readBytes(START_ADDR, &settings, sizeof(Settings)); 7 | 8 | if (settings.signature != SIGNATURE) 9 | { 10 | settings = _getDefaultSettings(); 11 | saveSettings(settings); 12 | } 13 | 14 | return settings; 15 | } 16 | 17 | void saveSettings(Settings newSettings) 18 | { 19 | newSettings.signature = SIGNATURE; 20 | EEPROM.writeBytes(START_ADDR, &newSettings, sizeof(Settings)); 21 | EEPROM.commit(); 22 | } 23 | 24 | Settings _getDefaultSettings() 25 | { 26 | Settings settings = {SIGNATURE, 0, 0, 0, 0}; 27 | 28 | settings.enableMqtt = false; 29 | settings.mqttHost[0] = '\0'; 30 | settings.mqttPort = DEFAULT_PORT; 31 | settings.mqttUser[0] = '\0'; 32 | settings.mqttPassword[0] = '\0'; 33 | settings.mqttTopic[0] = '\0'; 34 | settings.enableHomeAssistant = false; 35 | settings.requestDataInterval = 1000; 36 | settings.lastEnergyReset = 0; 37 | settings.prevEnergy = 0; 38 | settings.enableHomeAssistant = false; 39 | strlcpy(settings.haDiscoveryPrefix, DEFAULT_HA_PREFIX, sizeof(DEFAULT_HA_PREFIX)); 40 | strlcpy(settings.haNodeId, DEFAULT_HA_NODE_ID, sizeof(DEFAULT_HA_NODE_ID)); 41 | strlcpy(settings.otaPassword, DEFAULT_OTA_PWD, sizeof(DEFAULT_OTA_PWD)); 42 | strlcpy(settings.ntpServer, DEFAULT_NTP_SERVER, sizeof(DEFAULT_NTP_SERVER)); 43 | return settings; 44 | } -------------------------------------------------------------------------------- /include/WebServer.h: -------------------------------------------------------------------------------- 1 | #ifndef WEB_SERVER_h 2 | #define WEB_SERVER_h 3 | 4 | extern "C" 5 | { 6 | #include "freertos/FreeRTOS.h" 7 | #include "freertos/timers.h" 8 | } 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | 20 | #define CONTENT_TYPE_TEXT "text/plain" 21 | #define CONTENT_TYPE_HTML "text/html" 22 | #define CONTENT_TYPE_CSS "text/css" 23 | #define CONTENT_TYPE_JS "text/javascript" 24 | #define CONTENT_TYPE_JSON "application/json" 25 | #define EVENT_UPDATE_WEB_CLIENTS (1 << 20) 26 | 27 | extern AsyncWebServer server; 28 | extern AsyncWebSocket ws; 29 | extern EventGroupHandle_t eg; 30 | 31 | extern StaticJsonDocument<2048> webDoc; 32 | extern char webDataBuffer[4096]; 33 | extern SemaphoreHandle_t semaWebDataBuffer; 34 | extern NodeData currentData; 35 | extern Settings moduleSettings; 36 | 37 | void initWebServer(); 38 | void taskUpdateWebClients(void *pvParameters); 39 | void cleanupWebSocketsTimerHandler(); 40 | void _onWebSocketEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len); 41 | void _getSettings(AsyncWebServerRequest *request); 42 | void _saveSettings(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total); 43 | void _resetEnergy(AsyncWebServerRequest *request); 44 | void _reboot(AsyncWebServerRequest *request); 45 | void _getDebug(AsyncWebServerRequest *request); 46 | void _notFound(AsyncWebServerRequest *request); 47 | 48 | #endif -------------------------------------------------------------------------------- /web/src/app/pages/liveData/LiveDataPage.scss: -------------------------------------------------------------------------------- 1 | .live-data-page { 2 | .cards { 3 | display: grid; 4 | grid-template-columns: calc(50% - 6px) calc(50% - 6px); 5 | justify-content: space-between; 6 | padding: 0 12px; 7 | 8 | .card { 9 | height: 120px; 10 | background-color: #1d1d1d; 11 | margin-bottom: 12px; 12 | padding: 16px 16px 12px 16px; 13 | box-sizing: border-box; 14 | border-radius: 5px; 15 | 16 | &.clickable { 17 | cursor: pointer; 18 | } 19 | &.warning { 20 | background-color: #cf6679; 21 | color: #000; 22 | 23 | .title { 24 | opacity: 1; 25 | } 26 | } 27 | 28 | .title { 29 | opacity: 38%; 30 | margin-bottom: 18px; 31 | } 32 | .value { 33 | font-size: 1.8em; 34 | } 35 | .footer { 36 | margin-top: 4px; 37 | opacity: 38%; 38 | text-align: right; 39 | } 40 | } 41 | 42 | #uptime { 43 | font-size: 1.5em; 44 | } 45 | } 46 | @media (min-width: 768px) { 47 | .cards { 48 | grid-template-columns: calc(33.3% - 8px) calc(33.3% - 8px) calc(33.3% - 8px); 49 | } 50 | } 51 | 52 | @media (min-width: 1024px) { 53 | .cards { 54 | grid-template-columns: calc(25% - 10px) calc(25% - 10px) calc(25% - 10px) calc(25% - 10px); 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /web/src/images/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/app/utils/Formatter.js: -------------------------------------------------------------------------------- 1 | export default class Formatter { 2 | static uptime = (value) => { 3 | const days = Math.floor(value / (24 * 60 * 60)); 4 | let hours = Math.floor(value / (60 * 60)); 5 | const minutes = Math.floor(value / 60) % 60; 6 | const seconds = value % 60; 7 | 8 | let result = [{ value: hours, suffix: 'h' }, 9 | { value: minutes, suffix: 'm' }, 10 | { value: seconds, suffix: 's' }]; 11 | 12 | if (days > 1) { 13 | hours = hours - days * 24; 14 | result = [{ value: days, suffix: 'd' }, 15 | { value: hours, suffix: 'h' }, 16 | { value: minutes, suffix: 'm' }]; 17 | } 18 | 19 | return result 20 | .map(data => { 21 | return `${data.value}${data.suffix}` 22 | }) 23 | .join(' ') 24 | } 25 | static power = (v) => this.autoNumber(v, 1, ['W', 'KW', 'MW']) 26 | static current = (v) => this.autoNumber(v, 2, ['A', 'kA']) 27 | static voltage = (v) => this.autoNumber(v, 1, ['V', 'kV']) 28 | static frequency = (v) => this.autoNumber(v, 1, ['Hz', 'kHz']) 29 | static powerFactor = (v) => this.number(v, 2) 30 | static energy = (v) => this.autoNumber(v, 1, ['kWh', 'MWh']) 31 | static autoNumber = (value, scale, units) => { 32 | let unitText = ''; 33 | const currentUnit = Math.floor((Number(value).toFixed(0).length - 1) / 3) * 3; 34 | const unitName = units[Math.floor(currentUnit / 3)]; 35 | const num = value / ('1e' + currentUnit); 36 | 37 | unitText = `${this.number(num, scale)} ${unitName}`; 38 | 39 | return unitText; 40 | } 41 | static number = (v, scale, suffix = '') => { 42 | return v.toFixed(scale).replace(/([0-9]+(\.[0-9]+[1-9])?)(\.?0+$)/, '$1'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /web/src/app/mixins/NotifyUpgradableMixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | checkForUpdate() { 3 | const msgUpdate = 'New Update available! Please, reload the webapp to see the latest changes.'; 4 | 5 | this._isUpdateAvailable() 6 | .then(isAvailable => { 7 | if (isAvailable) 8 | this._showNotification(msgUpdate); 9 | }); 10 | }, 11 | 12 | _showNotification(text) { 13 | document.getElementById('notification').style.display = 'block'; 14 | document.getElementById('notification-text').innerText = text; 15 | }, 16 | 17 | _isUpdateAvailable() { 18 | return new Promise(function (resolve, reject) { 19 | if ('serviceWorker' in navigator && ['localhost', '127'].indexOf(location.hostname) === -1) { 20 | navigator.serviceWorker.register('/power/service-worker.js') 21 | .then(reg => { 22 | reg.onupdatefound = () => { 23 | const installingWorker = reg.installing; 24 | installingWorker.onstatechange = () => { 25 | switch (installingWorker.state) { 26 | case 'installed': 27 | if (navigator.serviceWorker.controller) { 28 | resolve(true); 29 | } else { 30 | resolve(false); 31 | } 32 | break; 33 | } 34 | }; 35 | }; 36 | }) 37 | .catch(err => console.error('[SW ERROR]', err)); 38 | } 39 | }); 40 | } 41 | } -------------------------------------------------------------------------------- /web/src/app/pages/liveData/LiveDataPage.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
Power Monitoring
4 | 7 |
8 |
9 |
10 |
Live data
11 |
12 |
13 |
Power
14 |
15 |
16 |
17 |
Current
18 |
19 |
20 |
21 |
Voltage
22 |
23 |
24 |
25 |
Frequency
26 |
27 |
28 |
29 |
Power Factor
30 |
31 |
32 |
33 |
Energy
34 |
35 | 36 |
37 |
38 |
Uptime
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
-------------------------------------------------------------------------------- /web/src/app/components/menu/Menu.js: -------------------------------------------------------------------------------- 1 | import scss from './Menu.scss'; 2 | import nativeEventsMixin from 'app/mixins/NativeEventsMixin'; 3 | 4 | export default class Menu { 5 | constructor(config) { 6 | Object.assign(this, nativeEventsMixin); 7 | 8 | this._cfg = config; 9 | 10 | this.addListener(this._cfg.el, 'click', this._onMenuButtonClick); 11 | 12 | this._menu = document.createElement('ul'); 13 | this._menu.classList.add('menu'); 14 | this._initMenuItems(); 15 | } 16 | 17 | destroy() { 18 | this.removeAllListeners(); 19 | } 20 | 21 | show() { 22 | if (!this._rendered) { 23 | this._rendered = true; 24 | this._cfg.el.appendChild(this._menu); 25 | } 26 | 27 | this._menu.classList.add('shown'); 28 | } 29 | 30 | hide() { 31 | this._menu.classList.remove('shown'); 32 | } 33 | 34 | _onMenuButtonClick(e) { 35 | this.show(); 36 | 37 | if (this._destroyOutsideMenuClick) return; 38 | this._destroyOutsideMenuClick = this.subscribe(document.body, 'click', this._onOutsideMenuClick); 39 | } 40 | 41 | _onOutsideMenuClick(e) { 42 | if (!e.composedPath().includes(this._cfg.el)) { 43 | this.hide(); 44 | this._destroyOutsideMenuClick(); 45 | this._destroyOutsideMenuClick = null; 46 | } 47 | } 48 | 49 | _initMenuItems() { 50 | this._cfg.items.forEach(item => { 51 | const li = document.createElement('li'); 52 | const a = document.createElement('a'); 53 | a.innerHTML = item.iconCls 54 | ? ` ${item.text}` 55 | : `${item.text}` 56 | this.addListener(a, 'click', e => { 57 | this.hide(); 58 | item.handler.apply(this._cfg.scope || this, [e]); 59 | }); 60 | li.appendChild(a); 61 | this._menu.appendChild(li); 62 | }); 63 | } 64 | } -------------------------------------------------------------------------------- /web/src/statics/icons-vector.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/index.js: -------------------------------------------------------------------------------- 1 | import LiveDataPage from "app/pages/liveData/LiveDataPage"; 2 | import SettingsPage from "app/pages/settings/SettingsPage"; 3 | import Router from "app/Router"; 4 | import "./main.scss"; 5 | 6 | class App { 7 | routes = { 8 | '/': LiveDataPage, 9 | '/settings': SettingsPage 10 | }; 11 | 12 | init() { 13 | this._router = new Router; 14 | this._router.on('change', this._onRouteChange, this); 15 | 16 | this._router.init(); 17 | } 18 | 19 | _onRouteChange(_, e) { 20 | let route; 21 | for (const [key, view] of Object.entries(this.routes)) { 22 | route = this._buildRoute(key, view)(e.route); 23 | 24 | if (route.matched) { 25 | if (this._view) { 26 | this._view.destroy(); 27 | delete this._view; 28 | } 29 | 30 | this._view = new route.view; 31 | this._view.init(route.params); 32 | return; 33 | } 34 | } 35 | 36 | if (this.routes[e.route] === undefined) { 37 | throw 'Implement 404'; 38 | } 39 | } 40 | 41 | _buildRoute(route, view) { 42 | const types = { 43 | int: { regexp: '[0-9.]+', get: v => parseInt(v, 10) }, 44 | string: { regexp: '[a-z.-]+', get: v => v }, 45 | }; 46 | const names = []; 47 | 48 | const part = route.replace(/{([a-z]+):([a-z]+)}/ig, (str, name, type) => { 49 | names.push({ name, get: types[type].get }); 50 | return `(${types[type].regexp})`; 51 | }); 52 | const regexp = new RegExp(`^${part}$`, 'i'); 53 | 54 | return (hash) => { 55 | const match = hash.match(regexp); 56 | const result = { 57 | matched: match != null, 58 | view: view, 59 | params: {} 60 | } 61 | if (names) { 62 | result.hasParams = true; 63 | names.forEach((n, i) => { 64 | result.params[n.name] = n.get(match[i + 1]); 65 | }); 66 | } 67 | 68 | return result; 69 | }; 70 | } 71 | } 72 | 73 | const app = new App; 74 | 75 | document.addEventListener("DOMContentLoaded", () => app.init()); -------------------------------------------------------------------------------- /web/src/app/utils/WebSocketWrapper.js: -------------------------------------------------------------------------------- 1 | import eventsMixin from "../mixins/EventsMixin"; 2 | 3 | export default class WebSocketWrapper { 4 | constructor(gateway, config) { 5 | Object.assign(this, eventsMixin); 6 | 7 | this._initConfig(gateway, config); 8 | 9 | this._onOpen = this._onWebSoketOpen.bind(this); 10 | this._onClose = this._onWebSoketClose.bind(this); 11 | this._onMessage = this._onWebSoketMessage.bind(this); 12 | this._initWebSocket(); 13 | } 14 | 15 | destroy() { 16 | this.suppressEvents(true); 17 | clearTimeout(this._reconnect); 18 | this._ws.removeEventListener('open', this._onOpen, true); 19 | this._ws.removeEventListener('close', this._onClose, true); 20 | this._ws.removeEventListener('message', this._onMessage, true); 21 | this._ws.close(); 22 | this._ws = null; 23 | } 24 | 25 | suppressEvents(suppress) { 26 | this._suppressEvents = suppress; 27 | } 28 | 29 | _onWebSoketOpen() { 30 | if (this._suppressEvents) return; 31 | this.trigger('open'); 32 | } 33 | 34 | _onWebSoketClose() { 35 | if (!this._destroying && this._cfg.reconnect > 0) { 36 | this._reconnect = setTimeout(() => this._initWebSocket(), this._cfg.reconnect); 37 | } 38 | if (this._suppressEvents) return; 39 | this.trigger('close'); 40 | } 41 | 42 | _onWebSoketMessage(data) { 43 | if (this._suppressEvents) return; 44 | this.trigger('message', data); 45 | } 46 | 47 | _initWebSocket() { 48 | if (this._ws) { 49 | this._ws.removeEventListener('open', this._onOpen, true); 50 | this._ws.removeEventListener('close', this._onClose, true); 51 | this._ws.removeEventListener('message', this._onMessage, true); 52 | this._ws.close(); 53 | this._ws = null; 54 | } 55 | 56 | this._ws = new WebSocket(this._cfg.gateway); 57 | this._ws.addEventListener('open', this._onOpen, true); 58 | this._ws.addEventListener('close', this._onClose, true); 59 | this._ws.addEventListener('message', this._onMessage, true); 60 | } 61 | 62 | _initConfig(gateway, cfg) { 63 | this._cfg = { 64 | gateway, 65 | reconnect: 2000 66 | }; 67 | 68 | Object.assign(this._cfg, cfg || {}); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /web/src/app/components/confirmation/Confirmation.js: -------------------------------------------------------------------------------- 1 | import html from './Confirmation.html'; 2 | import scss from './Confirmation.scss'; 3 | import eventsMixin from "app/mixins/EventsMixin"; 4 | 5 | export default class Confirmation { 6 | constructor(text, title, yes, no) { 7 | Object.assign(this, eventsMixin); 8 | 9 | if (typeof text == 'object') { 10 | const cfg = text; 11 | this.yes = cfg.yes || 'Yes'; 12 | this.no = cfg.no || 'Cancel'; 13 | this.title = cfg.title || 'Confirmation'; 14 | this.text = cfg.text; 15 | this._attachListeners(cfg.listeners); 16 | } else { 17 | this.yes = yes || 'Yes'; 18 | this.no = no || 'Cancel'; 19 | this.title = title || 'Confirmation'; 20 | this.text = text; 21 | } 22 | } 23 | 24 | show(el) { 25 | this._confirmation = document.createElement('div') 26 | this._confirmation.classList.add('app-dialog'); 27 | this._confirmation.innerHTML = html 28 | .replace(['{Title}'], [this.title]) 29 | .replace(['{Text}'], [this.text]) 30 | .replace(['{Yes}'], [this.yes]) 31 | .replace(['{No}'], [this.no]) 32 | 33 | el.appendChild(this._confirmation); 34 | const dialog = this._confirmation.children[0]; 35 | const marginTop = (document.body.clientHeight - dialog.offsetHeight) / 2; 36 | dialog.style.marginTop = marginTop - marginTop * 0.1; 37 | 38 | this._confirmation.getElementsByClassName('btn-no')[0].addEventListener('click', () => this._onAction(false), { 39 | once: true, 40 | capture: true 41 | }); 42 | this._confirmation.getElementsByClassName('btn-yes')[0].addEventListener('click', () => this._onAction(true), { 43 | once: true, 44 | capture: true 45 | }); 46 | } 47 | 48 | _attachListeners(listeners) { 49 | if (!listeners) return; 50 | 51 | const scope = listeners.scope || this; 52 | for (const [eventName, handler] of Object.entries(listeners)) { 53 | if (eventName === 'scope') continue; 54 | this.on(eventName, handler, scope); 55 | } 56 | } 57 | 58 | _onAction(response) { 59 | this._confirmation.remove(); 60 | this.trigger('change', response); 61 | if (response) this.trigger('confirm'); 62 | this.destroy(); 63 | } 64 | 65 | destroy() { 66 | self.destroy(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Pzem.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | void taskRetrieveData(void *pvParameters) 4 | { 5 | for (;;) 6 | { 7 | if (!xEventGroupWaitBits(eg, EVENT_RETRIEVE_DATA, pdTRUE, pdTRUE, portMAX_DELAY)) 8 | continue; 9 | 10 | if (xSemaphoreTake(semaPzem, TICKS_TO_WAIT12) == pdTRUE) 11 | { 12 | uint32_t uptime = millis() / 1000; 13 | float voltage = pzem.voltage(); 14 | if (isnan(voltage)) 15 | { 16 | xSemaphoreGive(semaPzem); 17 | currentData = {0, false, 0, 0, false, 0, false, 0, 0, uptime}; 18 | return; 19 | } 20 | 21 | currentData.voltage = voltage; 22 | currentData.current = pzem.current(); 23 | currentData.power = pzem.power(); 24 | currentData.energy = pzem.energy(); 25 | currentData.frequency = pzem.frequency(); 26 | currentData.pf = pzem.pf(); 27 | 28 | xSemaphoreGive(semaPzem); 29 | 30 | currentData.uptime = uptime; 31 | currentData.voltageWarn = 0; 32 | currentData.powerWarn = 0; 33 | currentData.currentWarn = 0; 34 | 35 | if (moduleSettings.voltageMin > 0 && moduleSettings.voltageMax > 0) 36 | currentData.voltageWarn = currentData.voltage <= moduleSettings.voltageMin || currentData.voltage >= moduleSettings.voltageMax; 37 | if (moduleSettings.powerMax > 0) 38 | currentData.powerWarn = currentData.power >= moduleSettings.powerMax; 39 | if (moduleSettings.currentMax > 0) 40 | currentData.currentWarn = currentData.current >= moduleSettings.currentMax; 41 | 42 | xEventGroupSetBits(eg, EVENT_UPDATE_DISPLAY | EVENT_UPDATE_WEB_CLIENTS); 43 | 44 | // ToDo: Probably move to a separate task 45 | if (moduleSettings.enableMqtt) 46 | { 47 | MqttMessage msg = composeMqttMessage(currentData); 48 | if (xQueueSendToBack(qMqtt, &msg, QUEUE_RECEIVE_DELAY) != pdPASS) 49 | { 50 | debugPrint("Failed to add to the mqtt queue"); 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | void taskResetEnergy(void *pvParameters) 58 | { 59 | for (;;) 60 | { 61 | if (!xEventGroupWaitBits(eg, EVENT_RESET_ENERGY, pdTRUE, pdTRUE, portMAX_DELAY)) 62 | continue; 63 | 64 | if (!isTimeSynchronized()) 65 | continue; 66 | 67 | time_t now; 68 | tm local; 69 | 70 | time(&now); 71 | localtime_r(&now, &local); 72 | 73 | uint32_t exprectedDate = static_cast(local.tm_year); 74 | exprectedDate = exprectedDate << 8 | static_cast(local.tm_mon); 75 | 76 | if (moduleSettings.lastEnergyReset != exprectedDate) 77 | { 78 | moduleSettings.lastEnergyReset = exprectedDate; 79 | moduleSettings.prevEnergy = currentData.energy; 80 | resetEnergy(); 81 | saveSettings(moduleSettings); 82 | } 83 | 84 | xTimerChangePeriod(tResetEnergy, pdMS_TO_TICKS(5 * 60 * 1000), portMAX_DELAY); 85 | } 86 | } 87 | 88 | void resetEnergyTimerHandler() 89 | { 90 | xEventGroupSetBits(eg, EVENT_RESET_ENERGY); 91 | } 92 | 93 | bool resetEnergy() 94 | { 95 | bool result = false; 96 | if (xSemaphoreTake(semaPzem, TICKS_TO_WAIT12) == pdTRUE) 97 | { 98 | result = pzem.resetEnergy(); 99 | xSemaphoreGive(semaPzem); 100 | } 101 | return result; 102 | } 103 | -------------------------------------------------------------------------------- /web/src/app/pages/settings/SettingsPage.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
Power Monitoring
5 | 6 |
7 |
8 |
9 |
10 |
Limits
11 | 12 |
13 | 14 | 15 |
16 | 17 |
18 | 19 | 20 |
21 | 22 |
23 | 24 | 25 |
26 | 27 |
28 | 29 | 30 |
31 | 32 | 33 |
MQTT Settings
34 | 35 |
36 | 37 | 38 |
39 | 40 |
41 | 42 | 43 |
44 | 45 |
46 | 47 | 48 |
49 | 50 |
51 | 52 | 53 |
54 | 55 |
56 | 57 | 58 |
59 | 60 |
61 | 62 | 63 |
64 | 65 |
66 | 67 | 68 |
69 | 70 |
71 | 72 | 73 |
74 | 75 | 76 |
System
77 | 78 |
79 | 80 | 81 |
82 | 83 |
84 | 85 | 86 |
87 | 88 |
89 | 90 | 91 |
92 |
93 |
94 |
95 |
96 |
97 |
-------------------------------------------------------------------------------- /src/main.cpp: -------------------------------------------------------------------------------- 1 | extern "C" 2 | { 3 | #include "freertos/FreeRTOS.h" 4 | #include "freertos/timers.h" 5 | } 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | #include 20 | #include 21 | #include 22 | #include 23 | #include 24 | #include 25 | #include 26 | #include 27 | #include 28 | 29 | #ifdef TELNET_DEBUG 30 | #include 31 | ESPTelnet telnet; 32 | #endif 33 | 34 | void requestDataTimerHandler(); 35 | 36 | EventGroupHandle_t eg; 37 | QueueHandle_t qMqtt; 38 | TimerHandle_t tRequestData; 39 | TimerHandle_t tConectMqtt; 40 | TimerHandle_t tConectNetwork; 41 | TimerHandle_t tResetEnergy; 42 | TimerHandle_t tCleanupWebSockets; 43 | TimerHandle_t tHandleChartCalcs; 44 | 45 | U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE, Cfg::pinSCL, Cfg::pinSDA); 46 | PZEM004Tv30 pzem(Serial1, Cfg::pinRX, Cfg::pinTX); 47 | SemaphoreHandle_t semaPzem; 48 | AsyncWebServer server(80); 49 | AsyncWebSocket ws("/power/ws"); 50 | AsyncMqttClient mqtt; 51 | StaticJsonDocument<2048> webDoc; 52 | char webDataBuffer[4096]; 53 | SemaphoreHandle_t semaWebDataBuffer; 54 | NodeData currentData; 55 | Settings moduleSettings; 56 | 57 | bool ethConnected = false; 58 | 59 | void setup() 60 | { 61 | #ifdef SERIAL_DEBUG 62 | Serial.begin(115200); 63 | Serial.println(F("Starting...")); 64 | #endif 65 | debugPrint("DEBUG MODE ON"); 66 | 67 | eg = xEventGroupCreate(); 68 | qMqtt = xQueueCreate(10, sizeof(MqttMessage)); 69 | semaPzem = xSemaphoreCreateMutex(); 70 | semaWebDataBuffer = xSemaphoreCreateMutex(); 71 | 72 | SPIFFS.begin(true); 73 | EEPROM.begin(sizeof(Settings)); 74 | 75 | moduleSettings = getSettings(); 76 | 77 | u8g2.begin(); 78 | u8g2.setContrast(Cfg::screenBrightness); 79 | 80 | configureMqtt(); 81 | WiFi.onEvent(WiFiEvent); 82 | ETH.begin(); 83 | 84 | xTaskCreatePinnedToCore(taskHandleOta, "ota", TaskStack15K, NULL, Priority5, NULL, Core1); 85 | xTaskCreatePinnedToCore(taskRetrieveData, "rd", TaskStack15K, NULL, Priority3, NULL, Core1); 86 | xTaskCreatePinnedToCore(taskUpdateDisplay, "ud", TaskStack10K, NULL, Priority3, NULL, Core1); 87 | xTaskCreatePinnedToCore(taskUpdateWebClients, "uwc", TaskStack15K, NULL, Priority3, NULL, Core1); 88 | xTaskCreatePinnedToCore(taskSendMqttMessages, "tMqtt", TaskStack10K, NULL, Priority2, NULL, Core1); 89 | xTaskCreatePinnedToCore(taskResetEnergy, "re", TaskStack10K, NULL, Priority2, NULL, Core1); 90 | 91 | tRequestData = xTimerCreate("rd", pdMS_TO_TICKS(moduleSettings.requestDataInterval), pdTRUE, (void *)0, reinterpret_cast(requestDataTimerHandler)); 92 | tConectMqtt = xTimerCreate("cm", pdMS_TO_TICKS(10000), pdTRUE, (void *)0, reinterpret_cast(connectToMqttTimerHandler)); 93 | tConectNetwork = xTimerCreate("cn", pdMS_TO_TICKS(20000), pdTRUE, (void *)0, reinterpret_cast(conectNetworkTimerHandler)); 94 | tResetEnergy = xTimerCreate("re", pdMS_TO_TICKS(60000), pdTRUE, (void *)0, reinterpret_cast(resetEnergyTimerHandler)); 95 | tCleanupWebSockets = xTimerCreate("cw", pdMS_TO_TICKS(1000), pdTRUE, (void *)0, reinterpret_cast(cleanupWebSocketsTimerHandler)); 96 | 97 | initWebServer(); 98 | 99 | xTimerStart(tRequestData, 0); 100 | xTimerStart(tConectNetwork, 0); 101 | xTimerStart(tCleanupWebSockets, 0); 102 | xTimerStart(tResetEnergy, 0); 103 | } 104 | 105 | void loop() 106 | { 107 | if (ethConnected) 108 | { 109 | #ifdef TELNET_DEBUG 110 | telnet.loop(); 111 | #endif 112 | } 113 | } 114 | 115 | void requestDataTimerHandler() 116 | { 117 | xEventGroupSetBits(eg, EVENT_RETRIEVE_DATA); 118 | } 119 | -------------------------------------------------------------------------------- /web/src/app/pages/liveData/LiveDataPage.js: -------------------------------------------------------------------------------- 1 | import html from './LiveDataPage.html'; 2 | import scss from './LiveDataPage.scss'; 3 | import BasePage from 'app/pages/BasePage'; 4 | import Formatter from 'app//utils/Formatter'; 5 | import WebSocketWrapper from 'app//utils/WebSocketWrapper'; 6 | import Confirmation from 'app//components/confirmation/Confirmation'; 7 | import Menu from 'app/components/menu/Menu'; 8 | 9 | export default class LiveDataPage extends BasePage { 10 | init() { 11 | super.init(html); 12 | this._initWebSocket(); 13 | this._initControls(); 14 | } 15 | 16 | destroy() { 17 | this._ws.destroy(); 18 | super.destroy(); 19 | } 20 | 21 | _onResetEnergyClick(e) { 22 | e.stopPropagation(); 23 | 24 | const confirmation = new Confirmation({ 25 | text: 'Are you sure you want to reset the energy counter?', 26 | listeners: { 27 | confirm: this._resetEnergy, 28 | scope: this 29 | } 30 | }); 31 | confirmation.show(document.getElementById('app')); 32 | } 33 | 34 | _onSettingsClick(e) { 35 | e.stopPropagation(); 36 | 37 | this.redirectTo('/settings'); 38 | } 39 | 40 | _onRebootClick(e) { 41 | e.stopPropagation(); 42 | 43 | fetch('/power/api/reboot') 44 | .then(() => location.reload()) 45 | .catch(e => console.log(e)); 46 | } 47 | 48 | _resetEnergy() { 49 | this._ws.suppressEvents(true); 50 | this.showLoading(); 51 | 52 | fetch('/power/api/resetEnergy', { 53 | method: 'POST', 54 | headers: { 'Content-Type': 'application/json' }, 55 | body: '' 56 | }).then(() => { 57 | this._ws.suppressEvents(false); 58 | }); 59 | } 60 | 61 | _initWebSocket() { 62 | const protocol = window.location.protocol == 'https:' ? 'wss' : 'ws', 63 | gateway = `${protocol}://${window.location.hostname}:${window.location.port}/power/ws`; 64 | 65 | this._ws = new WebSocketWrapper(gateway, { reconnect: 2000 }); 66 | this._ws.on('message', this._onMessage, this); 67 | } 68 | 69 | _onMessage(e) { 70 | const data = JSON.parse(e.data), 71 | power = document.getElementById('power'), 72 | current = document.getElementById('current'), 73 | voltage = document.getElementById('voltage'), 74 | frequency = document.getElementById('frequency'), 75 | pf = document.getElementById('pf'), 76 | energy = document.getElementById('energy'), 77 | prevEnergy = document.getElementById('prevEnergy'), 78 | uptime = document.getElementById('uptime'); 79 | 80 | power.innerText = Formatter.power(data.p); 81 | current.innerText = Formatter.current(data.c); 82 | voltage.innerText = Formatter.voltage(data.v); 83 | frequency.innerText = Formatter.frequency(data.f); 84 | pf.innerText = Formatter.powerFactor(data.pf); 85 | energy.innerText = Formatter.energy(data.e); 86 | prevEnergy.innerText = Formatter.energy(data.pe); 87 | uptime.innerText = Formatter.uptime(data.u); 88 | 89 | if (data.pw) 90 | power.parentElement.classList.add('warning') 91 | else 92 | power.parentElement.classList.remove('warning'); 93 | 94 | if (data.cw) 95 | current.parentElement.classList.add('warning') 96 | else 97 | current.parentElement.classList.remove('warning'); 98 | 99 | if (data.vw) 100 | voltage.parentElement.classList.add('warning') 101 | else 102 | voltage.parentElement.classList.remove('warning'); 103 | 104 | this.showContent(); 105 | } 106 | 107 | _initControls() { 108 | this._menu = new Menu({ 109 | el: document.getElementById('btn-menu'), 110 | items: [{ 111 | text: 'Settings', 112 | iconCls: 'settings', 113 | handler: this._onSettingsClick 114 | }, { 115 | text: 'Reset Energy', 116 | iconCls: 'reset', 117 | handler: this._onResetEnergyClick 118 | }, { 119 | text: 'Reboot', 120 | iconCls: 'reboot', 121 | handler: this._onRebootClick 122 | }], 123 | scope: this 124 | }); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /web/src/main.scss: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&display=swap"); 2 | 3 | * { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | padding: 0; 9 | margin: 0; 10 | background-color: #121212; 11 | color: #fff; 12 | font-family: Roboto; 13 | font-size: 16px; 14 | } 15 | 16 | .header { 17 | position: fixed; 18 | display: flex; 19 | height: 48px; 20 | width: 100%; 21 | background-color: #1f1f1f; 22 | font-size: 22px; 23 | line-height: 48px; 24 | padding: 0 8px 0 24px; 25 | box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); 26 | margin-bottom: 8px; 27 | z-index: 10; 28 | 29 | .title { 30 | margin-right: auto; 31 | } 32 | 33 | >.button { 34 | >.icon { 35 | margin-top: 8px; 36 | } 37 | 38 | &.btn-back { 39 | margin-left: -16px; 40 | margin-right: 8px; 41 | } 42 | } 43 | 44 | .button { 45 | position: relative; 46 | 47 | .icon { 48 | display: inline-block; 49 | height: 32px; 50 | width: 32px; 51 | cursor: pointer; 52 | 53 | &.back { 54 | background: url("images/back.svg") center center no-repeat; 55 | background-size: 24px 24px; 56 | } 57 | 58 | &.settings { 59 | background: url("images/settings.svg") center center no-repeat; 60 | background-size: 24px 24px; 61 | } 62 | 63 | &.reset { 64 | background: url("images/reset.svg") center center no-repeat; 65 | background-size: 24px 24px; 66 | } 67 | 68 | &.save { 69 | background: url("images/save.svg") center center no-repeat; 70 | background-size: 24px 24px; 71 | } 72 | 73 | &.menu { 74 | background: url("images/menu.svg") center center no-repeat; 75 | background-size: 24px 24px; 76 | } 77 | 78 | &.reload { 79 | background: url("images/reload.svg") center center no-repeat; 80 | background-size: 24px 24px; 81 | } 82 | 83 | &.reboot { 84 | background: url("images/reboot.svg") center center no-repeat; 85 | background-size: 24px 24px; 86 | } 87 | } 88 | 89 | .text { 90 | vertical-align: top; 91 | } 92 | 93 | ul.menu { 94 | right: 0; 95 | top: 40px; 96 | } 97 | } 98 | } 99 | 100 | #app-spinner { 101 | height: 128px; 102 | background: url("images/app-spinner.svg") center 100% no-repeat; 103 | background-size: 64px 64px; 104 | } 105 | 106 | #content { 107 | padding: 48px 0 24px 0; 108 | display: none; 109 | } 110 | 111 | .sub-header { 112 | padding: 8px 24px; 113 | font-size: 1.1em; 114 | font-weight: 300; 115 | } 116 | 117 | .notification { 118 | display: none; 119 | position: fixed; 120 | bottom: 0; 121 | left: 0; 122 | right: 0; 123 | padding: 16px; 124 | background-color: #424242; 125 | } 126 | 127 | form { 128 | padding: 8px 24px; 129 | 130 | .sub-header { 131 | padding: 16px 0 8px 0; 132 | } 133 | 134 | .field { 135 | position: relative; 136 | margin-bottom: 8px; 137 | 138 | &.disabled { 139 | opacity: 0.6; 140 | pointer-events: none; 141 | } 142 | 143 | &.checkbox { 144 | display: flex; 145 | 146 | input { 147 | margin-right: 16px; 148 | margin-top: 8px; 149 | } 150 | 151 | label { 152 | margin-right: auto; 153 | } 154 | } 155 | 156 | label { 157 | display: inline-block; 158 | padding: 4px 0px; 159 | color: #bdbdbd; 160 | } 161 | 162 | input:not([type="checkbox"]) { 163 | width: 100%; 164 | font-size: 1em; 165 | padding: 8px; 166 | background-color: #1d1d1d; 167 | border: none; 168 | color: #fff; 169 | box-sizing: border-box; 170 | } 171 | 172 | input[type="checkbox"] { 173 | transform: scale(2); 174 | padding: 10px; 175 | } 176 | 177 | .value { 178 | position: absolute; 179 | top: 4px; 180 | right: 0; 181 | } 182 | } 183 | } -------------------------------------------------------------------------------- /src/Mqtt.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | void configureMqtt() 4 | { 5 | if (!moduleSettings.enableMqtt) 6 | return; 7 | 8 | mqtt.onConnect(onMqttConnect); 9 | mqtt.onDisconnect(onMqttDisconnect); 10 | // mqtt.onMessage(onMqttMessage); 11 | mqtt.setServer(moduleSettings.mqttHost, moduleSettings.mqttPort); 12 | mqtt.setCredentials(moduleSettings.mqttUser, moduleSettings.mqttPassword); 13 | } 14 | 15 | void connectToMqttTimerHandler() 16 | { 17 | if (!moduleSettings.enableMqtt) 18 | return; 19 | 20 | if (ethConnected) 21 | { 22 | debugPrint("Connecting to MQTT..."); 23 | mqtt.connect(); 24 | } 25 | else 26 | { 27 | debugPrint("ETH is off, won't reconnect MQTT"); 28 | } 29 | } 30 | 31 | void taskSendMqttMessages(void *pvParameters) 32 | { 33 | MqttMessage msg; 34 | for (;;) 35 | { 36 | if (xQueueReceive(qMqtt, &msg, QUEUE_RECEIVE_DELAY)) 37 | { 38 | _mqttPublish(msg.topic, msg.data, msg.len, msg.retain); 39 | } 40 | } 41 | } 42 | 43 | void onMqttConnect(bool sessionPresent) 44 | { 45 | xTimerStop(tConectMqtt, TICKS_TO_WAIT12); 46 | queueMqttDiscoveryMessages(); 47 | } 48 | 49 | void onMqttDisconnect(AsyncMqttClientDisconnectReason reason) 50 | { 51 | xTimerStart(tConectMqtt, TICKS_TO_WAIT12); 52 | } 53 | 54 | void queueMqttDiscoveryMessages() 55 | { 56 | if (!moduleSettings.enableMqtt || !moduleSettings.enableHomeAssistant) 57 | return; 58 | 59 | char classMeasurement[] = "measurement"; 60 | char classTotalIncreasing[] = "total_increasing"; 61 | 62 | { 63 | MqttMessage msg = composeMqttDiscoveryMessage("Voltage", "V", classMeasurement, "{{ value_json.v | round(1) }}"); 64 | xQueueSendToBack(qMqtt, &msg, 0); 65 | } 66 | { 67 | MqttMessage msg = composeMqttDiscoveryMessage("Frequency", "Hz", classMeasurement, "{{ value_json.f | round(0) }}"); 68 | xQueueSendToBack(qMqtt, &msg, 0); 69 | } 70 | { 71 | MqttMessage msg = composeMqttDiscoveryMessage("Power", "W", classMeasurement, "{{ value_json.p | round(1) }}"); 72 | xQueueSendToBack(qMqtt, &msg, 0); 73 | } 74 | { 75 | MqttMessage msg = composeMqttDiscoveryMessage("Current", "A", classMeasurement, "{{ value_json.c | round(2) }}"); 76 | xQueueSendToBack(qMqtt, &msg, 0); 77 | } 78 | { 79 | MqttMessage msg = composeMqttDiscoveryMessage("Power Factor", "%", classMeasurement, "{{ (value_json.pf * 100) | round(0) }}"); 80 | xQueueSendToBack(qMqtt, &msg, 0); 81 | } 82 | { 83 | MqttMessage msg = composeMqttDiscoveryMessage("Energy", "kWh", classTotalIncreasing, "{{ value_json.e | round(1) }}"); 84 | xQueueSendToBack(qMqtt, &msg, 0); 85 | } 86 | { 87 | MqttMessage msg = composeMqttDiscoveryMessage("Uptime", "s", classTotalIncreasing, "{{ value_json.u }}"); 88 | xQueueSendToBack(qMqtt, &msg, 0); 89 | } 90 | } 91 | 92 | MqttMessage composeMqttMessage(NodeData data) 93 | { 94 | MqttMessage msg; 95 | StaticJsonDocument<256> doc; 96 | 97 | doc["v"] = data.voltage; 98 | doc["f"] = data.frequency; 99 | doc["p"] = data.power; 100 | doc["c"] = data.current; 101 | doc["pf"] = data.pf; 102 | doc["e"] = data.energy; 103 | doc["u"] = data.uptime; 104 | 105 | serializeJson(doc, msg.data, sizeof(msg.data)); 106 | strlcpy(msg.topic, moduleSettings.mqttTopic, sizeof(moduleSettings.mqttTopic)); 107 | 108 | msg.retain = false; 109 | msg.len = strlen(msg.data); 110 | 111 | return msg; 112 | } 113 | 114 | MqttMessage composeMqttDiscoveryMessage(const char *name, const char *unit, const char *stateClass, const char *tpl) 115 | { 116 | MqttMessage msg; 117 | StaticJsonDocument<512> doc; 118 | 119 | JsonObject dev = doc.createNestedObject("dev"); 120 | dev["name"] = Cfg::name; 121 | dev["sw"] = Cfg::version; 122 | dev["mf"] = Cfg::manufacturer; 123 | dev["mdl"] = Cfg::model; 124 | dev["ids"] = ETH.macAddress().c_str(); 125 | 126 | String devCla = strcmp(name, "Uptime") == 0 127 | ? String("duration") 128 | : String(name); 129 | devCla.toLowerCase(); 130 | devCla.replace(' ', '_'); 131 | 132 | char uniqId[64]; 133 | sprintf(uniqId, "%s_%s", ETH.macAddress().c_str(), devCla.c_str()); 134 | 135 | doc["name"] = name; 136 | doc["uniq_id"] = uniqId; 137 | doc["stat_t"] = moduleSettings.mqttTopic; 138 | doc["stat_cla"] = stateClass; 139 | doc["unit_of_meas"] = unit; 140 | doc["dev_cla"] = devCla.c_str(); 141 | doc["frc_upd"] = true; 142 | doc["val_tpl"] = tpl; 143 | 144 | sprintf(msg.topic, "%s/sensor/%s/config", moduleSettings.haDiscoveryPrefix, devCla.c_str()); 145 | serializeJson(doc, msg.data, sizeof(msg.data)); 146 | 147 | msg.retain = true; 148 | msg.len = strlen(msg.data); 149 | 150 | return msg; 151 | } 152 | 153 | uint16_t _mqttPublish(const char *topic, const char *data, size_t length, bool retain) 154 | { 155 | return mqtt.publish(topic, 2, retain, data, length); 156 | } 157 | -------------------------------------------------------------------------------- /web/src/app/pages/settings/SettingsPage.js: -------------------------------------------------------------------------------- 1 | import BasePage from "app/pages/BasePage"; 2 | import html from './SettingsPage.html'; 3 | import scss from './SettingsPage.scss'; 4 | 5 | export default class SettingsPage extends BasePage { 6 | settings = {}; 7 | 8 | init() { 9 | super.init(html); 10 | this._initControls(); 11 | this._loadSettings(); 12 | } 13 | 14 | _loadSettings() { 15 | fetch('/power/api/settings') 16 | .then(response => response.json()) 17 | .then(data => this.settings = data) 18 | .then(_ => this._applySettings()) 19 | .then(_ => this.showContent()) 20 | .catch(e => console.log(e)); 21 | } 22 | 23 | _onSave() { 24 | document.getElementById('form').requestSubmit(); 25 | } 26 | 27 | _onSubmit(e) { 28 | e.preventDefault(); 29 | 30 | this.showLoading(); 31 | 32 | const data = this._getSettings(); 33 | fetch('/power/api/settings', { 34 | method: 'PUT', 35 | headers: { 'Content-Type': 'application/json' }, 36 | body: JSON.stringify(data) 37 | }).then(() => this._loadSettings()); 38 | } 39 | 40 | _getSettings() { 41 | const result = {}; 42 | 43 | this._getFields().forEach(field => { 44 | const el = document.getElementById(field.id); 45 | 46 | if (field.deps) { 47 | const value = this._getFieldValue(el); 48 | const values = field.get(value); 49 | field.deps.forEach((prop, idx) => result[prop] = values[idx]); 50 | return; 51 | } 52 | 53 | result[field.prop] = this._getFieldValue(el); 54 | }); 55 | 56 | return result; 57 | } 58 | 59 | _applySettings() { 60 | const fileds = this._getFields(); 61 | 62 | fileds.forEach(field => { 63 | const el = document.getElementById(field.id); 64 | let value = this.settings[field.prop]; 65 | 66 | if (field.deps) { 67 | const values = field.deps.map(prop => this.settings[prop]); 68 | value = values.some(v => v == '') ? '' : field.set(values); 69 | } 70 | 71 | this._setFieldValue(el, value); 72 | }); 73 | } 74 | 75 | _initControls() { 76 | this._getFields().forEach(field => { 77 | if (field.refs) { 78 | const el = document.getElementById(field.id); 79 | el.onchange = () => field.refs.forEach(ref => this._disableFiled(ref, !this._getFieldValue(el))); 80 | field.refs.forEach(ref => this._disableFiled(ref, !this._getFieldValue(el))); 81 | } 82 | }); 83 | 84 | document.getElementById('save').addEventListener('click', () => this._onSave(), false); 85 | document.getElementById('form').addEventListener('submit', (event) => this._onSubmit(event), false); 86 | } 87 | 88 | _disableFiled(id, disable) { 89 | const el = document.getElementById(id); 90 | 91 | if (disable) { 92 | el.disabled = disable; 93 | el.parentElement.classList.add('disabled') 94 | } else { 95 | el.disabled = disable; 96 | el.parentElement.classList.remove('disabled') 97 | } 98 | 99 | const field = this._getFields().find(f => f.id == id); 100 | if (field && field.refs) { 101 | field.refs.forEach(ref => this._disableFiled(ref, disable)) 102 | } 103 | } 104 | 105 | _getFieldValue(el) { 106 | if (el.type == 'text' || el.type == 'password') 107 | return el.value; 108 | else if (el.type == 'number' || el.type == 'range') 109 | return Number(el.value); 110 | else if (el.type == 'checkbox') 111 | return el.checked; 112 | } 113 | 114 | _setFieldValue(el, value) { 115 | if (el.type == 'text' || el.type == 'number' || el.type == 'password' || el.type == 'range') 116 | el.value = value; 117 | else if (el.type == 'checkbox') 118 | el.checked = value; 119 | 120 | el.dispatchEvent(new Event('change')); 121 | } 122 | 123 | _getFields() { 124 | return [ 125 | { id: 'voltageMin', prop: 'vMin' }, 126 | { id: 'voltageMax', prop: 'vMax' }, 127 | { id: 'powerMax', prop: 'pMax' }, 128 | { id: 'currentMax', prop: 'cMax' }, 129 | { id: 'enableMqtt', prop: 'mqtt', refs: ['mqttServer', 'mqttUser', 'mqttPassword', 'mqttTopic', 'enableHomeAssistant'] }, 130 | { 131 | id: 'mqttServer', 132 | deps: ['mqttHost', 'mqttPort'], 133 | get: value => value.split(':'), 134 | set: values => values.join(':') 135 | }, 136 | { id: 'mqttUser', prop: 'mqttUsr' }, 137 | { id: 'mqttPassword', prop: 'mqttPwd' }, 138 | { id: 'mqttTopic', prop: 'mqttTopic' }, 139 | { id: 'enableHomeAssistant', prop: 'ha', refs: ['haDiscoveryPrefix', 'haNodeId'] }, 140 | { id: 'haDiscoveryPrefix', prop: 'haPref' }, 141 | { id: 'haNodeId', prop: 'haNodeId' }, 142 | { id: 'ntpServer', prop: 'ntp' }, 143 | { id: 'requestDataInterval', prop: 'rdi' }, 144 | { id: 'otaPassword', prop: 'otaPwd' }, 145 | ]; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'), 2 | fs = require('fs'), 3 | env = require("../env.json"), 4 | webpack = require("webpack"), 5 | TerserPlugin = require('terser-webpack-plugin'), 6 | CopyPlugin = require("copy-webpack-plugin"), 7 | CompressionPlugin = require('compression-webpack-plugin'), 8 | MiniCssExtractPlugin = require('mini-css-extract-plugin'), 9 | HtmlWebpackPlugin = require('html-webpack-plugin'), 10 | RemovePlugin = require('remove-files-webpack-plugin'), 11 | MiniSvgDataPlugin = require('mini-svg-data-uri'), 12 | package = require('./package.json'), 13 | crypto = require('crypto'); 14 | 15 | const moduleHost = env.otaHost; 16 | const mode = process.argv[process.argv.indexOf('--mode') + 1]; 17 | const isProduction = mode === 'production'; 18 | const dstProd = '../data'; 19 | const dstDev = 'public/power'; 20 | const dstPathFull = path.join(__dirname, isProduction ? dstProd : dstDev); 21 | const hasCompressedCopy = (filePath) => { 22 | const file = path.parse(filePath); 23 | return file.ext !== '.gz' && fs.existsSync(path.join(file.dir, `${file.base}.gz`)); 24 | }; 25 | 26 | 27 | const config = { 28 | entry: { 29 | main: './src/index.js', 30 | }, 31 | mode: mode, 32 | resolve: { 33 | alias: { 34 | 'app': path.resolve(__dirname, './src/app'), 35 | } 36 | }, 37 | output: { 38 | path: dstPathFull, 39 | publicPath: '/power/', 40 | filename: '[name].js', 41 | hashDigestLength: 8 42 | }, 43 | optimization: { 44 | minimize: isProduction, 45 | minimizer: [ 46 | new TerserPlugin({ parallel: true }) 47 | ] 48 | }, 49 | module: { 50 | rules: [ 51 | { 52 | test: /\.(svg)$/, 53 | type: 'asset/inline', 54 | generator: { 55 | dataUrl(content) { 56 | return MiniSvgDataPlugin(content.toString()); 57 | } 58 | }, 59 | use: { 60 | loader: 'svgo-loader', 61 | options: { 62 | configFile: '../svgo.config.js' 63 | } 64 | } 65 | }, 66 | { 67 | test: /\.s[ac]ss$/i, 68 | use: [ 69 | MiniCssExtractPlugin.loader, 70 | 'css-loader', 71 | 'sass-loader' 72 | ], 73 | }, 74 | { 75 | test: /\.css$/i, 76 | use: [ 77 | MiniCssExtractPlugin.loader, 78 | 'css-loader', 79 | ], 80 | }, 81 | { 82 | test: /\.html$/, 83 | use: 'html-loader' 84 | }, 85 | ], 86 | }, 87 | plugins: [ 88 | new CopyPlugin({ 89 | patterns: [ 90 | { 91 | from: 'src/statics', 92 | to: dstPathFull, 93 | transform(content, filePath) { 94 | if (path.parse(filePath).base == 'service-worker.js') { 95 | return content 96 | .toString() 97 | .replace('{{version}}', `${package.version}-${crypto.randomBytes(8).toString('hex')}`) 98 | } 99 | return content; 100 | } 101 | }, 102 | ], 103 | }), 104 | new CompressionPlugin, 105 | new MiniCssExtractPlugin, 106 | new HtmlWebpackPlugin({ 107 | title: 'Power Monitor', 108 | templateContent: ` 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 |
119 | 120 | 121 | ` 122 | }), 123 | ], 124 | }; 125 | 126 | if (isProduction) { 127 | config.plugins.push(new RemovePlugin({ 128 | before: { 129 | root: dstPathFull, 130 | test: [{ 131 | folder: '.', 132 | recursive: true, 133 | method: () => true 134 | }] 135 | }, 136 | after: { 137 | root: dstPathFull, 138 | test: [ 139 | { 140 | folder: '.', 141 | method: hasCompressedCopy 142 | }, 143 | { 144 | folder: './images', 145 | method: hasCompressedCopy 146 | } 147 | ] 148 | } 149 | })); 150 | } else { 151 | config.watch = true; 152 | config.devtool = 'source-map'; 153 | config.devServer = { 154 | port: 9000, 155 | hot: true, 156 | proxy: { 157 | '/power/ws': { 158 | target: `ws://${moduleHost}`, 159 | ws: true 160 | }, 161 | '/power/api': { 162 | target: `ws://${moduleHost}` 163 | }, 164 | }, 165 | }; 166 | } 167 | 168 | module.exports = config; 169 | -------------------------------------------------------------------------------- /src/WebServer.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | void initWebServer() 4 | { 5 | server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) 6 | { request->redirect("/power/index.html"); }); 7 | server.on("/power/", HTTP_GET, [](AsyncWebServerRequest *request) 8 | { request->redirect("/power/index.html"); }); 9 | server.on("/power/api/settings", HTTP_GET, _getSettings); 10 | server.on("/power/api/settings", HTTP_PUT, [](AsyncWebServerRequest *request) {}, NULL, _saveSettings); 11 | server.on("/power/api/resetEnergy", HTTP_POST, _resetEnergy); 12 | server.on("/power/api/reboot", HTTP_GET, _reboot); 13 | server.on("/power/api/debug", HTTP_GET, _getDebug); 14 | 15 | server.serveStatic("/power/", SPIFFS, "/"); 16 | server.serveStatic("/power/images", SPIFFS, "/images"); 17 | server.onNotFound(_notFound); 18 | 19 | ws.onEvent(_onWebSocketEvent); 20 | server.addHandler(&ws); 21 | 22 | server.begin(); 23 | } 24 | 25 | void taskUpdateWebClients(void *pvParameters) 26 | { 27 | for (;;) 28 | { 29 | if (!xEventGroupWaitBits(eg, EVENT_UPDATE_WEB_CLIENTS, pdTRUE, pdTRUE, portMAX_DELAY)) 30 | continue; 31 | 32 | StaticJsonDocument<256> doc; 33 | char buffer[256]; 34 | 35 | doc["v"] = round2(currentData.voltage); 36 | doc["vw"] = currentData.voltageWarn; 37 | doc["f"] = round2(currentData.frequency); 38 | doc["p"] = round2(currentData.power); 39 | doc["pw"] = currentData.powerWarn; 40 | doc["c"] = round2(currentData.current); 41 | doc["cw"] = currentData.currentWarn; 42 | doc["pf"] = round2(currentData.pf); 43 | doc["e"] = round2(currentData.energy); 44 | doc["pe"] = round2(moduleSettings.prevEnergy); 45 | doc["u"] = currentData.uptime; 46 | 47 | serializeJson(doc, buffer); 48 | 49 | ws.textAll(buffer); 50 | } 51 | } 52 | 53 | void cleanupWebSocketsTimerHandler() 54 | { 55 | ws.cleanupClients(); 56 | } 57 | 58 | void _onWebSocketEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) 59 | { 60 | switch (type) 61 | { 62 | case WS_EVT_CONNECT: 63 | debugPrintf("WebSocket client #%u connected from %s\n", client->id(), client->remoteIP().toString().c_str()); 64 | break; 65 | case WS_EVT_DISCONNECT: 66 | debugPrintf("WebSocket client #%u disconnected\n", client->id()); 67 | break; 68 | case WS_EVT_DATA: 69 | data[len] = 0; 70 | debugPrintf("WebSocket message: %s\n", data); 71 | break; 72 | case WS_EVT_PONG: 73 | case WS_EVT_ERROR: 74 | break; 75 | } 76 | } 77 | 78 | void _getSettings(AsyncWebServerRequest *request) 79 | { 80 | webDoc.clear(); 81 | Settings data = getSettings(); 82 | 83 | webDoc["vMin"] = data.voltageMin; 84 | webDoc["vMax"] = data.voltageMax; 85 | webDoc["pMax"] = data.powerMax; 86 | webDoc["cMax"] = data.currentMax; 87 | 88 | webDoc["mqtt"] = data.enableMqtt; 89 | webDoc["mqttHost"] = data.mqttHost; 90 | webDoc["mqttPort"] = data.mqttPort; 91 | webDoc["mqttUsr"] = data.mqttUser; 92 | webDoc["mqttPwd"] = data.mqttPassword; 93 | webDoc["mqttTopic"] = data.mqttTopic; 94 | 95 | webDoc["ha"] = data.enableHomeAssistant; 96 | webDoc["haPref"] = data.haDiscoveryPrefix; 97 | webDoc["haNodeId"] = data.haNodeId; 98 | 99 | webDoc["ntp"] = data.ntpServer; 100 | webDoc["rdi"] = data.requestDataInterval; 101 | webDoc["otaPwd"] = data.otaPassword; 102 | webDoc["eRst"] = data.lastEnergyReset; 103 | 104 | if (xSemaphoreTake(semaWebDataBuffer, TICKS_TO_WAIT0) == pdTRUE) 105 | { 106 | serializeJson(webDoc, webDataBuffer); 107 | request->send(200, CONTENT_TYPE_JSON, webDataBuffer); 108 | 109 | xSemaphoreGive(semaWebDataBuffer); 110 | } 111 | else 112 | { 113 | request->redirect(request->url().c_str()); 114 | } 115 | } 116 | 117 | void _saveSettings(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) 118 | { 119 | if (index == 0) 120 | { 121 | request->_tempObject = new uint8_t(0); 122 | if (xSemaphoreTake(semaWebDataBuffer, TICKS_TO_WAIT0) == pdTRUE) 123 | *(uint8_t*)request->_tempObject = 1; 124 | else 125 | request->redirect(request->url().c_str()); 126 | } 127 | 128 | if (*(uint8_t*)request->_tempObject == 0) 129 | return; 130 | 131 | memcpy(webDataBuffer + index, data, len); 132 | 133 | if (len + index < total) 134 | return; 135 | 136 | webDoc.clear(); 137 | deserializeJson(webDoc, webDataBuffer, total); 138 | 139 | Settings sett; 140 | sett.lastEnergyReset = moduleSettings.lastEnergyReset; 141 | sett.prevEnergy = moduleSettings.prevEnergy; 142 | 143 | sett.voltageMin = webDoc["vMin"]; 144 | sett.voltageMax = webDoc["vMax"]; 145 | sett.powerMax = webDoc["pMax"]; 146 | sett.currentMax = webDoc["cMax"]; 147 | 148 | sett.enableMqtt = webDoc["mqtt"]; 149 | strlcpy(sett.mqttHost, webDoc["mqttHost"].as(), sizeof(sett.mqttHost)); 150 | sett.mqttPort = webDoc["mqttPort"]; 151 | strlcpy(sett.mqttUser, webDoc["mqttUsr"].as(), sizeof(sett.mqttUser)); 152 | strlcpy(sett.mqttPassword, webDoc["mqttPwd"].as(), sizeof(sett.mqttPassword)); 153 | strlcpy(sett.mqttTopic, webDoc["mqttTopic"].as(), sizeof(sett.mqttTopic)); 154 | 155 | sett.enableHomeAssistant = webDoc["ha"]; 156 | strlcpy(sett.haDiscoveryPrefix, webDoc["haPref"].as(), sizeof(sett.haDiscoveryPrefix)); 157 | strlcpy(sett.haNodeId, webDoc["haNodeId"].as(), sizeof(sett.haNodeId)); 158 | 159 | strlcpy(sett.ntpServer, webDoc["ntp"].as(), sizeof(sett.ntpServer)); 160 | sett.requestDataInterval = webDoc["rdi"]; 161 | strlcpy(sett.otaPassword, webDoc["otaPwd"].as(), sizeof(sett.otaPassword)); 162 | 163 | saveSettings(sett); 164 | 165 | xSemaphoreGive(semaWebDataBuffer); 166 | request->send(200); 167 | 168 | bool restartRequired = moduleSettings.enableMqtt != sett.enableMqtt 169 | || strcmp(moduleSettings.mqttHost, sett.mqttHost) != 0 170 | || strcmp(moduleSettings.mqttUser, sett.mqttUser) != 0 171 | || strcmp(moduleSettings.mqttPassword, sett.mqttPassword) != 0 172 | || strcmp(moduleSettings.ntpServer, sett.ntpServer) != 0 173 | || strcmp(moduleSettings.mqttTopic, sett.mqttTopic) != 0 174 | || strcmp(moduleSettings.otaPassword, sett.otaPassword) != 0; 175 | 176 | if (restartRequired) 177 | { 178 | ESP.restart(); 179 | } 180 | 181 | moduleSettings = getSettings(); 182 | } 183 | 184 | void _resetEnergy(AsyncWebServerRequest *request) 185 | { 186 | #ifndef DO_NOT_RESET_ENERGY 187 | resetEnergy(); 188 | #endif 189 | request->send(200); 190 | } 191 | 192 | void _reboot(AsyncWebServerRequest *request) 193 | { 194 | request->send(200, CONTENT_TYPE_TEXT, "OK"); 195 | ESP.restart(); 196 | } 197 | 198 | void _getDebug(AsyncWebServerRequest *request) 199 | { 200 | time_t now; 201 | tm info; 202 | time(&now); 203 | localtime_r(&now, &info); 204 | 205 | int second = info.tm_sec; 206 | int minute = info.tm_min; 207 | int hour = info.tm_hour; 208 | int day = info.tm_mday; 209 | int month = info.tm_mon + 1; 210 | int year = info.tm_year + 1900; 211 | 212 | if (xSemaphoreTake(semaWebDataBuffer, TICKS_TO_WAIT0) == pdTRUE) 213 | { 214 | sprintf(webDataBuffer, "HEAP: %d, NOW: %02d.%02d.%d %02d:%02d:%02d\n", 215 | ESP.getFreeHeap(), 216 | day, month, year, hour, minute, second); 217 | request->send(200, CONTENT_TYPE_TEXT, webDataBuffer); 218 | 219 | xSemaphoreGive(semaWebDataBuffer); 220 | } 221 | else 222 | { 223 | request->redirect(request->url().c_str()); 224 | } 225 | } 226 | 227 | void _notFound(AsyncWebServerRequest *request) 228 | { 229 | request->send(404, CONTENT_TYPE_TEXT, "Not found"); 230 | } 231 | --------------------------------------------------------------------------------