├── .gitignore ├── LICENSE ├── README.md ├── data ├── cfg.html ├── cfg.js ├── log.html ├── log.js ├── pvChart.html ├── pvChart.js ├── time.html ├── time.js ├── web.css ├── web.html └── web.js ├── lib ├── README └── WiFiManager-asyncwebserver │ ├── LICENSE │ ├── WiFiManager.cpp │ └── WiFiManager.h ├── platformio.ini └── src ├── button.cpp ├── button.h ├── globalConfig.cpp ├── globalConfig.h ├── goEmulator.cpp ├── goEmulator.h ├── inverter.cpp ├── inverter.h ├── loadManager.cpp ├── loadManager.h ├── logger.cpp ├── logger.h ├── main.cpp ├── mbComm.cpp ├── mbComm.h ├── mqtt.cpp ├── mqtt.h ├── phaseCtrl.cpp ├── phaseCtrl.h ├── powerfox.cpp ├── powerfox.h ├── pvAlgo.cpp ├── pvAlgo.h ├── pvHttp.cpp ├── pvHttp.h ├── rfid.cpp ├── rfid.h ├── shelly.cpp ├── shelly.h ├── webServer.cpp ├── webServer.h ├── webSocket.cpp └── webSocket.h /.gitignore: -------------------------------------------------------------------------------- 1 | .pio 2 | .vscode/.browse.c_cpp.db* 3 | .vscode/c_cpp_properties.json 4 | .vscode/launch.json 5 | .vscode/extensions.json 6 | .vscode/ipch 7 | include/wlan_key.h 8 | output 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 steff393 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **wbec** - WLAN-Anbindung der Heidelberg **W**all**B**ox **E**nergy **C**ontrol über ESP8266 2 | 3 | Die Heidelberg Wallbox Energy Control ist eine hochwertige Ladestation, bietet aber nur Modbus RTU als Schnittstelle. 4 | Ziel des Projekts ist es, eine WLAN-Schnittstelle zu entwickeln um zusätzliche Funktionen (z.B. PV-Überschussladen) zu ermöglichen. 5 | 6 | [wbec Homepage](https://steff393.github.io/wbec-site/) 7 | [Empfehlung im Heidelberg Amperfied Blog](https://www.amperfied.de/de/clever-laden/blog/wbec-fuer-heidelberg-wallbox-energy-control-blog/) 8 | 9 | ![GitHub all releases](https://img.shields.io/github/downloads/steff393/wbec/total?color=blue&style=flat-square) 10 | 11 | ## Funktionen 12 | - Anbindung an openWB, EVCC, Solaranzeige 13 | - MQTT-Kommunikation mit openWB und EVCC (ideal für mehrere Ladestationen) 14 | - Steuerbar per Android App [Wallbox Steuerung](https://android.chk.digital/ecar-charger-control/) 15 | - PV-Überschussladen, Zielladen, etc. mit den o.g. Steuerungen 16 | - Abfrage von Shelly 3EM, powerfox, Solaredge, Fronius, ... 17 | - RFID-Kartenleser zur Freischaltung der Wallbox mit gültiger Karte/Chip (spezielle HW nötig, s. Wiki) 18 | - Ansteuerung aller verbundenen Ladestationen (bis zu 16 Follower am Modbus, bis zu 8 openWB-Ladepunkte) 19 | - Lokales Lastmanagement für zwei Wallboxen 20 | - Softwareupdate per WLAN (Over The Air), z.B. mit PlatformIO oder einfach per Browser (s. Wiki) 21 | - Weniger als 1W Strombedarf (trotz Ansteuerung von bis zu 16 Ladestationen) 22 | - [-> Neue Funktionen](https://steff393.github.io/wbec-site/features.html) 23 | 24 | ## Kontakt 25 | Bei Fragen oder wenn ihr Unterstützung braucht gerne einfach eine Mail schicken (wbec393@gmail.com). 26 | Bitte schaut auch ins [Wiki](https://github.com/steff393/wbec/wiki) und in meine anderen Projekte, z.B. den [SmartUploader](https://github.com/steff393/SmartUploader) zum Auslesen von Wechselrichtern und [hgdo](https://github.com/steff393/hgdo) zur Steuerung von Torantrieben. 27 | 28 | ## Beispiele 29 | Einfaches Web-Interface (geeignet für alle Browser, Smartphone, PC, etc.): 30 | `http://wbec.local/` 31 |

32 | 33 |

34 | 35 | JSON API Schnittstelle: 36 | `http://wbec.local/json` 37 | ```c++ 38 | { 39 | "wbec": { 40 | "version": "v0.3.0" // wbec version 41 | "bldDate": "2021-06-10" // wbec build date 42 | }, 43 | "box": [ 44 | { // s. also https://wallbox.heidelberg.com/wp-content/uploads/2021/04/EC_ModBus_register_table_20210222.pdf 45 | "busId": 1, // Modbus bus id (as configured by DIP switches) 46 | "version": "108", // Modbus Register-Layouts Version, e.g. 1.0.8 47 | "chgStat": 2, // Charging State 48 | "currL1": 0, // L1 - Current RMS (in 0.1A) 49 | "currL2": 0, // L2 - Current RMS (in 0.1A) 50 | "currL3": 0, // L3 - Current RMS (in 0.1A) 51 | "pcbTemp": 333, // PCB-Temperatur (in 0.1°C) 52 | "voltL1": 232, // Voltage L1 - N rms in Volt 53 | "voltL2": 9, // Voltage L2 - N rms in Volt 54 | "voltL3": 9, // Voltage L3 - N rms in Volt 55 | "extLock": 1, // extern lock state 56 | "power": 0, // Power (L1+L2+L3) in VA 57 | "energyP": 0, // Energy since PowerOn (in kWh) 58 | "energyI": 0.003, // Energy since Installation (in kWh) 59 | "currMax": 16, // Hardware configuration maximal current (in 0.1A) 60 | "currMin": 6, // Hardware configuration minimal current (in 0.1A) 61 | "logStr": " ", 62 | "wdTmOut": 15000, // ModBus-Master WatchDog Timeout (in ms) 63 | "standby": 4, // Standby Function Control 64 | "remLock": 1, // Remote lock (only if extern lock unlocked) 65 | "currLim": 130, // Maximal current command 66 | "currFs": 0, // FailSafe Current configuration 67 | "load": 0, // wbec load management 68 | "resCode": "0" // Result code of last Modbus message (0 = ok) 69 | }, 70 | { // Values of 2nd box ... 71 | "busId": 2, 72 | "version": "0", 73 | "chgStat": 0, 74 | ... 75 | "load": 0, 76 | "resCode": "e4" 77 | } 78 | ], 79 | "modbus": { 80 | "state": { 81 | "lastTm": 2852819, // Timestamp of last Modbus message (in ms) 82 | "millis": 2855489 // Time since start of wbec (in ms) 83 | } 84 | }, 85 | "rfid": { 86 | "enabled": true, 87 | "release": false, 88 | "lastId": "0cb6a781" 89 | }, 90 | "wifi": { 91 | "mac": "00:1F:3F:15:29:7E", // wbec MAC address 92 | "rssi": -76, // WiFi signal 93 | "signal": 48, // WiFi signal quality (in %) 94 | "channel": 11 // WiFi channel 95 | } 96 | } 97 | ``` 98 | 99 | Maximalen Ladestrom einstellen: 100 | ```c++ 101 | http://192.168.xx.yy/json?currLim=120 --> set current limit to 12A (on the box with id=0, i.e. ModBus Bus-ID=1) 102 | http://192.168.xx.yy/json?currLim=60&id=2 --> set current limit to 6A on the box with id=2 (i.e. ModBus Bus-ID=3) 103 | ``` 104 | 105 | ## Danksagung 106 | Folgende Projekte wurden in wbec genutzt/angepasst: 107 | - [modbus-esp8266](https://github.com/emelianov/modbus-esp8266) 108 | - [ESP Async WebServer](https://github.com/me-no-dev/ESPAsyncWebServer) 109 | - [ArduinoJson](https://github.com/bblanchon/ArduinoJson) 110 | - [PubSubClient](https://github.com/knolleary/PubSubClient) 111 | - [NTPClient](https://github.com/arduino-libraries/NTPClient) 112 | - [MFRC522](https://github.com/miguelbalboa/MFRC522) 113 | - [RTCVars](https://github.com/highno/RTCVars) 114 | - [arduinoWebSockets](https://github.com/Links2004/arduinoWebSockets) 115 | - [WiFiManager](https://github.com/tzapu/WiFiManager) 116 | - [Web Interface](https://RandomNerdTutorials.com) 117 | - [A Beginner's Guide to the ESP8266 - article](https://github.com/tttapa/ESP8266) 118 | - [AsyncElegantOTA](https://github.com/ayushsharma82/AsyncElegantOTA) 119 | 120 | Ein besonderer Dank ergeht an die frühen Tester und Unterstützer: mli987, profex1337, Clanchef und viele mehr! 121 | 122 | ## Unterstützung des Projektes 123 | wbec gefällt dir? Dann gib dem Projekt [einen Stern auf GitHub](https://github.com/steff393/wbec/stargazers)! 124 | 125 | [![Star History Chart](https://api.star-history.com/svg?repos=steff393/wbec&type=Date)](https://star-history.com/#steff393/wbec&Date) 126 | -------------------------------------------------------------------------------- /data/cfg.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | wbec Config 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | 32 |

wbec

33 |

Heidelberg Wallbox Energy Control

34 |
35 | 36 |
37 |
38 |

39 |  Konfiguration 40 |

41 |
42 | 43 | 44 | 45 |
46 |
(!) = Wert sollte in der Regel nicht verändert werden!
47 | Es findet keine Überprüfung auf plausible Werte statt!

48 |
49 |
50 | 51 |
52 |
53 | 54 |
55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /data/cfg.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 steff393, MIT license 2 | 3 | 4 | // Achtung 5 | // ---------------------------------------------------------------------------------- 6 | // Diese Datei hier ist nicht die "cfg.json", die die eigentlichen Parameter enthält. 7 | // Parameter werden nicht hier geändert. 8 | // ---------------------------------------------------------------------------------- 9 | 10 | // ----------------------------- COMMON SECTION: START ------------------------------ 11 | function initNavBar() { 12 | for (const element of document.querySelectorAll('[top-nav-link]')) { 13 | element.addEventListener('click', function() {window.location.href = element.getAttribute('top-nav-link')}); 14 | } 15 | } 16 | 17 | function assignValuesToHtml(values) { 18 | let valueContainerElements = document.querySelectorAll('[data-value]'); 19 | for (const element of valueContainerElements) { 20 | const key = element.getAttribute('data-value'); 21 | if (values[key] !== undefined) { 22 | element.innerHTML = values[key].toLocaleString('de-DE'); 23 | } 24 | } 25 | } 26 | 27 | function setClass(element, className, state) { 28 | if (state) { 29 | element.classList.add(className) 30 | } else { 31 | element.classList.remove(className) 32 | } 33 | } 34 | 35 | function setSectionVisibility(sectionId, isVisible) { 36 | setClass(document.getElementById(sectionId), 'not-available', !isVisible); 37 | } 38 | // ----------------------------- COMMON SECTION: END ------------------------------ 39 | 40 | // Default settings 24.03.2024 41 | const defaultObj = JSON.parse( 42 | '{"cfgApSsid":"wbec","cfgApPass":"wbec1234","cfgCntWb":1,"cfgMbCycleTime":10,"cfgMbDelay":100,"cfgMbTimeout":60000,"cfgStandby":4,"cfgFailsafeCurrent":0,"cfgMqttIp":"","cfgMqttLp":[],"cfgMqttPort":1883,"cfgMqttUser":"","cfgMqttPass":"","cfgMqttWattTopic":"wbec/pv/setWatt","cfgMqttWattJson":"","cfgMqttClientId":0,"cfgNtpServer":"europe.pool.ntp.org","cfgFoxUser":"","cfgFoxPass":"","cfgFoxDevId":"","cfgPvActive":0,"cfgPvCycleTime":30,"cfgPvLimStart":61,"cfgPvLimStop":50,"cfgPvPhFactor":69,"cfgPvOffset":0,"cfgPvCalcMode":0,"cfgPvInvert":0,"cfgPvInvertBatt":0,"cfgPvMinTime":0,"cfgPvOffCurrent":255,"cfgPvHttpIp":"","cfgPvHttpPath":"/","cfgPvHttpJson":"","cfgPvHttpJsonBatt":"","cfgPvHttpPort":80,"cfgTotalCurrMax":0,"cfgLmChargeState":4,"cfgHwVersion":15,"cfgWifiSleepMode":0,"cfgLoopDelay":255,"cfgKnockOutTimer":0,"cfgShellyIp":"","cfgInverterIp":"","cfgInverterType":0,"cfgInverterPort":0,"cfgInverterAddr":0,"cfgInvSmartAddr":0,"cfgInvRegPowerInv":0,"cfgInvRegPowerInvS":0,"cfgInvRegPowerMet":0,"cfgInvRegPowerMetS":0,"cfgInvRegToGrid":0,"cfgInvRegFromGrid":0,"cfgInvRegInputGrid":0,"cfgInvRegBattery":0,"cfgInvRegBattery16":0,"cfgBootlogSize":2000,"cfgBtnDebounce":0,"cfgWifiConnectTimeout":10,"cfgResetOnTimeout":0,"cfgEnergyOffset":0,"cfgDisplayAutoOff":2,"cfgWifiAutoReconnect":1,"cfgWifiScanMethod":0,"cfgLedIp":1,"cfgWifiOff":0,"cfgChargeLog":0,"cfgWallboxIp":"","cfgWallboxPort":502,"cfgWallboxAddr":1,"cfgRfidCurr":160,"cfgAutoEnable":1,"cfgEnwgSource":0,"cfgEnwgBox":0,"cfgWbecMac":237,"cfgWbecIp":""}' 43 | ); 44 | 45 | const descObj = { 46 | cfgApSsid :"(!) Name des initialen Access Points", 47 | cfgApPass :"Passwort des initialen Access Points", 48 | cfgCntWb :"Anzahl der verbundenen Wallboxen", 49 | cfgMbCycleTime :"(!) [s] Modbus Zykluszeit", 50 | cfgMbDelay :"(!) [ms] Min. Zeit zwischen Modbusbotschaften", 51 | cfgMbTimeout :"(!) [ms] Modbus Timeout (Register 257)", 52 | cfgStandby :"(!) Standby 0:aktiv, 4:inaktiv (empfohlen)", 53 | cfgFailsafeCurrent :"(!) [100mA] Strom bei Modbus-Timeout (Register 262)", 54 | cfgMqttIp :"MQTT-Broker: IP-Adresse, z.B. 192.168.178.123", 55 | cfgMqttLp :"MQTT: Zuordnung der Ladepunkte, s. Wiki, z.B. 1 oder 1,2,3", 56 | cfgMqttPort :"MQTT-Broker: Port ", 57 | cfgMqttUser :"MQTT-Broker: Username (wenn nötig, max. 31 Zeichen)", 58 | cfgMqttPass :"MQTT-Broker: Passwort (wenn nötig, max. 127 Zeichen)", 59 | cfgMqttWattTopic :"MQTT: Topic, um den Wert Bezug/Einspeisung zu empfangen", 60 | cfgMqttWattJson :"MQTT: Suchstring, um den Wert Bezug/Einspeisung zu finden", 61 | cfgMqttClientId :"MQTT: Client-ID, 0 = zufällig", 62 | cfgNtpServer :"NTP-Server", 63 | cfgFoxUser :"Powerfox: Benutzername", 64 | cfgFoxPass :"Powerfox: Passwort", 65 | cfgFoxDevId :"Powerfox: Device ID", 66 | cfgPvActive :"PV-Überschussregelung: 0:inaktiv, 1:aktiv", 67 | cfgPvCycleTime :"[s] PV-Überschussregelung: Zykluszeit", 68 | cfgPvLimStart :"[100mA] PV-Überschussregelung: Startstrom, z.B. 61=6,1A", 69 | cfgPvLimStop :"[100mA] PV-Überschussregelung: Stopstrom, z.B. 50=5,0A", 70 | cfgPvPhFactor :"PV-Überschussregelung: 23:1-ph, 42:2-ph, 69:3-phasig", 71 | cfgPvOffset :"[W] PV-Überschussregelung: Offset", 72 | cfgPvCalcMode :"PV-Überschussregelung: Berechnungsmodus", 73 | cfgPvInvert :"PV-Überschussregelung: Vorzeichen von Bezug/Einspeisung invertieren (1)", 74 | cfgPvInvertBatt :"PV-Überschussregelung: Vorzeichen von Batterieleistung invertieren (1)", 75 | cfgPvMinTime :"[min] PV-Überschussregelung: Minimale Aktivierungszeit", 76 | cfgPvOffCurrent :"[100mA] PV-Überschussregelung: Strom, welcher bei Wechsel auf Modus Aus eingestellt wird", 77 | cfgPvHttpIp :"PV-Überschussregelung HTTP: IP-Adresse, um den Wert Bezug/Einspeisung abzufragen", 78 | cfgPvHttpPath :"PV-Überschussregelung HTTP: URL, um den Wert Bezug/Einspeisung abzufragen", 79 | cfgPvHttpJson :"PV-Überschussregelung HTTP: Suchstring, um den Wert Bezug/Einspeisung zu finden", 80 | cfgPvHttpJsonBatt :"PV-Überschussregelung HTTP: Suchstring, um den Wert Batterieleistung zu finden", 81 | cfgPvHttpPort :"PV-Überschussregelung HTTP: Port, um den Wert Bezug/Einspeisung abzufragen", 82 | cfgTotalCurrMax :"[100mA] Maximaler Systemstrom bei mehreren Wallbox, ACHTUNG: SICHERUNG NÖTIG!", 83 | cfgLmChargeState :"(!) Ladezustand, ab dem Lastmanagement eine Ladeanforderung erkennt", 84 | cfgHwVersion :"(!) intern", 85 | cfgWifiSleepMode :"(!) intern", 86 | cfgLoopDelay :"(!) intern", 87 | cfgKnockOutTimer :"(!) [min] Zyklischer Reset von wbec alle xx Minuten", 88 | cfgShellyIp :"Shelly: IP-Adresse, um den Wert Bezug/Einspeisung abzufragen", 89 | cfgInverterIp :"Modbus-TCP: IP-Adresse, um den Wert Bezug/Einspeisung abzufragen", 90 | cfgInverterType :"Modbus-TCP: Typ, s. Wiki", 91 | cfgInverterPort :"Modbus-TCP: Port, s. Wiki", 92 | cfgInverterAddr :"(!) Modbus-TCP: Modbus-Adresse, s. Wiki", 93 | cfgInvSmartAddr :"(!) Modbus-TCP: Modbus-Adresse, s. Wiki", 94 | cfgInvRegPowerInv :"(!) Modbus-TCP: Register", 95 | cfgInvRegPowerInvS :"(!) Modbus-TCP: Register", 96 | cfgInvRegPowerMet :"(!) Modbus-TCP: Register", 97 | cfgInvRegPowerMetS :"(!) Modbus-TCP: Register", 98 | cfgInvRegToGrid :"(!) Modbus-TCP: Register", 99 | cfgInvRegFromGrid :"(!) Modbus-TCP: Register", 100 | cfgInvRegInputGrid :"(!) Modbus-TCP: Register", 101 | cfgInvRegBattery :"(!) Modbus-TCP: Register", 102 | cfgInvRegBattery16 :"(!) Modbus-TCP: Register", 103 | cfgBootlogSize :"(!) intern", 104 | cfgBtnDebounce :"[ms] Entprellzeit für Taster, z.B. 300", 105 | cfgWifiConnectTimeout :"(!) (s) Wartezeit bis wbec bei fehlendem WLAN einen eigenen Access Point öffnet", 106 | cfgResetOnTimeout :"(!) Nullen aller Werte bei Modbus-Timeout", 107 | cfgEnergyOffset :"[Wh] Offset, der vom Energiezähler abgezogen werden kann", 108 | cfgDisplayAutoOff :"[min] Wartezeit für Displayabschaltung", 109 | cfgWifiAutoReconnect :"(!) intern", 110 | cfgWifiScanMethod :"(!) 0: WIFI_FAST_SCAN (default), 1: WIFI_ALL_CHANNEL_SCAN (evtl. bei Mesh)", 111 | cfgLedIp :"IP-Adresse von wbec nach Reset signalisieren, 0:inaktiv, 1:aktiv", 112 | cfgWifiOff :"(!) WLAN abschalten (1) - VORSICHT!", 113 | cfgChargeLog :"Logbuch der Ladevorgänge: 0:inaktiv, 1:aktiv", 114 | cfgWallboxIp :"(!) connect.home Wallbox", 115 | cfgWallboxPort :"(!) connect.home Wallbox", 116 | cfgWallboxAddr :"(!) connect.home Wallbox", 117 | cfgRfidCurr :"[100mA] Strom bei Freischaltung per RFID", 118 | cfgAutoEnable :"1: nach Wakeup von Standby den letzten Stromwert wiederherstellen", 119 | cfgEnwgSource :"§14a EnWG: Quelle: 0:inaktiv, 1:Schließer, 2:Öffner, 3:HTTP, Achtung: permanent!", 120 | cfgEnwgBox :"§14a EnWG: Auswahl der Box für die Leistungsreduzierung", 121 | cfgWbecMac :"(!) wbecLan: Letztes Byte der wbec-MAC-Adresse ändern (dez.)", 122 | cfgWbecIp :"(!) wbecLan: stat. IP-Adresse für wbec, z.B. 192.168.178.123", 123 | } 124 | 125 | 126 | window.addEventListener('DOMContentLoaded', () => { 127 | var rootElement = document.documentElement; 128 | rootElement.style.setProperty('--container-width-max', '1000px'); 129 | 130 | initNavBar(); 131 | document.getElementById('btnStore').addEventListener('click', storeCfg); 132 | document.getElementById('btnReset').addEventListener('click', resetWbec); 133 | document.getElementById('btnRefresh').addEventListener('click', refresh); 134 | document.getElementById('btnResWifi').addEventListener('click', resetWifi); 135 | const settings = {}; 136 | 137 | createHtmlTable(); 138 | 139 | fetch('/cfg.json') 140 | .then(response => response.json()) 141 | .then(data => { 142 | // Walk through all defaultObj parameters and check, whether there is a differing value in "data" 143 | // settings = defaultObj + all changed values from data 144 | for (const key in defaultObj) { 145 | if (data.hasOwnProperty(key) && data[key] !== defaultObj[key]) { 146 | settings[key] = data[key]; 147 | } else { 148 | settings[key] = defaultObj[key]; 149 | } 150 | } 151 | console.log(settings); 152 | // fill the HTML page 153 | for (const key in defaultObj) { 154 | document.getElementById(key).value = settings[key]; 155 | } 156 | }) 157 | .catch(error => { 158 | console.log('cfg.json not found: ', error); 159 | }); 160 | }); 161 | 162 | 163 | function compareObjects(obj1, obj2) { 164 | // Check all objects in the default (obj1), whether they have a differing value in obj2 165 | const result = {}; 166 | for (let key in obj1) { 167 | if ((obj1.hasOwnProperty(key) && obj2.hasOwnProperty(key) && obj1[key] !== obj2[key]) || 168 | (key=="cfgApPass") || (key=="cfgCntWb")) { // to have 2 minimum parameters 169 | result[key] = obj2[key]; // Return a new object that only contains differing values 170 | } 171 | } 172 | return JSON.stringify(result); // return as JSON 173 | } 174 | 175 | 176 | function storeCfg() { 177 | // fetch data from the HTML page 178 | var configObj = {}; 179 | for (const key in defaultObj) { 180 | if (key == 'cfgMqttLp') { // needs special array handling 181 | var val = document.getElementById(key).value; 182 | if (val != '') { 183 | configObj[key] = val.split(',').map(Number); 184 | } 185 | } else { 186 | if (document.getElementById(key).type == 'number') { 187 | configObj[key] = parseInt(document.getElementById(key).value); 188 | } 189 | if (document.getElementById(key).type == 'text') { 190 | configObj[key] = document.getElementById(key).value; 191 | } 192 | } 193 | } 194 | 195 | const deltaJson = compareObjects(defaultObj, configObj); 196 | console.log(deltaJson); 197 | 198 | // append the JSON-String to a FormData and assign a file name 199 | var formData = new FormData(); 200 | formData.append('file', new Blob([deltaJson.replace(',"cfgMqttLp":""', '')], { type: 'application/json' }), '/cfg.json'); 201 | 202 | // configure the POST request 203 | var options = { 204 | method: 'POST', 205 | body: formData 206 | }; 207 | 208 | fetch('/edit', options) 209 | .then(response => { 210 | if (response.ok) { 211 | console.log('POST request sent successfully'); 212 | } else { 213 | console.error('Error during POST request'); 214 | } 215 | }) 216 | .catch(error => { 217 | console.error('Error during POST request: ', error); 218 | }); 219 | } 220 | 221 | 222 | function createHtmlTable() { 223 | var tableContainer = document.createElement('div'); 224 | tableContainer.className = 'config-table'; 225 | 226 | // Iteriere über die Eigenschaften des JSON-Objekts 227 | for (var key in defaultObj) { 228 | if (defaultObj.hasOwnProperty(key)) { 229 | // create the row 230 | var row = document.createElement('div'); 231 | row.className = 'config-row'; 232 | 233 | // create cell 1 234 | var keyCell = document.createElement('div'); 235 | keyCell.className = 'config-cell'; 236 | keyCell.innerHTML = key; 237 | row.appendChild(keyCell); 238 | 239 | // create cell 2 for the input 240 | var inputCell = document.createElement('div'); 241 | inputCell.className = 'config-cell'; 242 | var input = document.createElement('input'); 243 | input.type = typeof defaultObj[key] === 'number' ? 'number' : 'text'; 244 | input.id = key; 245 | inputCell.appendChild(input); 246 | row.appendChild(inputCell); 247 | 248 | // create cell 3 for the description 249 | var descriptionCell = document.createElement('div'); 250 | descriptionCell.className = 'config-cell'; 251 | descriptionCell.innerHTML = descObj[key]; 252 | row.appendChild(descriptionCell); 253 | 254 | tableContainer.appendChild(row); 255 | } 256 | } 257 | var tableBox = document.getElementById('tableBox'); 258 | tableBox.appendChild(tableContainer); 259 | } 260 | 261 | 262 | function resetWbec() { 263 | fetch('/reset'); 264 | } 265 | 266 | 267 | function resetWifi() { 268 | if (confirm('Möchtest du wirklich die WLAN-Zugangsdaten löschen? Hast du dir den Parameter cfgApPass notiert?')) { 269 | window.open('/resetwifi', '_self'); 270 | } 271 | } 272 | 273 | 274 | function refresh() { 275 | location.reload(); 276 | } 277 | -------------------------------------------------------------------------------- /data/log.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | wbec Log 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | 32 |

wbec

33 |

Heidelberg Wallbox Energy Control

34 |
35 | 36 |
37 |
38 |

39 |  Ladeübersicht 40 |

41 |
42 |
43 |
44 |
Start
45 |
Ende
46 |
Geladene
kWh
47 |
Ladedauer
hh:mm
48 |
Wallbox
49 |
50 |
51 | 52 |
53 |
54 |
55 | 56 |
57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /data/log.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 steff393, MIT license 2 | 3 | // ----------------------------- COMMON SECTION: START ------------------------------ 4 | function initNavBar() { 5 | for (const element of document.querySelectorAll('[top-nav-link]')) { 6 | element.addEventListener('click', function() {window.location.href = element.getAttribute('top-nav-link')}); 7 | } 8 | } 9 | 10 | function assignValuesToHtml(values) { 11 | let valueContainerElements = document.querySelectorAll('[data-value]'); 12 | for (const element of valueContainerElements) { 13 | const key = element.getAttribute('data-value'); 14 | if (values[key] !== undefined) { 15 | element.innerHTML = values[key].toLocaleString('de-DE'); 16 | } 17 | } 18 | } 19 | 20 | function setClass(element, className, state) { 21 | if (state) { 22 | element.classList.add(className) 23 | } else { 24 | element.classList.remove(className) 25 | } 26 | } 27 | 28 | function setSectionVisibility(sectionId, isVisible) { 29 | setClass(document.getElementById(sectionId), 'not-available', !isVisible); 30 | } 31 | // ----------------------------- COMMON SECTION: END ------------------------------ 32 | 33 | window.addEventListener('DOMContentLoaded', () => { 34 | let tableHeaders = document.querySelectorAll('.table-head'); 35 | var sortOrder = 1; 36 | 37 | function init() { 38 | initNavBar(); 39 | document.getElementById('btnExport').addEventListener('click', exportToExcel); 40 | 41 | for (const element of tableHeaders) { 42 | element.addEventListener('click', sortTable); 43 | } 44 | const cfgCntWb = 1; // number of connected wallboxes 45 | for (let i = 0; i < cfgCntWb; i++) { 46 | let url="/chargelog?id=" + i + "&len=10"; 47 | fetch(url) 48 | .then(response => response.json()) 49 | .then((msg) => updateData(msg)) 50 | } 51 | } 52 | 53 | 54 | function formatDate(xtime) { // convert unixtime to formatted date 55 | const date = new Date(xtime * 1000); 56 | const day = date.getDate().toString().padStart(2, '0'); // day with leading 0 57 | const month = (date.getMonth() + 1).toString().padStart(2, '0'); // month with leading 0 (months start at 0) 58 | const year = date.getFullYear().toString(); 59 | const hours = date.getHours().toString().padStart(2, '0'); 60 | const minutes = date.getMinutes().toString().padStart(2, '0'); 61 | const formattedDate = `${day}.${month}.${year} | ${hours}:${minutes}`; 62 | return(formattedDate); 63 | } 64 | 65 | 66 | function formatDur(dur) { 67 | const hours = Math.floor(dur / 3600); 68 | const minutes = Math.floor((dur % 3600) / 60).toString().padStart(2, '0'); 69 | const formattedDate = `${hours}:${minutes}`; 70 | return(formattedDate); 71 | } 72 | 73 | 74 | function updateData(data) { 75 | const message = data; 76 | let header = document.getElementById('logTable'); 77 | for (const line of message.line.reverse()) { 78 | var row = document.createElement('div'); 79 | row.className = 'table-row'; 80 | var cells = []; 81 | for (var i = 0; i <= 7; i++) { // nr of cells, incl. hidden 82 | var cell = document.createElement('div'); 83 | cell.className = 'table-cell'; 84 | row.appendChild(cell); 85 | cells.push(cell); 86 | } 87 | header.appendChild(row); 88 | cells[0].innerHTML = formatDate(line.timestamp); 89 | cells[1].innerHTML = line.timestamp; 90 | cells[1].style.display = 'none'; // only for sorting 91 | cells[2].innerHTML = formatDate(line.timestamp + line.duration); 92 | cells[3].innerHTML = line.timestamp + line.duration 93 | cells[3].style.display = 'none'; // only for sorting 94 | cells[4].innerHTML = (line.energy / 1000).toFixed(3); 95 | cells[5].innerHTML = formatDur (line.duration); 96 | cells[6].innerHTML = line.duration; 97 | cells[6].style.display = 'none'; // only for sorting 98 | cells[7].innerHTML = line.box + 1; 99 | } 100 | } 101 | 102 | 103 | function sortTable() { 104 | const col = Array.from(this.parentNode.children).indexOf(this); 105 | const map = {0:1, 1:3, 2:4, 3:6, 4:7}; // map the visible clicked column (of header) to the maybe invisible columns, see above 106 | var table = document.getElementById('logTable'); 107 | var rows = table.querySelectorAll('.table-row'); 108 | var sortedRows = Array.prototype.slice.call(rows, 1); // slice(1) removes the header line 109 | sortedRows.sort(function(row1, row2) { 110 | var val1 = parseFloat(row1.querySelectorAll('.table-cell')[map[col]].textContent); 111 | var val2 = parseFloat(row2.querySelectorAll('.table-cell')[map[col]].textContent); 112 | if (val1 < val2) { 113 | return -1 * sortOrder; 114 | } 115 | if (val1 > val2) { 116 | return 1 * sortOrder; 117 | } 118 | return 0; 119 | }); 120 | table.innerHTML = ""; // delete the content 121 | table.appendChild(rows[0]); // add the header again 122 | sortedRows.forEach(function(row) { 123 | table.appendChild(row); // append sorted rows 124 | }); 125 | sortOrder *= -1; // invert the sortOrder 126 | } 127 | 128 | 129 | init(); 130 | }); 131 | 132 | 133 | function exportToExcel() { 134 | var table = document.getElementById("logTable"); 135 | var rows = table.getElementsByClassName("table-row"); 136 | var csvContent = '"Startdatum","Startzeit","Enddatum","Endzeit","Energie","Dauer","Wallbox"'; 137 | 138 | for (var i = 0; i < rows.length; i++) { 139 | var cells = rows[i].getElementsByClassName("table-cell"); 140 | 141 | for (var j = 0; j < cells.length; j++) { 142 | var cellData = cells[j].innerText.trim(); 143 | if (j!=1 && j!=3 && j!=6) { // remove the hidden colums with raw values 144 | if (j==4) { 145 | csvContent += '"' + cellData.replace('.', ',') + '",'; // special handling for the energy decimal point 146 | } else 147 | { 148 | csvContent += '"' + cellData.replace(' | ', '","') + '",'; 149 | } 150 | } 151 | } 152 | csvContent += "\n"; 153 | } 154 | 155 | // create a temporary link in order to download the csv 156 | var downloadLink = document.createElement("a"); 157 | downloadLink.href = "data:text/csv;charset=utf-8," + encodeURIComponent(csvContent); 158 | downloadLink.download = "wbecLog.csv"; 159 | downloadLink.style.display = "none"; 160 | document.body.appendChild(downloadLink); 161 | downloadLink.click(); 162 | document.body.removeChild(downloadLink); 163 | } 164 | -------------------------------------------------------------------------------- /data/pvChart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | wbec Chart 6 | 7 | 8 | 9 | 10 | 11 |
12 |

wbec

13 |

Heidelberg Wallbox Energy Control

14 |
15 |
Loading ...
16 | 25 |
26 | 27 | 28 | 29 | 30 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /data/pvChart.js: -------------------------------------------------------------------------------- 1 | // based on https://github.com/tttapa/ESP8266, GPL-3.0 License 2 | var dataArray = []; 3 | 4 | var defaultZoomTime = 6*60*60*1000; // 6 hours 5 | var minZoom = -6; // 22 minutes 30 seconds 6 | var maxZoom = 3; // ~ 1 week 7 | 8 | var zoomLevel = 0; 9 | var viewportEndTime = new Date(); 10 | var viewportStartTime = new Date(); 11 | 12 | loadCSV(); // Download the CSV data, load Google Charts, parse the data, and draw the chart 13 | 14 | 15 | /* 16 | Structure: 17 | 18 | loadCSV 19 | callback: 20 | parseCSV 21 | load Google Charts (anonymous) 22 | callback: 23 | updateViewport 24 | displayDate 25 | drawChart 26 | */ 27 | 28 | /* 29 | | CHART | 30 | | VIEW PORT | 31 | invisible | visible | invisible 32 | ---------------|---------------------------------------------|---------------> time 33 | viewportStartTime viewportEndTime 34 | 35 | |______________viewportWidthTime______________| 36 | 37 | viewportWidthTime = 1 day * 2^zoomLevel = viewportEndTime - viewportStartTime 38 | */ 39 | 40 | function loadCSV() { 41 | var xmlhttp = new XMLHttpRequest(); 42 | xmlhttp.onreadystatechange = function() { 43 | if (this.readyState == 4 && this.status == 200) { 44 | dataArray = parseCSV(this.responseText); 45 | google.charts.load('current', { 'packages': ['line', 'corechart'] }); 46 | google.charts.setOnLoadCallback(updateViewport); 47 | } 48 | }; 49 | xmlhttp.open("GET", "pv.txt", true); 50 | xmlhttp.send(); 51 | var loadingdiv = document.getElementById("loading"); 52 | loadingdiv.style.visibility = "visible"; 53 | } 54 | 55 | function parseCSV(string) { 56 | var array = []; 57 | var lines = string.split("\n"); 58 | for (var i = 0; i < lines.length; i++) { 59 | var data = lines[i].split(";", 3); 60 | data[0] = new Date(parseInt(data[0]) * 1000); 61 | data[1] = parseInt(data[1]); 62 | data[2] = parseInt(data[2]); //parseFloat 63 | array.push(data); 64 | } 65 | return array; 66 | } 67 | 68 | function drawChart() { 69 | var data = new google.visualization.DataTable(); 70 | data.addColumn('datetime', 'UNIX'); 71 | data.addColumn('number', 'Bezug(+)/Einspeisung(-)'); 72 | data.addColumn('number', 'Ladeleistung der Wallbox'); 73 | 74 | data.addRows(dataArray); 75 | 76 | var options = { 77 | curveType: 'function', 78 | 79 | height: 840, 80 | 81 | legend: { position: 'top' }, 82 | 83 | hAxis: { 84 | viewWindow: { 85 | min: viewportStartTime, 86 | max: viewportEndTime 87 | }, 88 | gridlines: { 89 | count: -1, 90 | units: { 91 | days: { format: ['dd. MMM'] }, 92 | hours: { format: ['HH:mm', 'ha'] }, 93 | } 94 | }, 95 | minorGridlines: { 96 | units: { 97 | hours: { format: ['hh:mm:ss a', 'ha'] }, 98 | minutes: { format: ['HH:mm a Z', ':mm'] } 99 | } 100 | } 101 | }, 102 | vAxis: { 103 | title: "Leistung [W]" 104 | } 105 | }; 106 | 107 | var chart = new google.visualization.LineChart(document.getElementById('chart_div')); 108 | 109 | chart.draw(data, options); 110 | 111 | var dateselectdiv = document.getElementById("dateselect"); 112 | dateselectdiv.style.visibility = "visible"; 113 | 114 | var loadingdiv = document.getElementById("loading"); 115 | loadingdiv.style.visibility = "hidden"; 116 | } 117 | 118 | function displayDate() { // Display the start and end date on the page 119 | var dateDiv = document.getElementById("date"); 120 | 121 | var startDay = viewportStartTime.getDate(); 122 | var startMonth = viewportStartTime.getMonth(); 123 | var startYear = viewportStartTime.getFullYear(); 124 | var endDay = viewportEndTime.getDate(); 125 | var endMonth = viewportEndTime.getMonth(); 126 | var endYear = viewportEndTime.getFullYear(); 127 | 128 | if (endDay == startDay && endMonth == startMonth) { 129 | dateDiv.textContent = (endDay).toString() + "." + (endMonth + 1).toString() + "." + (endYear).toString(); 130 | } else { 131 | dateDiv.textContent = (startDay).toString() + "." + (startMonth + 1).toString() + "." + (startYear).toString() + " - " + (endDay).toString() + "." + (endMonth + 1).toString() + "." + (endYear).toString(); 132 | } 133 | } 134 | 135 | document.getElementById("prev").onclick = function() { 136 | viewportEndTime = new Date(viewportEndTime.getTime() - getViewportWidthTime()/3); // move the viewport to the left for one third of its width (e.g. if the viewport width is 3 days, move one day back in time) 137 | updateViewport(); 138 | } 139 | document.getElementById("next").onclick = function() { 140 | viewportEndTime = new Date(viewportEndTime.getTime() + getViewportWidthTime()/3); // move the viewport to the right for one third of its width (e.g. if the viewport width is 3 days, move one day into the future) 141 | updateViewport(); 142 | } 143 | 144 | document.getElementById("zoomout").onclick = function() { 145 | zoomLevel += 1; // increment the zoom level (zoom out) 146 | if(zoomLevel > maxZoom) zoomLevel = maxZoom; 147 | else updateViewport(); 148 | } 149 | document.getElementById("zoomin").onclick = function() { 150 | zoomLevel -= 1; // decrement the zoom level (zoom in) 151 | if(zoomLevel < minZoom) zoomLevel = minZoom; 152 | else updateViewport(); 153 | } 154 | 155 | document.getElementById("reset").onclick = function() { 156 | viewportEndTime = new Date(); // the end time of the viewport is the current time 157 | zoomLevel = 0; // reset the zoom level to the default (one day) 158 | updateViewport(); 159 | } 160 | document.getElementById("refresh").onclick = function() { 161 | viewportEndTime = new Date(); // the end time of the viewport is the current time 162 | loadCSV(); // download the latest data and re-draw the chart 163 | } 164 | 165 | document.body.onresize = drawChart; 166 | 167 | function updateViewport() { 168 | viewportStartTime = new Date(viewportEndTime.getTime() - getViewportWidthTime()); 169 | displayDate(); 170 | drawChart(); 171 | } 172 | function getViewportWidthTime() { 173 | return(defaultZoomTime*(2**zoomLevel)); // exponential relation between zoom level and zoom time span 174 | // every time you zoom, you double or halve the time scale 175 | } 176 | -------------------------------------------------------------------------------- /data/time.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | wbec Time 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 | 36 |

wbec

37 |

Heidelberg Wallbox Energy Control

38 |
39 | 40 |
41 |
42 |
43 | 44 | 45 | 46 |
47 |
48 |
49 | 50 |
51 |
52 |

53 |  Zeitsteuerung 54 |

55 |
56 |
57 | 0h 58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | 24h 66 |
67 | 68 |
69 |
70 |

71 |
Beginn: - Uhr, - A
72 |

73 |
74 |
75 | 0A 76 | 77 | 16A 78 |
79 |
80 |
81 | 84 |
85 |
86 |
87 |

88 |
Ende: - Uhr, - A
89 |

90 |
91 |
92 | 0A 93 | 94 | 16A 95 |
96 |
97 |
98 | 101 |
102 |
103 |
104 | 105 |
106 | 107 |

108 |  Energielimit: + - kWh 109 |

110 |
111 |
112 | 113 |
114 |
115 |
116 | 119 |
120 | 121 |

122 |

123 |
124 | 125 | 126 | 127 |
128 |
129 |
130 | 131 |

Geschätztes Ergebnis der An-Phase

132 |
133 | 134 |
135 |

136 |  Leistung 137 |

138 |
139 | ca. - kW 140 |
141 |
142 | 143 |
144 |

145 |  Ladung 146 |

147 |
148 | + ca. - % 149 |
150 |
151 | 152 |
153 |

154 |  Energiemenge 155 |

156 |
157 | + ca. - kWh (- €) 158 |
159 |
160 | 161 |
162 |

163 |  Fahrleistung 164 |

165 |
166 | + ca. - km 167 |
168 |
169 | 170 |
171 | 172 |
173 | 174 |
175 |

176 |  Energiezähler aktuell 177 |

178 |
179 | - kWh 180 |
181 |
182 | 183 |
184 |

185 |  Abschaltung bei 186 |

187 |
188 | - kWh 189 |
190 |
191 | 192 |
193 | 194 |
195 | 196 | 197 | 198 | -------------------------------------------------------------------------------- /data/time.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 steff393 2 | 3 | // ----------------------------- COMMON SECTION: START ------------------------------ 4 | function initNavBar() { 5 | for (const element of document.querySelectorAll('[top-nav-link]')) { 6 | element.addEventListener('click', function() {window.location.href = element.getAttribute('top-nav-link')}); 7 | } 8 | } 9 | 10 | function assignValuesToHtml(values) { 11 | let valueContainerElements = document.querySelectorAll('[data-value]'); 12 | for (const element of valueContainerElements) { 13 | const key = element.getAttribute('data-value'); 14 | if (values[key] !== undefined) { 15 | element.innerHTML = values[key].toLocaleString('de-DE'); 16 | } 17 | } 18 | } 19 | 20 | function setClass(element, className, state) { 21 | if (state) { 22 | element.classList.add(className) 23 | } else { 24 | element.classList.remove(className) 25 | } 26 | } 27 | 28 | function setSectionVisibility(sectionId, isVisible) { 29 | setClass(document.getElementById(sectionId), 'not-available', !isVisible); 30 | } 31 | // ----------------------------- COMMON SECTION: END ------------------------------ 32 | 33 | window.addEventListener('DOMContentLoaded', () => { 34 | // Adjustable values ----- 35 | let kWhPer100km = 20; // kWh needed for driving 100km 36 | let phases = 3; // number of phases 37 | let maxCapacity = 65; // maximum battery capacity of car in kWh 38 | let corrFactor = 1; // correction factor, can be changed to 1.1 or 1.2 if needed 39 | let pricePerkWh = 30 // ct per kWh 40 | let maxEnergyDelta = 200 // defines range of the energy slider in kWh 41 | let timeResolution = 4 // 4=quarter hours, 12 = 5mins, 60 = 1min 42 | // ----------------------- 43 | let id = 0; // currently only first box supported 44 | let knobShift = 10; // vertical offset of the knobs 45 | let sliderContainer = document.getElementById('sliderContainer'); 46 | let sliderTrack = document.getElementById('sliderTrack'); 47 | let sliderRange = document.getElementById('sliderRange'); 48 | let sliderKnobStart = document.getElementById('sliderKnobStart'); 49 | let sliderKnobEnd = document.getElementById('sliderKnobEnd'); 50 | let elementSliderCurrStart = document.getElementById('sliderCurrStart'); 51 | let elementSliderCurrEnd = document.getElementById('sliderCurrEnd'); 52 | let elementSliderEnergy = document.getElementById('sliderEnergy'); 53 | let cbOnce = document.getElementById('cbOnce'); 54 | let cbOnlyActiveOff = document.getElementById('cbOnlyActiveOff'); 55 | let cbDailyEnergyDelta = document.getElementById('cbDailyEnergyDelta'); 56 | let btnLoad = document.getElementById('btnLoad'); 57 | let btnSave = document.getElementById('btnSave'); 58 | let btnDeact = document.getElementById('btnDeact'); 59 | let divTimeStart = document.getElementById('divTimeStart'); 60 | let divSliderStart = document.getElementById('divSliderStart'); 61 | let wallboxButtons = document.querySelectorAll('[data-wallbox-id]'); 62 | let activeElement = null; // Flag to check which element (knob or range) is being moved 63 | let energyNow = 0; 64 | 65 | function init() { 66 | setSectionVisibility('boxSelection', wallboxButtons.length > 1); 67 | initNavBar(); 68 | load(); 69 | 70 | sliderKnobStart.addEventListener('mousedown', handleElementStart); 71 | sliderKnobStart.addEventListener('touchstart', handleElementStart); 72 | sliderKnobEnd. addEventListener('mousedown', handleElementStart); 73 | sliderKnobEnd. addEventListener('touchstart', handleElementStart); 74 | 75 | sliderRange.addEventListener('mousedown', handleElementStart); 76 | sliderRange.addEventListener('touchstart', handleElementStart); 77 | 78 | window.addEventListener('mouseup', handleElementEnd); 79 | window.addEventListener('touchend', handleElementEnd); 80 | window.addEventListener('mousemove', handleElementMove); 81 | window.addEventListener('touchmove', handleElementMove); 82 | 83 | elementSliderCurrStart.addEventListener('input', onSetCurrentSliderStart); 84 | elementSliderCurrEnd .addEventListener('input', onSetCurrentSliderEnd); 85 | elementSliderEnergy .addEventListener('input', onSetSliderEnergy); 86 | 87 | cbOnlyActiveOff .addEventListener('click', onCbOnlyActiveOff); 88 | btnLoad .addEventListener('click', load); 89 | btnSave .addEventListener('click', save); 90 | btnDeact .addEventListener('click', deactivate); 91 | 92 | elementSliderEnergy.max = maxEnergyDelta; 93 | } 94 | 95 | function onCbOnlyActiveOff() { 96 | if (cbOnlyActiveOff.checked) { 97 | // disable the Start slider, when OnlyActiveOff is set 98 | divTimeStart .classList.add('divDisabled'); 99 | divSliderStart.classList.add('divDisabled'); 100 | elementSliderCurrStart.disabled = true; 101 | elementSliderCurrStart.value = 0; 102 | onSetCurrentSliderStart(); 103 | } else { 104 | divTimeStart .classList.remove('divDisabled'); 105 | divSliderStart.classList.remove('divDisabled'); 106 | elementSliderCurrStart.disabled = false; 107 | } 108 | } 109 | 110 | function limitCurrent(val) { 111 | if (val >= 60 && val <= 160) { 112 | return(val); 113 | } else if (val > 50 && val < 60 ) { 114 | return(60); 115 | } else { 116 | return(0); 117 | } 118 | } 119 | 120 | function onSetCurrentSliderStart() { 121 | let val = limitCurrent(parseInt(elementSliderCurrStart.value)); 122 | elementSliderCurrStart.value = val; 123 | assignValuesToHtml({ 124 | currStart : val / 10, 125 | }); 126 | reCalculation(); 127 | } 128 | 129 | function onSetCurrentSliderEnd() { 130 | let val = limitCurrent(parseInt(elementSliderCurrEnd.value)); 131 | elementSliderCurrEnd.value = val; 132 | assignValuesToHtml({ 133 | currEnd : val / 10, 134 | }); 135 | reCalculation(); 136 | } 137 | 138 | function onSetSliderEnergy() { 139 | reCalculation(); 140 | } 141 | 142 | function reCalculation() { 143 | let eDel = parseInt(elementSliderEnergy.value) 144 | assignValuesToHtml({ 145 | powerEst : getPowerEstimation(), 146 | energyEst : getEnergyEstimation(), 147 | mileageEst : getMilageEstimation(), 148 | percentEst : getPercentEstimation(), 149 | priceEst : getPriceEstimation(), 150 | energyDelta : eDel, 151 | energyTarget : eDel ? energyNow + eDel : '-' 152 | }); 153 | } 154 | 155 | function getPowerEstimation() { 156 | let power = parseInt(elementSliderCurrStart.value) / 10 * 230 * phases; 157 | return(parseFloat((power/1000).toFixed(1))); 158 | } 159 | 160 | function getEnergyEstimation() { 161 | let startTime = getTime(sliderKnobStart.offsetLeft + knobShift); 162 | let endTime = getTime(sliderKnobEnd. offsetLeft + knobShift); 163 | let power = parseInt(elementSliderCurrStart.value) / 10 * 230 * phases; 164 | let duration = endTime - startTime; 165 | if (startTime > endTime) { 166 | duration += 24; 167 | } 168 | let energy = power * duration / 1000 * corrFactor; 169 | energy = Math.min(energy, maxCapacity); 170 | if (elementSliderEnergy.value != 0) { 171 | energy = Math.min(energy, parseInt(elementSliderEnergy.value)); 172 | } 173 | return(parseFloat(energy.toFixed(1))); 174 | } 175 | 176 | function getMilageEstimation() { 177 | let milage = getEnergyEstimation() * 100 / kWhPer100km; 178 | return(parseInt(milage.toFixed(0))); 179 | } 180 | 181 | function getPercentEstimation() { 182 | let percent = getEnergyEstimation() * 100 / maxCapacity; 183 | percent = Math.min(percent, 100); 184 | return(parseInt(percent.toFixed(0))); 185 | } 186 | 187 | function getPriceEstimation() { 188 | let price = getEnergyEstimation() / 100 * pricePerkWh; 189 | return(parseInt(price.toFixed(0))); 190 | } 191 | 192 | function dec2hhmm(decimalTime) { 193 | const wholeNumberPart = Math.floor(decimalTime); 194 | const decimalPart = decimalTime - wholeNumberPart; 195 | const minutes = Math.round(decimalPart * 60); 196 | 197 | // Ensure minutes are displayed with leading zero if needed (e.g., 4:03 instead of 4:3) 198 | const formattedMinutes = String(minutes).padStart(2, '0'); 199 | 200 | return `${wholeNumberPart}:${formattedMinutes}`; 201 | } 202 | 203 | function pos2time(pos) { 204 | return(pos / sliderTrack.offsetWidth * 24); 205 | } 206 | 207 | function time2pos(time) { 208 | return(time / 24 * sliderTrack.offsetWidth); 209 | } 210 | 211 | // Converts the slider's position to the corresponding value 212 | function getTime(position) { 213 | let value = (pos2time(position) * timeResolution).toFixed(0) / timeResolution; // convert to quarterly hours 214 | return(parseFloat(value.toFixed(2))); 215 | } 216 | 217 | // Updates the displayed time range 218 | function updateTimeRange() { 219 | let startTime = getTime(sliderKnobStart.offsetLeft + knobShift); 220 | let endTime = getTime(sliderKnobEnd. offsetLeft + knobShift); 221 | 222 | assignValuesToHtml({ 223 | timeStart : dec2hhmm(startTime), 224 | timeEnd : dec2hhmm(endTime), 225 | }); 226 | reCalculation(); 227 | } 228 | 229 | function drawSlider(start, end) { 230 | let invert = start > end; 231 | sliderKnobStart.style.left = start - knobShift + 'px'; 232 | sliderKnobEnd. style.left = end - knobShift + 'px'; // inverted mode : normal mode 233 | sliderRange.style.left = (invert ? end : start) + 'px'; 234 | sliderRange.style.width = (invert ? (start - end) : (end - start)) + 'px'; 235 | sliderRange.style.backgroundColor = invert ? 'var(--theme-color-5)' : 'var(--theme-color-2)'; 236 | sliderTrack.style.backgroundColor = invert ? 'var(--theme-color-2)' : 'var(--theme-color-5)'; 237 | } 238 | 239 | // Activates the element (knob or range) when a mouse or touch start event is triggered 240 | function handleElementStart(event) { 241 | activeElement = event.target; 242 | 243 | if (event.type === 'touchstart') { 244 | event.preventDefault(); // Prevents screen scrolling during slider operation 245 | } 246 | } 247 | 248 | // Deactivates the element (knob or range) when a mouse or touch end event is triggered 249 | function handleElementEnd() { 250 | activeElement = null; 251 | } 252 | 253 | // Moves the element (knob or range) when a mouse or touch move event is triggered 254 | function handleElementMove(event) { 255 | if (activeElement) { 256 | let newPosition = event.clientX; 257 | 258 | if (event.type === 'touchmove') { 259 | newPosition = event.touches[0].clientX; // Takes finger position into account for touch events 260 | } 261 | 262 | let rect = sliderContainer.getBoundingClientRect(); 263 | newPosition -= rect.left; 264 | 265 | newPosition = Math.max(0, Math.min(newPosition, sliderTrack.offsetWidth)); 266 | 267 | let posStart = sliderKnobStart.offsetLeft + knobShift; 268 | let posEnd = sliderKnobEnd. offsetLeft + knobShift; 269 | let oldWidth = posEnd - posStart; 270 | 271 | if (activeElement === sliderKnobStart) { 272 | posStart = newPosition; 273 | } else if (activeElement === sliderKnobEnd) { 274 | posEnd = newPosition; 275 | } else if (activeElement === sliderRange) { 276 | posStart = newPosition - oldWidth / 2; // newPosition is exactly the middle of the bar 277 | if (posStart < posEnd) { 278 | posStart = Math.max(0, Math.min(posStart, sliderTrack.offsetWidth - oldWidth)); // keep the range inside the track 279 | } else { 280 | posStart = Math.max(0 - oldWidth, Math.min(posStart, sliderTrack.offsetWidth)); // keep the range inside the track (in inverted mode) 281 | } 282 | posEnd = posStart + oldWidth; 283 | } 284 | drawSlider(posStart, posEnd); 285 | updateTimeRange(); 286 | } 287 | } 288 | 289 | function load() { 290 | fetch('/time?id=' + id) 291 | .then(response => response.json()) 292 | .then(data => { 293 | drawSlider(time2pos(data['mOn'] / 60), time2pos(data['mOff'] / 60)); 294 | elementSliderCurrStart.value = data['cOn']; 295 | elementSliderCurrEnd. value = data['cOff']; 296 | elementSliderEnergy. value = data['eDel']; 297 | energyNow = data['eNow']; 298 | assignValuesToHtml({ 299 | currStart : data['cOn'] / 10, 300 | currEnd : data['cOff'] / 10, 301 | energyDelta : data['eDel'], 302 | energyNow : data['eNow'], 303 | energyTarget : data['eDel'] ? data['eNow'] + data['eDel'] : '-' 304 | }) 305 | cbOnce.checked = data['flag'] & 0x01; 306 | cbOnlyActiveOff.checked = data['flag'] & 0x02; 307 | cbDailyEnergyDelta.checked = data['flag'] & 0x04; 308 | updateTimeRange(); 309 | onCbOnlyActiveOff(); 310 | }) 311 | .catch(error => { 312 | console.log('No answer to /time: ', error); 313 | }); 314 | } 315 | 316 | function save() { 317 | let mOn = getTime(sliderKnobStart.offsetLeft + knobShift) * 60; // minutes since midnight 318 | let mOff = getTime(sliderKnobEnd. offsetLeft + knobShift) * 60; 319 | let cOn = elementSliderCurrStart.value; 320 | let cOff = elementSliderCurrEnd.value; 321 | let flag = cbOnce.checked + 322 | 2 * cbOnlyActiveOff.checked + 323 | 4 * cbDailyEnergyDelta.checked; 324 | let eDel = elementSliderEnergy.value; 325 | // example: http://wbec.local/time?mOn=900&mOff=975&cOn=124&cOff=66&flag=0&eDel=1000&id=0 326 | fetch('/time?mOn=' + mOn + '&mOff=' + mOff + '&cOn=' + cOn + '&cOff=' + cOff + '&flag=' + flag + '&eDel=' + eDel + '&id=' + id) 327 | .catch(error => { 328 | console.log('No answer to /time: ', error); 329 | }); 330 | } 331 | 332 | function deactivate() { 333 | fetch('/time?mOn=0&mOff=0&cOn=0&cOff=0&flag=0&eDel=0&id=' + id) 334 | .then(data => { 335 | load(); 336 | }) 337 | .catch(error => { 338 | console.log('No answer to /time: ', error); 339 | }) 340 | } 341 | 342 | // Initialization 343 | init(); 344 | }); 345 | -------------------------------------------------------------------------------- /data/web.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --theme-color-1: rgb(106, 106, 106); 3 | --theme-color-2: rgb(40, 152, 161); 4 | --theme-color-3: rgb(239, 239, 243); 5 | --theme-color-4: rgb(0, 0, 0); 6 | --theme-color-5: rgb(169, 169, 169); 7 | --theme-color-6: rgb(255, 165, 0); 8 | --theme-color-7: rgb(255, 0, 0); 9 | } 10 | 11 | :root { 12 | --body-background-color: var(--theme-color-1); 13 | 14 | --container-width-min: 250px; 15 | --container-width-max: 640px; 16 | 17 | --headline-1-color: var(--theme-color-2); 18 | --headline-2-color: var(--theme-color-3); 19 | --connection-color: var(--theme-color-6); 20 | --enwgError-color: var(--theme-color-7); 21 | 22 | --box-background-color: var(--theme-color-3); 23 | --box-text-color: var(--theme-color-4); 24 | --main-disabled: var(--theme-color-5); 25 | 26 | --grid-size: 10px; 27 | --main-border-radius: var(--grid-size); 28 | } 29 | 30 | body { 31 | display: flex; 32 | justify-content: center; 33 | } 34 | 35 | body { 36 | background-color: var(--body-background-color); 37 | color: var(--box-background-color); 38 | font-family: 'Arial', sans-serif; 39 | } 40 | 41 | h1 { 42 | color: var(--headline-1-color); 43 | text-align: center; 44 | font-size: 2em; 45 | } 46 | 47 | h2 { 48 | color: var(--headline-2-color); 49 | text-align: center; 50 | font-size: 1.25em; 51 | font-weight: normal; 52 | } 53 | 54 | .containerHeader { 55 | display: flex; 56 | justify-content: center; 57 | align-items: center; 58 | position: relative; 59 | padding-top: var(--grid-size); 60 | padding-bottom: calc(3*var(--grid-size)); 61 | } 62 | 63 | .title { 64 | text-align: center; 65 | position: absolute; 66 | left: 50%; 67 | transform: translateX(-50%); 68 | } 69 | 70 | .enwgWarning { 71 | color: var(--enwgError-color); 72 | text-align: center; 73 | font-size: 1em; 74 | font-weight: bold; 75 | padding-top: var(--grid-size); 76 | } 77 | 78 | .enwg14aClass { 79 | position: absolute; 80 | left: 0; 81 | color: var(--connection-color); 82 | font-size: 0.75em; 83 | } 84 | 85 | .connectionOff { 86 | position: absolute; 87 | right: 0; 88 | color: var(--connection-color); 89 | font-size: 0.75em; 90 | } 91 | 92 | footer { 93 | margin-top: 20px; 94 | text-align: center; 95 | font-size: 0.75em; 96 | } 97 | 98 | .container { 99 | width: 100%; 100 | max-width: var(--container-width-max); 101 | min-width: var(--container-width-min); 102 | } 103 | 104 | .top-nav { 105 | max-width: 820px; 106 | text-align: right; 107 | } 108 | .top-nav-item { 109 | font-weight: bold; 110 | } 111 | .top-nav-text { 112 | font-weight: normal; 113 | } 114 | 115 | .box-group { 116 | margin: 0 calc(var(--grid-size) * -1); 117 | display: flex; 118 | flex-wrap: wrap; 119 | } 120 | 121 | .box-group .box { 122 | margin: var(--grid-size); 123 | flex: 1 0 34%; /* allow max 2 items per row */ 124 | } 125 | 126 | .box { 127 | background-color: var(--box-background-color); 128 | color: var(--box-text-color); 129 | border-radius: var(--main-border-radius); 130 | padding: var(--grid-size) calc(2*var(--grid-size)) calc(2*var(--grid-size)); 131 | } 132 | 133 | .box-header { 134 | color: var(--headline-1-color); 135 | margin-top: 0; 136 | } 137 | 138 | .box-header svg { 139 | fill: var(--headline-1-color); 140 | } 141 | 142 | .box-content { 143 | font-weight: bold; 144 | text-align: center; 145 | } 146 | 147 | .time-end { 148 | color: var(--body-background-color); 149 | } 150 | 151 | .divDisabled { 152 | color: var(--main-disabled); 153 | } 154 | 155 | .value { 156 | display: block; 157 | } 158 | 159 | .not-available { 160 | display: none !important; 161 | } 162 | 163 | .btn { 164 | border-radius: var(--main-border-radius); 165 | padding: var(--grid-size); 166 | border: none; 167 | cursor: pointer; 168 | } 169 | 170 | .box .btn { 171 | background-color: var(--body-background-color); 172 | color: var(--box-background-color); 173 | font-size: 1.2rem; 174 | margin-bottom: var(--grid-size); 175 | } 176 | 177 | .btn svg { 178 | fill: var(--box-background-color); 179 | } 180 | 181 | .btn.active, .btn:hover { 182 | background-color: var(--headline-1-color); 183 | } 184 | 185 | .btn.disabled { 186 | background-color: var(--main-disabled); 187 | } 188 | 189 | .btn.active, .btn.disabled { 190 | cursor: default; 191 | } 192 | 193 | .btn.disabled:hover { 194 | background-color: var(--main-disabled); 195 | } 196 | 197 | .table { 198 | display: table; 199 | width: calc(100% - 10px); 200 | padding: 5px; 201 | } 202 | 203 | .table-head { 204 | display: table-cell; 205 | font-weight: bold; 206 | } 207 | 208 | .table-row { 209 | display: table-row; 210 | } 211 | 212 | .table-cell { 213 | display: table-cell; 214 | font-weight: normal; 215 | padding: 5px; 216 | } 217 | 218 | .config-table { 219 | display: table; 220 | width: 100%; 221 | border-collapse: collapse; 222 | } 223 | 224 | .config-row { 225 | display: table-row; 226 | } 227 | 228 | .config-cell { 229 | display: table-cell; 230 | text-align: left; 231 | font-size: 0.8em; 232 | font-weight: normal; 233 | padding: 5px; 234 | border: 1px solid #ccc; 235 | } 236 | 237 | svg { 238 | width: 1rem; 239 | height: 1rem; 240 | float: left; 241 | margin: 2px 0; 242 | } 243 | 244 | .slider-wrapper { 245 | display: flex; 246 | margin: var(--grid-size) 0; 247 | } 248 | 249 | .slider { 250 | --slider-height: 0.8rem; 251 | -webkit-appearance: none; /* Override default CSS styles */ 252 | appearance: none; 253 | width: 100%; /* Full-width */ 254 | margin: auto var(--grid-size); 255 | height: var(--slider-height); 256 | border-radius: calc(var(--slider-height) / 2); 257 | background: var(--theme-color-5); /* Grey background */ 258 | outline: none; /* Remove outline */ 259 | opacity: 0.7; /* Set transparency (for mouse-over effects on hover) */ 260 | -webkit-transition: .2s; /* 0.2 seconds transition on hover */ 261 | transition: opacity .2s; 262 | } 263 | 264 | .slider:hover { 265 | opacity: 1; /* Fully shown on mouse-over */ 266 | } 267 | 268 | /* The slider handle (use -webkit- (Chrome, Opera, Safari, Edge) and -moz- (Firefox) to override default look) */ 269 | .slider::-webkit-slider-thumb { 270 | -webkit-appearance: none; /* Override default look */ 271 | appearance: none; 272 | width: 1.5rem; /* Set a specific slider handle width */ 273 | height: 1.5rem; 274 | border-radius: 50%; 275 | background: var(--theme-color-2); 276 | cursor: pointer; /* Cursor on hover */ 277 | } 278 | 279 | .slider::-moz-range-thumb { 280 | width: 1.5rem; /* Set a specific slider handle width */ 281 | height: 1.5rem; 282 | border-radius: 50%; 283 | background: var(--theme-color-2); 284 | cursor: pointer; /* Cursor on hover */ 285 | } 286 | 287 | /* ---------- Slider for time based charging ---------- */ 288 | 289 | #sliderContainer { 290 | width: 100%; 291 | margin-bottom: 20px; 292 | } 293 | #sliderTrack { 294 | --slider-height: 0.8rem; 295 | -webkit-appearance: none; /* Override default CSS styles */ 296 | appearance: none; 297 | width: 95%; 298 | margin: auto var(--grid-size); 299 | height: var(--slider-height); 300 | border-radius: calc(var(--slider-height) / 2); 301 | background: var(--theme-color-5); /* Grey background */ 302 | outline: none; /* Remove outline */ 303 | opacity: 0.7; /* Set transparency (for mouse-over effects on hover) */ 304 | -webkit-transition: .2s; /* 0.2 seconds transition on hover */ 305 | transition: opacity .2s; 306 | 307 | position: relative; 308 | } 309 | #sliderTrack:hover { 310 | opacity: 1; /* Fully shown on mouse-over */ 311 | } 312 | #sliderRange { 313 | height: 100%; 314 | background: var(--theme-color-2); 315 | position: absolute; 316 | cursor: move; 317 | } 318 | #sliderKnobStart, 319 | #sliderKnobEnd { 320 | -webkit-appearance: none; /* Override default look */ 321 | appearance: none; 322 | width: 1.5rem; /* Set a specific slider handle width */ 323 | height: 1.5rem; 324 | border-radius: 50%; 325 | border: 1px solid var(--theme-color-5); 326 | background: var(--theme-color-2); 327 | cursor: pointer; 328 | 329 | position: absolute; 330 | top: -0.375rem; 331 | } 332 | #sliderKnobEnd { 333 | background: var(--body-background-color); 334 | } 335 | -------------------------------------------------------------------------------- /data/web.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | wbec 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | 38 |

wbec

39 |
40 | §14a EnWG
Reduzierung
41 |

Heidelberg Wallbox Energy Control

42 | Offline /
Standby
43 |
44 |
45 |
46 | 47 |
48 |
49 |
50 | 51 | 52 | 53 |
54 |
55 |
56 | 57 |
58 |
59 |

60 |  Ladestrom 61 |

62 |
63 | 64 |
65 | 0A 66 | 67 | 16A 68 |
69 |
70 |
71 |
72 | 73 |
74 |
75 |

76 |  PV Laden 77 |

78 |
79 | 82 | 85 | 88 |
89 |
90 |
91 | 92 |
93 | 94 |
95 |

96 |  Verbunden 97 |

98 |
99 | - 100 |
101 |
102 | 103 |
104 |

105 |  Laden erlaubt 106 |

107 |
108 | - 109 |
110 |
111 | 112 |
113 |

114 |  Energiezähler 115 |

116 |
117 | - kWh 118 |
119 |
120 | 121 |
122 |

123 |  Ladevorgang 124 |

125 |
126 | - kWh 127 |
128 |
129 | 130 |
131 |

132 |  Ladeleistung 133 |

134 |
135 | - kW 136 |
137 |
138 | 139 |
140 |

141 |  Bezug(+) / Einsp.(-) 142 |

143 |
144 | - kW 145 |
146 |
147 | 148 |
149 | 150 |
151 |
-
152 |
153 |
154 | 155 | 156 | 157 | -------------------------------------------------------------------------------- /data/web.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 steff393, MIT license 2 | 3 | // ----------------------------- COMMON SECTION: START ------------------------------ 4 | function initNavBar() { 5 | for (const element of document.querySelectorAll('[top-nav-link]')) { 6 | element.addEventListener('click', function() {window.location.href = element.getAttribute('top-nav-link')}); 7 | } 8 | } 9 | 10 | function assignValuesToHtml(values) { 11 | let valueContainerElements = document.querySelectorAll('[data-value]'); 12 | for (const element of valueContainerElements) { 13 | const key = element.getAttribute('data-value'); 14 | if (values[key] !== undefined) { 15 | element.innerHTML = values[key].toLocaleString('de-DE'); 16 | } 17 | } 18 | } 19 | 20 | function setClass(element, className, state) { 21 | if (state) { 22 | element.classList.add(className) 23 | } else { 24 | element.classList.remove(className) 25 | } 26 | } 27 | 28 | function setSectionVisibility(sectionId, isVisible) { 29 | setClass(document.getElementById(sectionId), 'not-available', !isVisible); 30 | } 31 | // ----------------------------- COMMON SECTION: END ------------------------------ 32 | 33 | window.addEventListener('DOMContentLoaded', () => { 34 | // Adjustable values ----- 35 | let phases = 3; // number of phases 36 | // ----------------------- 37 | let Socket; 38 | let elementCurrentSlider = document.getElementById('slideCurr'); 39 | let pvModeButtons = document.querySelectorAll('[data-pv-mode]'); 40 | let wallboxButtons = document.querySelectorAll('[data-wallbox-id]'); 41 | let valueContainerElements = document.querySelectorAll('[data-value]'); 42 | 43 | let sliderSliding = false; 44 | 45 | function init() { 46 | setSectionVisibility('enwg14a', false); 47 | setSectionVisibility('connection', false); 48 | setSectionVisibility('boxSelection', wallboxButtons.length > 1); 49 | setSectionVisibility('pvLaden', false); 50 | initNavBar(); 51 | document.getElementById('btnExit').addEventListener('click', exit); 52 | 53 | Socket = new WebSocket(`ws://${window.location.hostname}:81/`); 54 | Socket.onmessage = processReceivedCommand; 55 | 56 | // Update the current slider value (each time you drag the slider handle) 57 | elementCurrentSlider.addEventListener('input', onStartSliderSliding); 58 | elementCurrentSlider.addEventListener('change', onSliderReleased); 59 | 60 | for (const element of document.querySelectorAll('[data-send-command]')) { 61 | element.addEventListener('click', () => { 62 | sendText(element.getAttribute('data-send-command')); 63 | }) 64 | } 65 | } 66 | 67 | function onStartSliderSliding() { 68 | sliderSliding = true; 69 | 70 | let val = parseInt(elementCurrentSlider.value); 71 | if ((val >= 0) && (val <= 30)) { 72 | elementCurrentSlider.value = val = 0; 73 | } 74 | if ((val > 30) && (val <= 60)) { 75 | elementCurrentSlider.value = val = 60; 76 | } 77 | 78 | assignValuesToHtml({ 79 | currLim: val / 10, 80 | }) 81 | } 82 | 83 | function onSliderReleased() { 84 | let val = parseInt(elementCurrentSlider.value); 85 | if ((val >= 0) && (val <= 30)) { 86 | elementCurrentSlider.value = val = 0; 87 | } 88 | if ((val > 30) && (val <= 60)) { 89 | elementCurrentSlider.value = val = 60; 90 | } 91 | 92 | 93 | sendText(`currLim=${val}`); 94 | sliderSliding = false; 95 | } 96 | 97 | function setClass(element, className, state) { 98 | if (state) { 99 | element.classList.add(className) 100 | } else { 101 | element.classList.remove(className) 102 | } 103 | } 104 | 105 | function processReceivedCommand(evt) { 106 | const message = JSON.parse(evt.data); 107 | let carStat; 108 | let wbStat; 109 | switch (message.chgStat) { 110 | case 2: /*A1*/ carStat = 'nein'; wbStat = 'nein'; break; 111 | case 3: /*A2*/ carStat = 'nein'; wbStat = 'ja'; break; 112 | case 4: /*B1*/ carStat = 'ja, ohne Ladeanf.'; wbStat = 'nein'; break; 113 | case 5: /*B2*/ carStat = 'ja, ohne Ladeanf.'; wbStat = 'ja'; break; 114 | case 6: /*C1*/ carStat = 'ja, mit Ladeanf.'; wbStat = 'nein'; break; 115 | case 7: /*C2*/ carStat = 'ja, mit Ladeanf.'; wbStat = 'ja'; break; 116 | default: carStat = message.chgStat; wbStat = '-'; 117 | } 118 | assignValuesToHtml({ 119 | carStat: carStat, 120 | wbStat: wbStat, 121 | power: message.power / 1000, 122 | energyI: message.energyI, 123 | energyC: message.energyC, 124 | watt: message.watt / 1000, 125 | enwgErr: message.enwgErr ? "Fehler in der Funktion §14a EnWG.
Die Anlage ist nicht mehr EnWG-konform (" + message.enwgErr + "). " : "", 126 | timeNow: message.timeNow, 127 | }) 128 | 129 | if (!sliderSliding) { 130 | assignValuesToHtml({ 131 | currLim: message.currLim, 132 | }); 133 | elementCurrentSlider.value = message.currLim * 10; 134 | } 135 | 136 | for (const element of pvModeButtons) { 137 | setClass(element, 'active', message.pvMode === parseInt(element.getAttribute('data-pv-mode'))); 138 | } 139 | setSectionVisibility('enwg14a', message.enwg14a == 1); 140 | setSectionVisibility('connection', message.failCnt >= 10); 141 | setSectionVisibility('pvLaden', message.pvMode >= 1 && message.pvMode <= 3); 142 | 143 | for (const element of wallboxButtons) { 144 | setClass(element, 'active', message.id === parseInt(element.getAttribute('data-wallbox-id'))); 145 | } 146 | } 147 | 148 | function exit() { 149 | assignValuesToHtml({ 150 | carStat: '-', 151 | wbStat: '-', 152 | power: '-', 153 | energyI: '-', 154 | energyC: '-', 155 | currLim: '-', 156 | watt: '-', 157 | timeNow: '-', 158 | }) 159 | for (const element of document.querySelectorAll('[data-wallbox-id],[data-pv-mode]')) { 160 | setClass(element, 'active', false); 161 | setClass(element, 'disabled', true); 162 | } 163 | Socket.close(); 164 | } 165 | 166 | function sendText(data){ 167 | Socket.send(data); 168 | } 169 | 170 | init(); 171 | 172 | }); 173 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/WiFiManager-asyncwebserver/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 tzapu 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 | 23 | -------------------------------------------------------------------------------- /lib/WiFiManager-asyncwebserver/WiFiManager.h: -------------------------------------------------------------------------------- 1 | /************************************************************** 2 | WiFiManager is a library for the ESP8266/Arduino platform 3 | (https://github.com/esp8266/Arduino) to enable easy 4 | configuration and reconfiguration of WiFi credentials using a Captive Portal 5 | inspired by: 6 | http://www.esp8266.com/viewtopic.php?f=29&t=2520 7 | https://github.com/chriscook8/esp-arduino-apboot 8 | https://github.com/esp8266/Arduino/tree/master/libraries/DNSServer/examples/CaptivePortalAdvanced 9 | Built by AlexT https://github.com/tzapu 10 | Licensed under MIT license 11 | **************************************************************/ 12 | 13 | #ifndef WiFiManager_h 14 | #define WiFiManager_h 15 | 16 | #include 17 | 18 | #ifdef WIFI_MANAGER_USE_ASYNC_WEB_SERVER 19 | #include 20 | typedef AsyncWebServer WifiManagerWebServerType; 21 | typedef AsyncWebServerRequest WifiManagerWebServerRequestType; 22 | #else 23 | #include 24 | typedef ESP8266WebServer WifiManagerWebServerType; 25 | typedef ESP8266WebServer WifiManagerWebServerRequestType; 26 | #endif 27 | 28 | #include 29 | #include 30 | 31 | extern "C" { 32 | #include "user_interface.h" 33 | } 34 | 35 | const char WIFI_MANAGER_HTTP_HEADER[] PROGMEM = "{v}"; 36 | const char WIFI_MANAGER_HTTP_STYLE[] PROGMEM = ""; 37 | const char WIFI_MANAGER_HTTP_SCRIPT[] PROGMEM = ""; 38 | const char WIFI_MANAGER_HTTP_HEADER_END[] PROGMEM = "
"; 39 | const char WIFI_MANAGER_HTTP_PORTAL_OPTIONS[] PROGMEM = "



"; 40 | const char WIFI_MANAGER_HTTP_ITEM[] PROGMEM = "
{v} {r}%
"; 41 | const char WIFI_MANAGER_HTTP_FORM_START[] PROGMEM = "


"; 42 | const char WIFI_MANAGER_HTTP_FORM_PARAM[] PROGMEM = "
"; 43 | const char WIFI_MANAGER_HTTP_FORM_END[] PROGMEM = "
"; 44 | const char WIFI_MANAGER_HTTP_SCAN_LINK[] PROGMEM = "
"; 45 | const char WIFI_MANAGER_HTTP_SAVED[] PROGMEM = "
Credentials Saved
Trying to connect ESP to network.
If it fails reconnect to AP to try again
"; 46 | const char WIFI_MANAGER_HTTP_END[] PROGMEM = "
"; 47 | 48 | #ifndef WIFI_MANAGER_MAX_PARAMS 49 | #define WIFI_MANAGER_MAX_PARAMS 10 50 | #endif 51 | 52 | class WiFiManagerParameter { 53 | public: 54 | /** 55 | Create custom parameters that can be added to the WiFiManager setup web page 56 | @id is used for HTTP queries and must not contain spaces nor other special characters 57 | */ 58 | WiFiManagerParameter(const char *custom); 59 | WiFiManagerParameter(const char *id, const char *placeholder, const char *defaultValue, int length); 60 | WiFiManagerParameter(const char *id, const char *placeholder, const char *defaultValue, int length, const char *custom); 61 | ~WiFiManagerParameter(); 62 | 63 | const char *getID(); 64 | const char *getValue(); 65 | const char *getPlaceholder(); 66 | int getValueLength(); 67 | const char *getCustomHTML(); 68 | private: 69 | const char *_id; 70 | const char *_placeholder; 71 | char *_value; 72 | int _length; 73 | const char *_customHTML; 74 | 75 | void init(const char *id, const char *placeholder, const char *defaultValue, int length, const char *custom); 76 | 77 | friend class WiFiManager; 78 | }; 79 | 80 | 81 | class WiFiManager 82 | { 83 | public: 84 | WiFiManager(); 85 | ~WiFiManager(); 86 | 87 | boolean autoConnect(); 88 | boolean autoConnect(char const *apName, char const *apPassword = NULL); 89 | 90 | //if you want to always start the config portal, without trying to connect first 91 | boolean startConfigPortal(); 92 | boolean startConfigPortal(char const *apName, char const *apPassword = NULL); 93 | 94 | // get the AP name of the config portal, so it can be used in the callback 95 | String getConfigPortalSSID(); 96 | 97 | void resetSettings(); 98 | 99 | //sets timeout before webserver loop ends and exits even if there has been no setup. 100 | //useful for devices that failed to connect at some point and got stuck in a webserver loop 101 | //in seconds setConfigPortalTimeout is a new name for setTimeout 102 | void setConfigPortalTimeout(unsigned long seconds); 103 | void setTimeout(unsigned long seconds); 104 | 105 | //sets timeout for which to attempt connecting, useful if you get a lot of failed connects 106 | void setConnectTimeout(unsigned long seconds); 107 | 108 | 109 | void setDebugOutput(boolean debug); 110 | //defaults to not showing anything under 8% signal quality if called 111 | void setMinimumSignalQuality(int quality = 8); 112 | //sets a custom ip /gateway /subnet configuration 113 | void setAPStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn); 114 | //sets config for a static IP 115 | void setSTAStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn); 116 | //called when AP mode and config portal is started 117 | void setAPCallback( void (*func)(WiFiManager*) ); 118 | //called when settings have been changed and connection was successful 119 | void setSaveConfigCallback( void (*func)(void) ); 120 | //adds a custom parameter, returns false on failure 121 | bool addParameter(WiFiManagerParameter *p); 122 | //if this is set, it will exit after config, even if connection is unsuccessful. 123 | void setBreakAfterConfig(boolean shouldBreak); 124 | //if this is set, try WPS setup when starting (this will delay config portal for up to 2 mins) 125 | //TODO 126 | //if this is set, customise style 127 | void setCustomHeadElement(const char* element); 128 | //if this is true, remove duplicated Access Points - defaut true 129 | void setRemoveDuplicateAPs(boolean removeDuplicates); 130 | 131 | private: 132 | std::unique_ptr dnsServer; 133 | std::unique_ptr server; 134 | 135 | //const int WM_DONE = 0; 136 | //const int WM_WAIT = 10; 137 | 138 | //const String WIFI_MANAGER_HTTP_HEADER = "{v}"; 139 | 140 | void setupConfigPortal(); 141 | void startWPS(); 142 | 143 | const char* _apName = "no-net"; 144 | const char* _apPassword = NULL; 145 | String _ssid = ""; 146 | String _pass = ""; 147 | unsigned long _configPortalTimeout = 0; 148 | unsigned long _connectTimeout = 0; 149 | unsigned long _configPortalStart = 0; 150 | 151 | IPAddress _ap_static_ip; 152 | IPAddress _ap_static_gw; 153 | IPAddress _ap_static_sn; 154 | IPAddress _sta_static_ip; 155 | IPAddress _sta_static_gw; 156 | IPAddress _sta_static_sn; 157 | 158 | int _paramsCount = 0; 159 | int _minimumQuality = -1; 160 | boolean _removeDuplicateAPs = true; 161 | boolean _shouldBreakAfterConfig = false; 162 | boolean _tryWPS = false; 163 | 164 | const char* _customHeadElement = ""; 165 | 166 | //String getEEPROMString(int start, int len); 167 | //void setEEPROMString(int start, int len, String string); 168 | 169 | int status = WL_IDLE_STATUS; 170 | int connectWifi(String ssid, String pass); 171 | uint8_t waitForConnectResult(); 172 | 173 | void handleRoot(WifiManagerWebServerRequestType *request); 174 | void handleWifi(WifiManagerWebServerRequestType *request, boolean scan); 175 | void handleWifiSave(WifiManagerWebServerRequestType *request); 176 | void handleInfo(WifiManagerWebServerRequestType *request); 177 | void handleReset(WifiManagerWebServerRequestType *request); 178 | void handleNotFound(WifiManagerWebServerRequestType *request); 179 | void handle204(WifiManagerWebServerRequestType *request); 180 | boolean captivePortal(WifiManagerWebServerRequestType *request); 181 | boolean configPortalHasTimeout(); 182 | 183 | // DNS server 184 | const byte DNS_PORT = 53; 185 | 186 | //helpers 187 | int getRSSIasQuality(int RSSI); 188 | boolean isIp(String str); 189 | String toStringIp(IPAddress ip); 190 | 191 | boolean connect; 192 | boolean _debug = true; 193 | 194 | void (*_apcallback)(WiFiManager*) = NULL; 195 | void (*_savecallback)(void) = NULL; 196 | 197 | int _max_params; 198 | WiFiManagerParameter** _params; 199 | 200 | template 201 | void DEBUG_WM(Generic text); 202 | 203 | template 204 | auto optionalIPFromString(T *obj, const char *s) -> decltype( obj->fromString(s) ) { 205 | return obj->fromString(s); 206 | } 207 | auto optionalIPFromString(...) -> bool { 208 | DEBUG_WM("NO fromString METHOD ON IPAddress, you need ESP8266 core 2.1.0 or newer for Custom IP configuration to work."); 209 | return false; 210 | } 211 | }; 212 | 213 | #endif 214 | -------------------------------------------------------------------------------- /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:nodemcuv2] 12 | platform = espressif8266@4.0.1 13 | board = nodemcuv2 14 | framework = arduino 15 | board_build.filesystem = littlefs 16 | build_flags = 17 | -DWEBSOCKETS_SERVER_CLIENT_MAX=2 ; default was 5, but every additional client needs ca. 208 byte on heap 18 | -D PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED 19 | -DWBEC_VERSION_MAJOR=0 20 | lib_deps = 21 | emelianov/modbus-esp8266@^4.1.0 22 | me-no-dev/ESP Async WebServer@^1.2.3 23 | bblanchon/ArduinoJson@^6.19.4 24 | knolleary/PubSubClient @ ^2.8 25 | arduino-libraries/NTPClient @ ^3.2.1 26 | miguelbalboa/MFRC522 @ ^1.4.10 27 | highno/RTCVars @ ^0.1.1 28 | links2004/WebSockets @ ^2.3.7 29 | ayushsharma82/AsyncElegantOTA @ ^2.2.7 30 | thomasfredericks/Bounce2 @ ^2.70 31 | monitor_speed = 115200 32 | ;lib_ldf_mode = deep+ ; was needed for ESP Async WebServer to avoid missing ESP8266Wifi.h (which is in fact not missing) 33 | upload_speed = 921600 34 | -------------------------------------------------------------------------------- /src/button.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | #define POWER_LOSS_DELAY 3000 // ms 12 | #define BOXID 0 // only 1 box supported 13 | 14 | 15 | static Bounce2::Button btn_PV_SWITCH = Bounce2::Button(); 16 | static uint32_t powerLossTimer = 0; 17 | 18 | void btn_setup() { 19 | if (rfid_getEnabled()) { 20 | ; // if RFID is enabled, then it's not possible to use the GPIOs for other purposes 21 | } else { 22 | // if RFID is not active, then PV_SWITCH can be used to change the PV mode 23 | if (cfgBtnDebounce > 0) { 24 | btn_PV_SWITCH.attach(PIN_PV_SWITCH, INPUT_PULLUP); // USE INTERNAL PULL-UP 25 | btn_PV_SWITCH.interval(cfgBtnDebounce); 26 | btn_PV_SWITCH.setPressedState(LOW); 27 | btn_PV_SWITCH.update(); 28 | if (btn_PV_SWITCH.isPressed()) { 29 | pv_setMode(PV_ACTIVE); 30 | } else { 31 | powerLossTimer = millis(); // if Power loss: Wallbox is booting ~2s slower than WBEC: "lm_storeRequest" would be lost 32 | } 33 | } 34 | // and RST can be used as an output 35 | pinMode(PIN_RST, OUTPUT); 36 | } 37 | 38 | } 39 | 40 | 41 | void btn_loop() { 42 | if (cfgBtnDebounce > 0) { 43 | btn_PV_SWITCH.update(); 44 | if (btn_PV_SWITCH.fell()) { 45 | pv_setMode(PV_ACTIVE); 46 | } 47 | if (btn_PV_SWITCH.rose() || 48 | ((powerLossTimer > 0) && (millis() - powerLossTimer > POWER_LOSS_DELAY))) { 49 | pv_setMode(PV_OFF); 50 | lm_storeRequest(BOXID, CURR_ABS_MAX); 51 | powerLossTimer = 0; // reset the timer, when it was once used 52 | } 53 | } 54 | } 55 | 56 | 57 | boolean btn_getState() { 58 | return(btn_PV_SWITCH.isPressed()); 59 | } 60 | -------------------------------------------------------------------------------- /src/button.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #ifndef BUTTON_H 4 | #define BUTTON_H 5 | 6 | extern void btn_setup(); 7 | extern void btn_loop(); 8 | extern boolean btn_getState(); 9 | 10 | 11 | #endif /* BUTTON_H */ 12 | -------------------------------------------------------------------------------- /src/globalConfig.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 steff393, MIT license 2 | // based on https://github.com/esp8266/Arduino/blob/master/libraries/esp8266/examples/ConfigFile/ConfigFile.ino 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | const uint8_t m = 5; 10 | 11 | #define WBEC_VER(s) "v" MAJOR_VER_STRING(s) ".5.4" // token stringification 12 | #define MAJOR_VER_STRING(s) #s // .. with two levels of macros 13 | 14 | char cfgWbecVersion[] = WBEC_VER(WBEC_VERSION_MAJOR); // wbec version 15 | char cfgBuildDate[] = __DATE__ " " __TIME__; // wbec build date 16 | 17 | char cfgApSsid[32]; // SSID of the initial Access Point 18 | char cfgApPass[63]; // Password of the initial Access Point 19 | uint8_t cfgCntWb; // number of connected wallboxes in the system 20 | uint8_t cfgMbCycleTime; // cycle time of the modbus (in seconds) 21 | uint16_t cfgMbDelay; // delay time of the modbus before sending new message (in milliseconds) 22 | uint16_t cfgMbTimeout; // Reg. 257: Modbus timeout (in milliseconds) 23 | uint16_t cfgStandby; // Reg. 258: Standby Function Control: 0 = enable standby, 4 = disable standby 24 | uint16_t cfgFailsafeCurrent; // Reg. 262: Failsafe Current configuration in case of loss of Modbus communication (in 0.1A) 25 | char cfgMqttIp[16]; // IP address of MQTT broker, "" to disable MQTT 26 | uint16_t cfgMqttPort; // Port of MQTT broker (optional) 27 | char cfgMqttUser[32]; // MQTT: Username 28 | char cfgMqttPass[128]; // MQTT: Password 29 | uint8_t cfgMqttLp[WB_CNT]; // Array with assignments to openWB loadpoints, e.g. [4,2,0,1]: Box0 = LP4, Box1 = LP2, Box2 = no MQTT, Box3 = LP1 30 | char cfgMqttWattTopic[60]; // MQTT: Topic for setting the watt value for PV charging, default: "wbec/pv/setWatt" 31 | char cfgMqttWattJson[30]; // MQTT: Optional: Element in a JSON string, which contains the power in watt, default: "" 32 | uint8_t cfgMqttClientId; // MQTT: Client-ID, default: 0 = random 33 | char cfgNtpServer[30]; // NTP server 34 | char cfgFoxUser[32]; // powerfox: Username 35 | char cfgFoxPass[16]; // powerfox: Password 36 | char cfgFoxDevId[16]; // powerfox: DeviceId 37 | uint8_t cfgPvActive; // PV charging: Active (1) or inactive (0) 38 | uint8_t cfgPvCycleTime; // PV charging: cycle time (in seconds) 39 | uint8_t cfgPvLimStart; // PV charging: Target current needed for starting (in 0.1A), e.g. 61=6.1A 40 | uint8_t cfgPvLimStop; // PV charging: Target current to stop charging when below (in 0.1A) 41 | uint8_t cfgPvPhFactor; // PV charging: Power/Current factor, e.g. 69: 1A equals 690W at 3phases, 23: 1A equals 230W at 1phase 42 | uint16_t cfgPvOffset; // PV charging: Offset for the available power calculation (in W); can be used to assure that no/less current is consumed from net 43 | uint8_t cfgPvInvert; // PV charging: Invert the watt value (pos./neg.) 44 | uint8_t cfgPvMinTime; // PV charging: Minimum activation time (in minutes), 0 to disable 45 | uint8_t cfgPvOffCurrent; // PV charging: Current value which will be set, when mode changes to OFF (255 to disable) 46 | char cfgPvHttpIp[16]; // IP for generic HTTP call, "" to disable 47 | char cfgPvHttpPath[64]; // Path for generic http call, default: "/", example: /cm?cmd=status%2010 48 | char cfgPvHttpJson[30]; // Element in a JSON string, which contains the power in watt, default: "", example: ",\"power_curr\":" 49 | char cfgPvHttpJsonBatt[30]; // Element in a JSON string, which contains the power in watt, default: "", example: ",\"power_curr\":" 50 | uint16_t cfgPvHttpPort; // Port for generic http call, default: 80 51 | uint16_t cfgTotalCurrMax; // Total current limit for load management (in 0.1A) - !! Additional fuse mandatory !! 52 | uint8_t cfgHwVersion; // Selection of the used HW 53 | uint8_t cfgWifiSleepMode; // Set sleep type for power saving, recomendation is 255 (=no influence) or 0 (=WIFI_NONE_SLEEP) 54 | uint8_t cfgLoopDelay; // Delay [ms] at end of main loop, might have an impact on web server reactivitiy, default: 255 = inactive 55 | uint16_t cfgKnockOutTimer; // Interval[min] after which wbec knocks itself out, i.e. triggers a reset, default: 0 = inactive; values < 20min not allowed 56 | char cfgShellyIp[16]; // IP address of Shelly 3em, "" to disable 57 | char cfgInverterIp[16]; // IP address of Inverter, "" to disable 58 | uint8_t cfgInverterType; // 0=off, 1=SolarEdge, 2=Fronius, 3=Kostal 59 | uint16_t cfgInverterPort; // Overwrite default inverter port setting 60 | uint16_t cfgInverterAddr; // Overwrite default inverter address setting 61 | uint16_t cfgInvSmartAddr; // Overwrite default smart meter address setting 62 | uint16_t cfgBootlogSize; // Size of the bootlog buffer for debugging, max. 5000 [bytes] 63 | uint16_t cfgBtnDebounce; // Debounce time for button [ms] 64 | uint16_t cfgWifiConnectTimeout; // Timeout in seconds to connect to Wifi before change to AP-Mode 65 | uint8_t cfgResetOnTimeout; // Set (some) Modbus values to 0 after 10x message timeout 66 | 67 | 68 | static bool createConfig() { 69 | StaticJsonDocument<128> doc; 70 | 71 | // default configuration parameters 72 | doc["cfgApPass"] = F("wbec1234"); // older version had "cebw1234" 73 | doc["cfgCntWb"] = 1; 74 | 75 | File configFile = LittleFS.open(F("/cfg.json"), "w"); 76 | if (!configFile) { 77 | return(false); 78 | } 79 | 80 | serializeJson(doc, configFile); 81 | configFile.close(); 82 | return (true); 83 | } 84 | 85 | 86 | static boolean checkConfig(JsonDocument& doc) { 87 | File configFile = LittleFS.open(F("/cfg.json"), "r"); 88 | if (!configFile) { 89 | LOG(m, "Failed to open config file... Creating default config...","") 90 | if (createConfig()) { 91 | LOG(0, "Successful!", ""); 92 | configFile = LittleFS.open(F("/cfg.json"), "r"); 93 | } else { 94 | LOG(m, "Failed to create default config... Please try to erase flash",""); 95 | return(false); 96 | } 97 | } 98 | 99 | size_t size = configFile.size(); 100 | if (size > 2048) { 101 | LOG(m, "Config file size is too large",""); 102 | return(false); 103 | } 104 | 105 | // Allocate a buffer to store contents of the file. 106 | std::unique_ptr buf(new char[size]); 107 | 108 | // We don't use String here because ArduinoJson library requires the input 109 | // buffer to be mutable. If you don't use ArduinoJson, you may as well 110 | // use configFile.readString instead. 111 | configFile.readBytes(buf.get(), size); 112 | 113 | auto error = deserializeJson(doc, buf.get()); 114 | if (error) { 115 | LOG(m, "Failed to parse config file: %s", error.c_str()); 116 | return(false); 117 | } 118 | configFile.close(); 119 | 120 | //configFile = LittleFS.open("/cfg.json", "r"); 121 | //log(m, configFile.readString()); 122 | //configFile.close(); 123 | return(true); 124 | } 125 | 126 | 127 | void loadConfig() { 128 | StaticJsonDocument<2048> doc; 129 | if (!checkConfig(doc)) { 130 | LOG(m, "Using default config", ""); 131 | deserializeJson(doc, F("{}")); 132 | } 133 | 134 | strncpy(cfgApSsid, doc["cfgApSsid"] | "wbec", sizeof(cfgApSsid)); 135 | strncpy(cfgApPass, doc["cfgApPass"] | "wbec1234", sizeof(cfgApPass)); 136 | cfgCntWb = doc["cfgCntWb"] | 1; 137 | cfgMbCycleTime = doc["cfgMbCycleTime"] | 10; 138 | cfgMbDelay = doc["cfgMbDelay"] | 100UL; 139 | cfgMbTimeout = doc["cfgMbTimeout"] | 60000UL; 140 | cfgStandby = doc["cfgStandby"] | 4UL; 141 | cfgFailsafeCurrent = doc["cfgFailsafeCurrent"] | 0UL; 142 | strncpy(cfgMqttIp, doc["cfgMqttIp"] | "", sizeof(cfgMqttIp)); 143 | cfgMqttPort = doc["cfgMqttPort"] | 1883UL; 144 | strncpy(cfgMqttUser, doc["cfgMqttUser"] | "", sizeof(cfgMqttUser)); 145 | strncpy(cfgMqttPass, doc["cfgMqttPass"] | "", sizeof(cfgMqttPass)); 146 | strncpy(cfgMqttWattTopic, doc["cfgMqttWattTopic"] | "wbec/pv/setWatt", sizeof(cfgMqttWattTopic)); 147 | strncpy(cfgMqttWattJson, doc["cfgMqttWattJson"] | "", sizeof(cfgMqttWattJson)); 148 | cfgMqttClientId = doc["cfgMqttClientId"] | 0; 149 | strncpy(cfgNtpServer, doc["cfgNtpServer"] | "europe.pool.ntp.org", sizeof(cfgNtpServer)); 150 | strncpy(cfgFoxUser, doc["cfgFoxUser"] | "", sizeof(cfgFoxUser)); 151 | strncpy(cfgFoxPass, doc["cfgFoxPass"] | "", sizeof(cfgFoxPass)); 152 | strncpy(cfgFoxDevId, doc["cfgFoxDevId"] | "", sizeof(cfgFoxDevId)); 153 | cfgPvActive = doc["cfgPvActive"] | 0; 154 | cfgPvCycleTime = doc["cfgPvCycleTime"] | 30; 155 | cfgPvLimStart = doc["cfgPvLimStart"] | 61; 156 | cfgPvLimStop = doc["cfgPvLimStop"] | 50; 157 | cfgPvPhFactor = doc["cfgPvPhFactor"] | 69; 158 | cfgPvOffset = doc["cfgPvOffset"] | 0UL; 159 | cfgPvInvert = doc["cfgPvInvert"] | 0L; 160 | cfgPvMinTime = doc["cfgPvMinTime"] | 0L; 161 | cfgPvOffCurrent = doc["cfgPvOffCurrent"] | 255; 162 | strncpy(cfgPvHttpIp, doc["cfgPvHttpIp"] | "", sizeof(cfgPvHttpIp)); 163 | strncpy(cfgPvHttpPath, doc["cfgPvHttpPath"] | "/", sizeof(cfgPvHttpPath)); 164 | strncpy(cfgPvHttpJson, doc["cfgPvHttpJson"] | "", sizeof(cfgPvHttpJson)); 165 | strncpy(cfgPvHttpJsonBatt, doc["cfgPvHttpJsonBatt"] | "", sizeof(cfgPvHttpJsonBatt)); 166 | cfgPvHttpPort = doc["cfgPvHttpPort"] | 80; 167 | cfgTotalCurrMax = doc["cfgTotalCurrMax"] | 0UL; 168 | cfgHwVersion = doc["cfgHwVersion"] | 15; 169 | cfgWifiSleepMode = doc["cfgWifiSleepMode"] | 0; 170 | cfgLoopDelay = doc["cfgLoopDelay"] | 255; 171 | cfgKnockOutTimer = doc["cfgKnockOutTimer"] | 0UL; 172 | strncpy(cfgShellyIp, doc["cfgShellyIp"] | "", sizeof(cfgShellyIp)); 173 | strncpy(cfgInverterIp, doc["cfgInverterIp"] | "", sizeof(cfgInverterIp)); 174 | cfgInverterType = doc["cfgInverterType"] | 0; 175 | cfgInverterPort = doc["cfgInverterPort"] | 0UL; 176 | cfgInverterAddr = doc["cfgInverterAddr"] | 0UL; 177 | cfgInvSmartAddr = doc["cfgInvSmartAddr"] | 0UL; 178 | cfgBootlogSize = doc["cfgBootlogSize"] | 2000; 179 | cfgBtnDebounce = doc["cfgBtnDebounce"] | 0; 180 | cfgWifiConnectTimeout = doc["cfgWifiConnectTimeout"] | 10; 181 | cfgResetOnTimeout = doc["cfgResetOnTimeout"] | 0; 182 | 183 | LOG(m, "cfgWbecVersion: %s", cfgWbecVersion); 184 | LOG(m, "cfgBuildDate: %s" , cfgBuildDate); 185 | LOG(m, "cfgCntWb: %d" , cfgCntWb); 186 | 187 | for (uint8_t i = 0; i < WB_CNT; i++) { 188 | if (i < doc["cfgMqttLp"].size()) { 189 | cfgMqttLp[i] = doc["cfgMqttLp"][i]; 190 | } else { 191 | cfgMqttLp[i] = 0; 192 | } 193 | if (cfgMqttLp[i] > OPENWB_MAX_LP) { 194 | cfgMqttLp[i] = 0; 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/globalConfig.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 steff393, MIT license 2 | 3 | #ifndef GLOBALCONFIG_H 4 | #define GLOBALCONFIG_H 5 | 6 | #define WB_CNT 16 // max. possible number of wallboxes in the system (NodeMCU has Bus-ID = 0) 7 | #define OPENWB_MAX_LP 8 // maximum supported loadpoints by openWB 8 | #define REG_WD_TIME_OUT 257 // modbus register for "ModBus-Master Watchdog Timeout in ms" 9 | #define REG_STANDBY_CTRL 258 // modbus register for "Standby Function Control" 10 | #define REG_REMOTE_LOCK 259 // modbus register for "Remote lock (only if extern lock unlocked)" 11 | #define REG_CURR_LIMIT 261 // modbus register for "Maximal current command" 12 | #define REG_CURR_LIMIT_FS 262 // modbus register for "FailSafe Current configuration (in case loss of Modbus communication)" 13 | 14 | #define CURR_ABS_MIN 60 // absolute possible lower limit for current 15 | #define CURR_ABS_MAX 160 // absolute possible upper limit for current 16 | 17 | #define PIN_DI 5 // GPIO5, NodeMCU pin D1 18 | #define PIN_RO 2 // GPIO2, NodeMCU pin D4 19 | #define PIN_DE_RE 4 // GPIO4, NodeMCU pin D2 20 | #define PIN_RST 0 // GPIO0, NodeMCU pin D3 21 | #define PIN_PV_SWITCH 13 // GPIO0, NodeMCU pin D7 22 | #define PIN_SS 15 // GPIO15,NodeMCU pin D8 23 | 24 | 25 | extern char cfgWbecVersion[]; // wbec version 26 | extern char cfgBuildDate[]; // wbec build date 27 | 28 | extern char cfgApSsid[32]; // SSID of the initial Access Point 29 | extern char cfgApPass[63]; // Password of the initial Access Point 30 | extern uint8_t cfgCntWb; // number of connected wallboxes in the system 31 | extern uint8_t cfgMbCycleTime; // cycle time of the modbus (in seconds) 32 | extern uint16_t cfgMbDelay; // delay time of the modbus before sending new message (in milliseconds) 33 | extern uint16_t cfgMbTimeout; // Reg. 257: Modbus timeout (in milliseconds) 34 | extern uint16_t cfgStandby; // Reg. 258: Standby Function Control: 0 = enable standby, 4 = disable standby 35 | extern uint16_t cfgFailsafeCurrent; // Reg. 262: Failsafe Current configuration in case of loss of Modbus communication (in 0.1A) 36 | extern char cfgMqttIp[16]; // IP address of MQTT broker, "" to disable MQTT 37 | extern uint16_t cfgMqttPort; // Port of MQTT broker (optional) 38 | extern char cfgMqttUser[32]; // MQTT: Username 39 | extern char cfgMqttPass[128]; // MQTT: Password 40 | extern uint8_t cfgMqttLp[WB_CNT]; // Array with assignments to openWB loadpoints, e.g. [4,2,0,1]: Box0 = LP4, Box1 = LP2, Box2 = no MQTT, Box3 = LP1 41 | extern char cfgMqttWattTopic[60]; // MQTT: Topic for setting the watt value for PV charging, default: "wbec/pv/setWatt" 42 | extern char cfgMqttWattJson[30]; // MQTT: Optional: Element in a JSON string, which contains the power in watt, default: "" 43 | extern uint8_t cfgMqttClientId; // MQTT: Client-ID, default: 0 = random 44 | extern char cfgNtpServer[30]; // NTP server 45 | extern char cfgFoxUser[32]; // powerfox: Username 46 | extern char cfgFoxPass[16]; // powerfox: Password 47 | extern char cfgFoxDevId[16]; // powerfox: DeviceId 48 | extern uint8_t cfgPvActive; // PV charging: Active (1) or inactive (0) 49 | extern uint8_t cfgPvCycleTime; // PV charging: cycle time (in seconds) 50 | extern uint8_t cfgPvLimStart; // PV charging: Target current needed for starting (in 0.1A), e.g. 61=6.1A 51 | extern uint8_t cfgPvLimStop; // PV charging: Target current to stop charging when below (in 0.1A) 52 | extern uint8_t cfgPvPhFactor; // PV charging: Power/Current factor, e.g. 69: 1A equals 690W at 3phases, 23: 1A equals 230W at 1phase 53 | extern uint16_t cfgPvOffset; // PV charging: Offset for the available power calculation (in W); can be used to assure that no/less current is consumed from net 54 | extern uint8_t cfgPvInvert; // PV charging: Invert the watt value (pos./neg.) 55 | extern uint8_t cfgPvMinTime; // PV charging: Minimum activation time (in minutes), 0 to disable 56 | extern uint8_t cfgPvOffCurrent; // PV charging: Current value which will be set, when mode changes to OFF (255 to disable) 57 | extern char cfgPvHttpIp[16]; // IP for generic HTTP call, "" to disable 58 | extern char cfgPvHttpPath[64]; // Path for generic http call, default: "/", example: /cm?cmd=status%2010 59 | extern char cfgPvHttpJson[30]; // Element in a JSON string, which contains the power in watt, default: "", example: ",\"power_curr\":" 60 | extern char cfgPvHttpJsonBatt[30]; // Element in a JSON string, which contains the power in watt, default: "", example: ",\"power_curr\":" 61 | extern uint16_t cfgPvHttpPort; // Port for generic http call, default: 80 62 | extern uint16_t cfgTotalCurrMax; // Total current limit for load management (in 0.1A) - !! Additional fuse mandatory !! 63 | extern uint8_t cfgHwVersion; // Selection of the used HW 64 | extern uint8_t cfgWifiSleepMode; // Set sleep type for power saving, recomendation is 255 (=no influence) or 0 (=WIFI_NONE_SLEEP) 65 | extern uint8_t cfgLoopDelay; // Delay [ms] at end of main loop, might have an impact on web server reactivitiy, default: 255 = inactive 66 | extern uint16_t cfgKnockOutTimer; // Interval[min] after which wbec knocks itself out, i.e. triggers a reset, default: 0 = inactive; values < 20min not allowed 67 | extern char cfgShellyIp[16]; // IP address of Shelly 3em, "" to disable 68 | extern char cfgInverterIp[16]; // IP address of Inverter, "" to disable 69 | extern uint8_t cfgInverterType; // 0=off, 1=SolarEdge, 2=Fronius, 3=Kostal 70 | extern uint16_t cfgInverterPort; // Overwrite default inverter port setting 71 | extern uint16_t cfgInverterAddr; // Overwrite default inverter address setting 72 | extern uint16_t cfgInvSmartAddr; // Overwrite default smart meter address setting 73 | extern uint16_t cfgBootlogSize; // Size of the bootlog buffer for debugging, e.g. 5000 bytes 74 | extern uint16_t cfgBtnDebounce; // Debounce time for button [ms] 75 | extern uint16_t cfgWifiConnectTimeout; // Timeout in seconds to connect to Wifi before change to AP-Mode 76 | extern uint8_t cfgResetOnTimeout; // Set (some) Modbus values to 0 after 10x message timeout 77 | 78 | 79 | extern void loadConfig(); 80 | 81 | #endif /* GLOBALCONFIG_H */ 82 | -------------------------------------------------------------------------------- /src/goEmulator.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #define CYCLE_TIME 1000 // 1s 11 | #define MAX_PLAUSIBLE_ENERGY 1000000ULL // no car should be able to load more than 1000kWh in one cycle 12 | 13 | const uint8_t m = 4; 14 | 15 | typedef struct goE_struct { 16 | uint32_t energyI = 0; 17 | uint16_t chgStat_old = 0; 18 | uint16_t dwo = 0; 19 | uint8_t amp = 0; // unit 0.1A (like reg. 261 of Energy Control) 20 | uint8_t alw = 0; 21 | } goE_t; 22 | 23 | goE_t box[WB_CNT]; 24 | uint32_t goE_lastCall = 0; 25 | 26 | 27 | 28 | boolean goE_plugged(uint16_t chgStat) { 29 | if (chgStat >= 4 && chgStat <= 7) { 30 | return(true); 31 | } else { 32 | return(false); 33 | } 34 | } 35 | 36 | 37 | void goE_handle() { 38 | if (millis() - goE_lastCall < CYCLE_TIME) { 39 | // avoid unnecessary frequent calls 40 | return; 41 | } 42 | goE_lastCall = millis(); 43 | 44 | for (uint8_t id = 0; id < cfgCntWb; id++) { 45 | if ((!goE_plugged(box[id].chgStat_old) && goE_plugged(content[id][1])) || (box[id].energyI == 0)) { 46 | // vehicle plugged --> store energy count (and also, when energyI == 0 because this indicates that there was no update after init) 47 | box[id].energyI = (uint32_t) content[id][13] << 16 | (uint32_t)content[id][14]; 48 | } 49 | box[id].chgStat_old = content[id][1]; 50 | 51 | // update alw & amp based on value from wallbox 52 | if (content[id][53] == 0) { 53 | box[id].alw = 0; 54 | } else { 55 | box[id].alw = 1; 56 | box[id].amp = content[id][53]; 57 | } 58 | 59 | // implement the auto-switch-off function 60 | if (box[id].dwo != 0) { 61 | if (box[id].dwo * 100 < (((uint32_t) content[id][13] << 16 | (uint32_t)content[id][14]) - box[id].energyI)) { 62 | // Defined energy for this load cycle was reached => stop loading 63 | box[id].alw = 0; 64 | box[id].dwo = 0; 65 | lm_storeRequest(id, 0); 66 | } 67 | } 68 | } 69 | } 70 | 71 | 72 | void goE_setPayload(String payload, uint8_t id) { 73 | String cmd; 74 | uint16_t val = 0; 75 | cmd = payload.substring(0,3); // first 4 chars, e.g. "amx=" 76 | val = payload.substring(4).toInt(); // everything after "=" 77 | if (cmd == F("alw")) { 78 | if (val == 1) { 79 | // charging allowed 80 | box[id].alw = 1; 81 | lm_storeRequest(id, box[id].amp); 82 | } 83 | if (val == 0) { 84 | // charging not allowed 85 | box[id].alw = 0; 86 | lm_storeRequest(id, 0); 87 | } 88 | } 89 | if (cmd == F("amp") || cmd == F("amx")) { 90 | // go-e has 1A resolution, wbec has 0.1A resulotion 91 | val = val * 10; 92 | // set current 93 | if (val >= CURR_ABS_MIN && val <= CURR_ABS_MAX) { // values are between 6..32A according to API, 0 is not allowed 94 | box[id].amp = val; 95 | lm_storeRequest(id, box[id].amp); 96 | } 97 | } 98 | if (cmd == F("dwo")) { 99 | if (val <= 0xFFFFu) { 100 | box[id].dwo = val; 101 | } 102 | } 103 | } 104 | 105 | String goE_getStatus(uint8_t id, boolean fromApp) { 106 | DynamicJsonDocument data(1024); 107 | 108 | if (fromApp) { 109 | data[F("oem")]=F("wbec"); 110 | data[F("typ")]=F("Heidelberg Energy Control"); 111 | data[F("box")]=String(id); 112 | } 113 | data["version"] = F("B"); 114 | switch(content[id][1]) { 115 | case 2: data[F("car")] = F("1"); data[F("err")] = F( "0"); break; 116 | case 3: data[F("car")] = F("1"); data[F("err")] = F( "0"); break; 117 | case 4: data[F("car")] = F("4"); data[F("err")] = F( "0"); break; 118 | case 5: data[F("car")] = F("4"); data[F("err")] = F( "0"); break; 119 | case 6: data[F("car")] = F("2"); data[F("err")] = F( "0"); break; 120 | case 7: data[F("car")] = F("2"); data[F("err")] = F( "0"); break; 121 | case 8: data[F("car")] = F("2"); data[F("err")] = F( "0"); break; 122 | case 9: data[F("car")] = F("2"); data[F("err")] = F("10"); break; // not sure, if 9 is really an error... 123 | case 10: data[F("car")] = F("1"); data[F("err")] = F( "0"); break; // e.g. when remote locked the status will be set to F = 10 -> not clear if a vehicle is connected?! 124 | default: data[F("car")] = F("1"); data[F("err")] = F("10"); break; 125 | } 126 | data[F("alw")] = String(box[id].alw); 127 | data[F("amp")] = String(box[id].amp / 10); 128 | data[F("amx")] = String(box[id].amp / 10); 129 | data[F("stp")] = F("0"); 130 | uint8_t pha = 0; 131 | if (content[id][6] > 200) { pha+=9; } // 0000 1001 132 | if (content[id][7] > 200) { pha+=18; } // 0001 0010 133 | if (content[id][8] > 200) { pha+=36; } // 0010 0100 134 | data[F("pha")] = String(pha); 135 | data[F("tmp")] = String(content[id][5] / 10); 136 | data[F("dws")] = String((((uint32_t) content[id][13] << 16 | (uint32_t)content[id][14]) - box[id].energyI) * 360); 137 | data[F("dwo")] = String(box[id].dwo); 138 | data[F("uby")] = F("0"); 139 | data[F("eto")] = String(((uint32_t) content[id][13] << 16 | (uint32_t)content[id][14]) / 100); 140 | data[F("nrg")][0] = content[id][6]; // L1 141 | data[F("nrg")][1] = content[id][7]; // L2 142 | data[F("nrg")][2] = content[id][8]; // L3 143 | data[F("nrg")][3] = 0; 144 | data[F("nrg")][4] = content[id][2]; // L1 145 | data[F("nrg")][5] = content[id][3]; // L2 146 | data[F("nrg")][6] = content[id][4]; // L3 147 | data[F("nrg")][7] = 0; 148 | data[F("nrg")][8] = 0; 149 | data[F("nrg")][9] = 0; 150 | data[F("nrg")][10] = 0; 151 | data[F("nrg")][11] = content[id][10] / 10; 152 | data[F("nrg")][12] = 0; 153 | data[F("nrg")][13] = 0; 154 | data[F("nrg")][14] = 0; 155 | data[F("nrg")][15] = 0; 156 | data[F("fwv")] = F("040"); 157 | char txt[7]; mb_getAscii(id, 27, 3, txt); 158 | data[F("sse")] = txt; 159 | data[F("ama")] = String(content[id][15]); 160 | data[F("ust")] = F("2"); 161 | data[F("ast")] = F("0"); 162 | 163 | String response; 164 | serializeJson(data, response); 165 | log(m, response); 166 | return(response); 167 | } 168 | 169 | 170 | uint32_t goE_getEnergySincePlugged(uint8_t id) { 171 | // substract the stored energy counter at plugging from the current energy counter 172 | uint32_t delta = ((uint32_t) content[id][13] << 16 | (uint32_t)content[id][14]) - box[id].energyI; 173 | if (delta > MAX_PLAUSIBLE_ENERGY) { 174 | return(0); // prevent underflow 175 | } else { 176 | return(delta); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/goEmulator.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393 2 | 3 | #ifndef GOEMULATOR_H 4 | #define GOEMULATOR_H 5 | 6 | extern void goE_handle(); 7 | extern void goE_setPayload(String payload, uint8_t id); 8 | extern String goE_getStatus(uint8_t id, boolean fromApp); 9 | extern uint32_t goE_getEnergySincePlugged(uint8_t id); 10 | 11 | #endif /* GOEMULATOR_H */ -------------------------------------------------------------------------------- /src/inverter.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 andreas.miketta, steff393, andy5macht, MIT license 2 | // based on https://github.com/AMiketta/wbec and https://github.com/andy5macht/wbec 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | 13 | static IPAddress remote; // Address of Modbus Slave device 14 | static ModbusIP mb; // Declare ModbusTCP instance 15 | 16 | static bool inverterActive = false; 17 | static bool isConnected = false; 18 | 19 | static uint16_t inverterPort = 0; 20 | static uint16_t inverterAddr = 0; 21 | static uint16_t smartmetAddr = 0; 22 | static uint16_t regAcCurrent = 0; 23 | static uint16_t regPowerInv = 0; 24 | static uint16_t regPowerInvS = 0; 25 | static uint16_t regPowerMet = 0; 26 | static uint16_t regPowerMetS = 0; 27 | 28 | static int16_t power_inverter = 0; 29 | static int16_t power_inverter_scale = 0; 30 | static int16_t power_meter = 0; // power (pos. = 'Einspeisung', neg. = 'Bezug') 31 | static int16_t power_meter_scale = 0; 32 | static uint16_t ac_current = 0; 33 | static uint16_t power_house = 0; 34 | static uint32_t lastHandleCall = 0; 35 | 36 | static int16_t pwrInv = 0; 37 | static int16_t pwrMet = 0; 38 | 39 | 40 | static bool cb(Modbus::ResultCode event, uint16_t transactionId, void *data) { 41 | if (event != Modbus::EX_SUCCESS) { 42 | Serial.printf("Modbus result: %02X\n", event); 43 | } 44 | #ifdef DEBUG_INVERTER 45 | if (event == Modbus::EX_TIMEOUT) { 46 | Serial.println("Timeout"); 47 | } 48 | #endif 49 | return true; 50 | } 51 | 52 | 53 | static int16_t pow_int16(int16_t base, uint16_t exp) { 54 | int16_t x = 1; 55 | for (uint16_t i = 0; i < exp; i++) { 56 | x = x * base; 57 | } 58 | return(x); 59 | } 60 | 61 | 62 | void inverter_setup() { 63 | if (strcmp(cfgInverterIp, "") != 0) { 64 | if (remote.fromString(cfgInverterIp)) { 65 | mb.client(); // Act as Modbus TCP server 66 | inverterActive = true; 67 | 68 | switch(cfgInverterType) { // select the port and addresses based on the different inverter types 69 | case 1: // SolarEdge (sunspec) registers for modbusTCP 70 | inverterPort = 1502; 71 | inverterAddr = 1; 72 | smartmetAddr = 1; 73 | regAcCurrent = 40071; // 40072 1 I_AC_Current uint16 Amps AC Total Current value 74 | regPowerInv = 40083; // modbus register for "AC Power value", int16 in Watts 75 | regPowerInvS = 40084; // modbus register for "AC Power scale factor" int16 76 | regPowerMet = 40206; // modbus register for "Total Real Power (sum of active phases)" int16 in Watts 77 | regPowerMetS = 40210; // modbus register for "AC Real Power Scale Factor" int16 SF 78 | break; 79 | case 2: // Fronius (sunspec) registers for modbusTCP 80 | inverterPort = 502; 81 | inverterAddr = 1; 82 | smartmetAddr = 240; 83 | //regAcCurrent = 40071; // 40072 1 I_AC_Current uint16 Amps AC Total Current value 84 | regPowerInv = 40083; // modbus register for "AC Power value", int16 in Watts 85 | regPowerInvS = 40084; // modbus register for "AC Power scale factor" int16 86 | regPowerMet = 40087; // modbus register for "Total Real Power (sum of active phases)" int16 in Watts 87 | regPowerMetS = 40091; // modbus register for "AC Real Power Scale Factor" int16 SF 88 | break; 89 | case 3: // Kostal (sunspec) registers for modbusTCP 90 | inverterPort = 502; 91 | inverterAddr = 1; 92 | smartmetAddr = 240; 93 | //regAcCurrent = 40071; // 40072 1 I_AC_Current uint16 Amps AC Total Current value 94 | //regPowerInv = 40083; // modbus register for "AC Power value", int16 in Watts 95 | //regPowerInvS = 40084; // modbus register for "AC Power scale factor" int16 96 | regPowerMet = 40087; // modbus register for "Total Real Power (sum of active phases)" int16 in Watts 97 | regPowerMetS = 40091; // modbus register for "AC Real Power Scale Factor" int16 SF 98 | break; 99 | default: ;// nothing 100 | } 101 | // overwrite, if specifically configured by parameter 102 | if (cfgInverterPort) { inverterPort = cfgInverterPort; } 103 | if (cfgInverterAddr) { inverterAddr = cfgInverterAddr; } 104 | if (cfgInvSmartAddr) { smartmetAddr = cfgInvSmartAddr; } 105 | } 106 | } 107 | } 108 | 109 | 110 | void inverter_loop() { 111 | if ((millis() - lastHandleCall < (uint16_t)cfgPvCycleTime * 1000) || // avoid unnecessary frequent calls 112 | (inverterActive == false)) { 113 | return; 114 | } 115 | lastHandleCall = millis(); 116 | 117 | isConnected = mb.isConnected(remote); 118 | 119 | if (!isConnected) { // Check if connection to Modbus Slave is established 120 | mb.connect(remote, inverterPort); // Try to connect if no connection 121 | } else { 122 | if (regAcCurrent) { mb.readHreg(remote, regAcCurrent, (uint16_t *) &ac_current, 1, cb, inverterAddr); } 123 | if (regPowerInv) { mb.readHreg(remote, regPowerInv, (uint16_t *) &power_inverter, 1, cb, inverterAddr); } //Power Inverter 124 | if (regPowerInvS) { mb.readHreg(remote, regPowerInvS, (uint16_t *) &power_inverter_scale, 1, cb, inverterAddr); } //Power Inverter Scale Factor 125 | if (regPowerMet) { mb.readHreg(remote, regPowerMet, (uint16_t *) &power_meter, 1, cb, smartmetAddr); } //Power Zähler 126 | if (regPowerMetS) { mb.readHreg(remote, regPowerMetS, (uint16_t *) &power_meter_scale, 1, cb, smartmetAddr); } //Power Zähler Scale Factor 127 | } 128 | mb.task(); // Common local Modbus task 129 | 130 | if (power_inverter_scale < 0) { // if negative, then divide 131 | pwrInv = power_inverter / pow_int16(10, (uint16_t)(-power_inverter_scale)); 132 | } else { // if positive, then multiply 133 | pwrInv = power_inverter * pow_int16(10, (uint16_t) power_inverter_scale); 134 | } 135 | if (power_meter_scale < 0) { // if negative, then divide 136 | pwrMet = power_meter / pow_int16(10, (uint16_t)(-power_meter_scale)); 137 | } else { // if positive, then multiply 138 | pwrMet = power_meter * pow_int16(10, (uint16_t) power_meter_scale); 139 | } 140 | power_house = pwrInv - pwrMet; 141 | 142 | pv_setWatt(-pwrMet); // pvAlgo expects the value inverted 143 | } 144 | 145 | 146 | String inverter_getStatus() { 147 | StaticJsonDocument data; 148 | data[F("inverter")][F("isConnected")] = String(isConnected); 149 | data[F("power")][F("AC_Total")] = String(ac_current); 150 | data[F("power")][F("house")] = String(power_house); 151 | data[F("power")][F("inverter")] = String(power_inverter); 152 | data[F("power")][F("inverter_scale")] = String(power_inverter_scale); 153 | data[F("power")][F("meter")] = String(power_meter); 154 | data[F("power")][F("meter_scale")] = String(power_meter_scale); 155 | 156 | String response; 157 | serializeJson(data, response); 158 | return(response); 159 | } 160 | 161 | 162 | int16_t inverter_getPwrInv() { 163 | return(pwrInv); 164 | } 165 | 166 | 167 | int16_t inverter_getPwrMet() { 168 | return(pwrMet); 169 | } 170 | -------------------------------------------------------------------------------- /src/inverter.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 andreas.miketta, steff393, MIT license 2 | // based on https://github.com/AMiketta/wbec 3 | #ifndef INVERTER_H 4 | #define INVERTER_H 5 | 6 | #define INVERTER_JSON_LEN 256 7 | 8 | extern void inverter_setup(); 9 | extern void inverter_loop(); 10 | extern String inverter_getStatus(); 11 | extern int16_t inverter_getPwrInv(); 12 | extern int16_t inverter_getPwrMet(); 13 | 14 | #endif /* INVERTER_H */ 15 | -------------------------------------------------------------------------------- /src/loadManager.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #define CYCLE_TIME 500 // ms 9 | 10 | 11 | static uint32_t lastCall = 0; 12 | 13 | static uint8_t currLim[WB_CNT]; 14 | static uint8_t lastReq[WB_CNT]; 15 | 16 | /* 17 | static uint16_t sumRead = 0; 18 | static uint16_t sumReq = 0; 19 | static boolean allBoxesReceived = false; 20 | */ 21 | 22 | static uint16_t chargingRequested(uint8_t id) { 23 | // check charging state, as a binary number 24 | if (content[id][1] == 6 || content[id][1] == 7) { // C1 or C2 25 | return(1 << id); 26 | } else { 27 | return(0); 28 | } 29 | } 30 | 31 | 32 | static uint8_t saturate1(uint8_t val, uint16_t limit) { 33 | if (val > limit) { 34 | return(limit); 35 | } else { 36 | return(val); 37 | } 38 | } 39 | 40 | 41 | static uint8_t saturate2(uint8_t val, uint16_t limit1, uint16_t limit2) { 42 | uint16_t limit; 43 | if (limit1 < limit2) { 44 | limit = limit1; 45 | } else { 46 | limit = limit2; 47 | } 48 | return(saturate1(val, limit)); 49 | } 50 | 51 | 52 | 53 | static void lm_updateWbLimits() { 54 | // This is the central load management function. 55 | // It shall calculate the currLim[] value for every wallbox 56 | // 57 | // Input: 58 | // - chargingRequested(id) Car connected with charging request 59 | // - lastReq[id] Last requested current limit from any of the 'applications' on higher level 60 | // - content[id][15] Maximum current configured in box (switch S1) 61 | // - content[id][53] Current limit which is wallbox 62 | // Output: 63 | // - currLim[id] Current limit which shall be in the wallbox 64 | 65 | 66 | // Simple stupid method, only for 2 wallboxes with 50%/50% 67 | uint16_t requestMap = chargingRequested(0) + chargingRequested(1); 68 | 69 | switch(requestMap) { 70 | case 0: { // no charging request 71 | currLim[0] = 0; 72 | currLim[1] = 0; 73 | break; 74 | } 75 | case 1: { // charging request only on box 0 76 | currLim[0] = saturate2(lastReq[0], content[0][15] * 10, cfgTotalCurrMax); 77 | currLim[1] = 0; 78 | break; 79 | } 80 | case 2: { // charging request only on box 1 81 | currLim[0] = 0; 82 | currLim[1] = saturate2(lastReq[1], content[1][15] * 10, cfgTotalCurrMax); 83 | break; 84 | } 85 | case 3: { // charging request on both boxes 86 | currLim[0] = saturate2(lastReq[0], content[0][15] * 10, cfgTotalCurrMax / 2); 87 | currLim[1] = saturate2(lastReq[1], content[1][15] * 10, cfgTotalCurrMax / 2); 88 | break; 89 | } 90 | default: { ; } // shouldn't happen 91 | } 92 | 93 | /* 94 | // Complex, flexible methods 95 | 96 | uint16_t cnt = 0; 97 | uint16_t limit = 0; 98 | uint16_t remaining = cfgTotalCurrMax; 99 | 100 | sumRead = 0; 101 | sumReq = 0; 102 | 103 | // Method A 104 | // ----------------------------------------------------------------- 105 | 106 | // count boxes with charge request 107 | for (uint8_t id = 0; id < cfgCntWb ; id++) { 108 | if (chargingRequested(id)) { 109 | cnt++; 110 | } 111 | } 112 | 113 | // calculate 'fair' limit (=allowed current / nr. of boxes) 114 | if (cnt != 0) { 115 | limit = remaining / cnt; 116 | } 117 | 118 | // every box with charge request and request < 'fair' limit gets its request 119 | for (uint8_t id = 0; id < cfgCntWb ; id++) { 120 | if (chargingRequested(id) && lastReq[id] <= limit) { 121 | currLim[id] = saturate2(lastReq[id], remaining, content[id][15]); 122 | remaining -= currLim[id]; // can't become negative, as currLim is always <= remaining 123 | cnt--; 124 | } 125 | } 126 | 127 | // calculate 'fair' limit (=allowed current / nr. of boxes) 128 | if (cnt != 0) { 129 | limit = remaining / cnt; 130 | } 131 | 132 | // every box with charge request and request > 'fair' limit gets its request 133 | for (uint8_t id = 0; id < cfgCntWb ; id++) { 134 | if (chargingRequested(id)) { 135 | currLim[id] = saturate2(lastReq[id], limit, content[id][15]); 136 | remaining -= currLim[id]; // can't become negative, as currLim is always <= remaining 137 | } 138 | } 139 | 140 | // TODO... 141 | 142 | 143 | 144 | // Method B 145 | // ----------------------------------------------------------------- 146 | 147 | // count boxes with load request 148 | for (uint8_t id = 0; id < cfgCntWb ; id++) { 149 | if (chargingRequested(id)) { 150 | cnt++; 151 | } 152 | // avoid limits < 6A 153 | if (cnt > (cfgTotalCurrMax / CURR_ABS_MIN)) { 154 | cnt--; 155 | break; 156 | } 157 | sumRead += content[id][53]; 158 | sumReq += lastReq[id]; 159 | } 160 | 161 | if (sumReq <= cfgTotalCurrMax) { 162 | // no limitation needed, every box gets its request 163 | for (uint8_t id = 0; id < cfgCntWb ; id++) { 164 | currLim[id] = lastReq[id]; 165 | } 166 | } else { 167 | // more requests than allowed, splitting is necessary: 168 | // prio 1: Boxes with a car that wants charging 169 | // prio 2: all other boxes 170 | for (uint8_t id = 0; id < cfgCntWb ; id++) { 171 | // ... 172 | // TODO 173 | // ... 174 | } 175 | } 176 | // TODO... 177 | */ 178 | } 179 | 180 | 181 | void lm_setup() { 182 | for (uint8_t id = 0; id < WB_CNT ; id++) { 183 | currLim[id] = 255; // 255 is the marker, that no valid value received yet 184 | lastReq[id] = 0; 185 | } 186 | //if (cfgTotalCurrMax != 0) { 187 | // cfgStandby = 4; // disable standby when using load management 188 | //} Reasons to remove: 189 | // - if standby is disabled 190 | // --> then this code doesn't matter 191 | // - if standby is enabled 192 | // - as long as communication is up and running --> all fine, all values up to date 193 | // - if communication stops: 194 | // - normally car was disconnected before (because this is the reason to enter standby mode) --> chgStat = unplugged, no charge request 195 | // - if communication stops due to an error: 196 | // - the old values remain --> chgStat = plugged, charge request --> safe state 197 | } 198 | 199 | 200 | void lm_loop() { 201 | if ((millis() - lastCall < CYCLE_TIME) || (cfgTotalCurrMax == 0) || (cfgCntWb != 2)) { 202 | // avoid unnecessary frequent calls 203 | return; 204 | } 205 | lastCall = millis(); 206 | 207 | /* 208 | // load management only starts when all boxes have been received once 209 | if (allBoxesReceived == false) { 210 | for (uint8_t id = 0; id < cfgCntWb ; id++) { 211 | if (currLim[id] == 255) { return; } 212 | } 213 | allBoxesReceived = true; 214 | } 215 | */ 216 | 217 | lm_updateWbLimits(); 218 | 219 | for (uint8_t id = 0; id < cfgCntWb ; id++) { 220 | if (content[id][53] != currLim[id]) { 221 | // when the value from box differs to wanted value then write current via modbus 222 | mb_writeReg(id, REG_CURR_LIMIT, currLim[id]); 223 | } 224 | } 225 | } 226 | 227 | 228 | uint8_t lm_getWbLimit(uint8_t id) { 229 | return currLim[id]; 230 | } 231 | 232 | 233 | uint8_t lm_getLastRequest(uint8_t id) { 234 | return lastReq[id]; 235 | } 236 | 237 | 238 | void lm_storeRequest(uint8_t id, uint8_t val) { 239 | lastReq[id] = val; 240 | // when there is still buffer OR request is lowered OR load management inactive 241 | if (/*(sumRead + val < cfgTotalCurrMax) ||*/ (val < content[id][53]) || (cfgTotalCurrMax == 0)) { 242 | // direct write is possible 243 | mb_writeReg(id, REG_CURR_LIMIT, val); 244 | } 245 | } 246 | 247 | 248 | void lm_currentReadSuccess(uint8_t id) { 249 | // takeover the value from wallbox as long as not all boxes are received 250 | //if (allBoxesReceived == false) { 251 | // currLim[id] = content[id][53]; 252 | //} // else do nothing 253 | } 254 | -------------------------------------------------------------------------------- /src/loadManager.h: -------------------------------------------------------------------------------- 1 | #ifndef LOADMANAGER_H 2 | #define LOADMANAGER_H 3 | 4 | extern void lm_setup(); 5 | extern void lm_loop(); 6 | 7 | extern uint8_t lm_getWbLimit(uint8_t id); 8 | extern uint8_t lm_getLastRequest(uint8_t id); 9 | extern void lm_storeRequest(uint8_t id, uint8_t val); 10 | extern void lm_currentReadSuccess(uint8_t id); 11 | 12 | #endif /* LOADMANAGER_H */ 13 | -------------------------------------------------------------------------------- /src/logger.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | #define TIME_LEN 10 // "23:12:01: " 12 | #define MOD_LEN 6 // "WEBS: " 13 | 14 | static WiFiUDP ntpUDP; 15 | static NTPClient timeClient(ntpUDP, cfgNtpServer, 3600, 60000); // GMT+1 and update every minute 16 | 17 | static const char *mod[15] = {"", "MB ", "MQTT", "WEBS", "GO-E", "CFG ", "1P3P", "LLOG", "RFID", "PFOX", "SOCK", "PV ", "SHLY", "PVHT", "MAIN"}; 18 | static char * bootLog; 19 | static uint16_t bootLogSize; 20 | 21 | 22 | static boolean getDstGermany(uint32_t unixtime) { 23 | 24 | const uint32_t SEKUNDEN_PRO_TAG = 86400ul; /* 24* 60 * 60 */ 25 | const uint32_t TAGE_IM_GEMEINJAHR = 365ul; /* kein Schaltjahr */ 26 | const uint32_t TAGE_IN_4_JAHREN = 1461ul; /* 4*365 + 1 */ 27 | const uint32_t TAGE_IN_100_JAHREN = 36524ul; /* 100*365 + 25 - 1 */ 28 | const uint32_t TAGE_IN_400_JAHREN = 146097ul; /* 400*365 + 100 - 4 + 1 */ 29 | const uint32_t TAGN_AD_1970_01_01 = 719468ul; /* Tagnummer bezogen auf den 1. Maerz des Jahres "Null" */ 30 | uint8_t month; 31 | uint8_t wday; 32 | uint8_t hour; 33 | 34 | uint16_t day; 35 | uint32_t dayN; 36 | uint32_t temp; 37 | 38 | // *unixtime += 3600; // +1 für UTC->D 39 | 40 | // alle berechnungen nachfolgend 41 | dayN = (TAGN_AD_1970_01_01 + unixtime / SEKUNDEN_PRO_TAG); 42 | 43 | wday = (uint8_t)(((unixtime / 3600 / 24) + 4) % 7); // weekday 44 | // Schaltjahrregel des Gregorianischen Kalenders: Jedes durch 100 teilbare Jahr ist kein Schaltjahr, es sei denn, es ist durch 400 teilbar. 45 | temp = 4 * (dayN + TAGE_IN_100_JAHREN + 1) / TAGE_IN_400_JAHREN - 1; 46 | dayN -= TAGE_IN_100_JAHREN * temp + temp / 4; 47 | 48 | // Schaltjahrregel des Julianischen Kalenders: 49 | // Jedes durch 4 teilbare Jahr ist ein Schaltjahr. 50 | temp = 4 * (dayN + TAGE_IM_GEMEINJAHR + 1) / TAGE_IN_4_JAHREN - 1; 51 | dayN -= TAGE_IM_GEMEINJAHR * temp + temp / 4; 52 | 53 | // dayN enthaelt jetzt nur noch die Tage des errechneten Jahres bezogen auf den 1. Maerz. 54 | month = (uint8_t)((5 * (uint16_t)dayN + 2) / 153); 55 | day = (uint16_t)((uint16_t)dayN - (uint16_t)(month * 153 + 2) / 5 + 1); 56 | 57 | hour = (uint8_t)((unixtime % SEKUNDEN_PRO_TAG) / 3600); 58 | 59 | // vom Jahr, das am 1. Maerz beginnt auf unser normales Jahr umrechnen: 60 | month = (uint8_t)((uint8_t)(month + 3) % 13); 61 | 62 | if( month < 3 || month > 10 ) // month 1, 2, 11, 12 63 | return 0; // -> Winter 64 | 65 | if(day - wday >= 25 && (wday || hour >= 2)) { // after last Sunday 2:00 66 | 67 | if(month == 10) // October -> Winter 68 | return 0; 69 | 70 | } else { // before last Sunday 2:00 71 | 72 | if(month == 3) // March -> Winter 73 | return 0; 74 | 75 | } 76 | return 1; 77 | //*unixtime += 3600; // nochmal+1 für Sommerzeit 78 | 79 | } 80 | 81 | 82 | void log(uint8_t module, String msg, boolean newLine /* =true */) { 83 | String output; 84 | 85 | if (module) { 86 | output = timeClient.getFormattedTime() + ": " + String(mod[module]) + ": "; 87 | } 88 | output += msg; 89 | if (newLine) { 90 | output += "\n"; 91 | } 92 | Serial.print(output); 93 | 94 | if ((strlen(bootLog)+strlen(output.c_str()) + 5) < bootLogSize) { 95 | strcat(bootLog, output.c_str()); 96 | } 97 | mqtt_log(output.c_str(), msg.c_str()); 98 | } 99 | 100 | 101 | void log(uint8_t module, const char *msg, boolean newLine /* =true */) { 102 | char output[TIME_LEN + MOD_LEN + 1]; 103 | 104 | if (module) { 105 | strcpy(output, timeClient.getFormattedTime().c_str()); // 2 for ": " 106 | strcat(output, ": "); 107 | strncat(output, mod[module], MOD_LEN-2); // 2 for ": " 108 | strcat(output, ": "); 109 | } else { 110 | strcpy(output, ""); 111 | } 112 | // print to Serial 113 | Serial.print(output); 114 | Serial.print(msg); 115 | if (newLine) { 116 | Serial.print("\n"); 117 | } 118 | // print to bootLog, if there is still enough space 119 | if ((strlen(bootLog)+strlen(output)+strlen(msg) + 5) < bootLogSize) { 120 | strcat(bootLog, output); 121 | strcat(bootLog, msg); 122 | if (newLine) { 123 | strcat(bootLog, "\n"); 124 | } 125 | } 126 | mqtt_log(output, msg); 127 | } 128 | 129 | 130 | String log_time() { 131 | return(timeClient.getFormattedTime()); 132 | } 133 | 134 | 135 | uint32_t log_unixTime() { 136 | // The pfox chart needs the 'real' unixtime, not corrected for timezone or DST 137 | uint32_t time = timeClient.getEpochTime() - 3600; 138 | 139 | if (getDstGermany(time)) { 140 | time = time - 3600; 141 | } 142 | return(time); 143 | } 144 | 145 | 146 | char* log_getBuffer() { 147 | return(bootLog); 148 | } 149 | 150 | 151 | void log_freeBuffer() { 152 | // set string-end character to first position to indicate an empty string 153 | bootLog[0] = '\0'; 154 | } 155 | 156 | 157 | void logger_allocate() { 158 | // allocate a small amount at the beginning, for logging during loadconfig() 159 | bootLogSize = 200; 160 | bootLog = (char *) malloc(bootLogSize); 161 | bootLog[0] = '\0'; 162 | } 163 | 164 | 165 | void logger_setup() { 166 | // call this once the values from config are available 167 | if (!strcmp(cfgFoxUser, "") || !strcmp(cfgFoxPass, "") || !strcmp(cfgFoxDevId, "")) { 168 | // powerfox is NOT configured => extend bootlog 169 | char * tmpPtr; 170 | bootLogSize = cfgBootlogSize; 171 | tmpPtr = (char *) malloc(bootLogSize); // allocate a new area on heap 172 | strncpy(tmpPtr, bootLog, bootLogSize); // copy content from old bootlog 173 | free(bootLog); 174 | bootLog = tmpPtr; // set bootLog pointer to new larger area 175 | } 176 | // connect to NTP time server 177 | timeClient.begin(); 178 | } 179 | 180 | 181 | void logger_loop() { 182 | timeClient.update(); 183 | if (getDstGermany(timeClient.getEpochTime())) timeClient.setTimeOffset(7200); 184 | } 185 | -------------------------------------------------------------------------------- /src/logger.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #ifndef LOGGER_H 4 | #define LOGGER_H 5 | 6 | // standard log 7 | #define LOG(MODULE, TEXT, ...) {char s[100]; snprintf_P(s, sizeof(s), PSTR(TEXT), __VA_ARGS__); log(MODULE, s);} 8 | // standard log without newline 9 | #define LOGN(MODULE, TEXT, ...) {char s[100]; snprintf_P(s, sizeof(s), PSTR(TEXT), __VA_ARGS__); log(MODULE, s, false);} 10 | // large log 11 | #define LOGEXT(MODULE, TEXT, ...) {char s[600]; snprintf_P(s, sizeof(s), PSTR(TEXT), __VA_ARGS__); log(MODULE, s);}; 12 | 13 | extern void logger_allocate(); 14 | extern void logger_setup(); 15 | extern void logger_loop(); 16 | 17 | extern void log(uint8_t module, String msg, boolean newLine=true); 18 | extern void log(uint8_t module, const char *msg, boolean newLine=true); 19 | extern String log_time(); 20 | extern uint32_t log_unixTime(); 21 | 22 | extern char* log_getBuffer(); 23 | extern void log_freeBuffer(); 24 | 25 | #endif /* LOGGER_H */ 26 | -------------------------------------------------------------------------------- /src/main.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #include 4 | #include 5 | #include 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 | #define WIFI_MANAGER_USE_ASYNC_WEB_SERVER 21 | #include 22 | #include 23 | #include 24 | 25 | const uint8_t m = 14; 26 | 27 | 28 | static bool _handlingOTA = false; 29 | 30 | 31 | void setup() { 32 | Serial.begin(115200); 33 | Serial.println(F(" ")); Serial.println(F("-----------------")); 34 | Serial.println(F("Starting wbec ;-)")); 35 | Serial.println(F("-----------------")); Serial.println(F(" ")); 36 | logger_allocate(); 37 | 38 | if(!LittleFS.begin()){ 39 | Serial.println(F("An Error has occurred while mounting LittleFS")); 40 | return; 41 | } 42 | 43 | loadConfig(); 44 | 45 | WiFiManager wifiManager; 46 | char ssid[32]; strcpy(ssid, cfgApSsid); 47 | char pass[63]; strcpy(pass, cfgApPass); 48 | wifiManager.setConnectTimeout(cfgWifiConnectTimeout); 49 | wifiManager.autoConnect(ssid, pass); 50 | 51 | // still experimental (see #12): 52 | if (cfgWifiSleepMode >= WIFI_NONE_SLEEP && cfgWifiSleepMode <= WIFI_MODEM_SLEEP) { 53 | WiFi.setSleepMode((WiFiSleepType_t)cfgWifiSleepMode); 54 | } 55 | 56 | logger_setup(); 57 | 58 | // setup the Webserver 59 | webServer_setup(); 60 | webSocket_setup(); 61 | 62 | // setup the OTA server 63 | ArduinoOTA.setHostname("wbec"); 64 | ArduinoOTA.begin(); 65 | 66 | ArduinoOTA.onStart([]() 67 | { 68 | _handlingOTA = true; 69 | }); 70 | 71 | mb_setup(); 72 | mqtt_begin(); 73 | rfid_setup(); 74 | powerfox_setup(); 75 | shelly_setup(); 76 | pvHttp_setup(); 77 | inverter_setup(); 78 | btn_setup(); 79 | pv_setup(); 80 | lm_setup(); 81 | LOG(m, "Boot time: %ld ms", millis()); 82 | LOG(m, "Free heap: %ld Byte",ESP.getFreeHeap()); 83 | } 84 | 85 | 86 | void loop() { 87 | ArduinoOTA.handle(); 88 | if(!_handlingOTA) { 89 | logger_loop(); 90 | mb_loop(); 91 | goE_handle(); 92 | mqtt_handle(); 93 | webServer_loop(); 94 | webSocket_loop(); 95 | rfid_loop(); 96 | powerfox_loop(); 97 | shelly_loop(); 98 | //pvHttp_loop(); 99 | inverter_loop(); 100 | btn_loop(); 101 | pv_loop(); 102 | //pc_handle(); 103 | lm_loop(); 104 | if (cfgLoopDelay <= 10) { // see #18, might have an effect to reactivity of webserver in some environments 105 | delay(cfgLoopDelay); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/mbComm.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 steff393, MIT license 2 | 3 | #include 4 | #include "globalConfig.h" 5 | #include "logger.h" 6 | #include "mbComm.h" 7 | #include "mqtt.h" 8 | #include 9 | #include "loadManager.h" 10 | #include "phaseCtrl.h" 11 | #include 12 | 13 | #define RINGBUF_SIZE 20 14 | 15 | const uint8_t m = 1; 16 | 17 | 18 | typedef struct rb_struct { 19 | uint8_t id; // box id (0..WB_CNT) 20 | uint16_t reg; // register 21 | uint16_t val; // value 22 | uint16_t * buf; // write: null read: buffer where to write the response 23 | } rb_t; 24 | 25 | 26 | uint16_t content[WB_CNT][55]; 27 | uint32_t modbusLastTime = 0; 28 | uint8_t modbusResultCode[WB_CNT]; 29 | 30 | static SoftwareSerial S; 31 | static ModbusRTU mb; 32 | static uint32_t modbusLastMsgSentTime = 0; 33 | static uint8_t modbusFailureCnt[WB_CNT]; 34 | static uint8_t msgCnt = 0; 35 | static uint8_t id = 0; 36 | static uint8_t msgCnt0_lastId = 255; 37 | static rb_t rb[RINGBUF_SIZE]; // ring buffer 38 | static uint8_t rbIn = 0; // last element, which was written to ring buffer 39 | static uint8_t rbOut = 0; // last element, which was read from ring buffer 40 | 41 | static boolean mb_available() { 42 | // don't allow new msg, when communication is still active (ca.30ms) or minimum delay time not exceeded 43 | if (mb.slave() || millis() - modbusLastMsgSentTime < cfgMbDelay) { 44 | return(false); 45 | } else { 46 | return(true); 47 | } 48 | } 49 | 50 | 51 | void mb_getAscii(uint8_t id, uint8_t from, uint8_t len, char *result) { 52 | // translate the uint16 values into a String 53 | for (int i = from; i < (from + len) ; i++) { 54 | result[(i-from)*2] = (char) (content[id][i] & 0x00FF); 55 | result[(i-from)*2+1] = (char) (content[id][i] >> 8); 56 | } 57 | result[len*2]='\0'; 58 | } 59 | 60 | 61 | static void timeout(uint8_t id) { 62 | if (cfgResetOnTimeout) { 63 | if (cfgStandby == 4) { 64 | // standby disabled => timeout indicates a failure => reset all 65 | for (int i = 1; i <= 16; i++) { content[id][i] = 0; } 66 | for (int i = 49; i <= 54; i++) { content[id][i] = 0; } 67 | } else { 68 | // standby enabled => timeout is normal, but the following should avoid to consider 'old' values as valid 69 | for (int i = 2; i <= 12; i++) { content[id][i] = 0; } 70 | content[id][53] = 0; 71 | } 72 | } 73 | } 74 | 75 | 76 | static bool cbWrite(Modbus::ResultCode event, uint16_t transactionId, void* data) { 77 | int id = mb.slave()-1; 78 | modbusResultCode[id] = event; 79 | if (event) { 80 | LOG(m, "Comm-Failure BusID %d", mb.slave()); 81 | if (modbusFailureCnt[id] < 250) { 82 | modbusFailureCnt[id]++; 83 | } 84 | if (modbusFailureCnt[id] == 10) { 85 | // too many consecutive timeouts --> reset values 86 | LOG(m, "Timeout BusID %d", mb.slave()); 87 | timeout(id); 88 | } 89 | } else { 90 | // no failure 91 | modbusFailureCnt[id] = 0; 92 | // tell load manager that the current register was successfully read 93 | if (msgCnt == 6+1) { 94 | lm_currentReadSuccess(id); 95 | } 96 | } 97 | 98 | //log(m, "ResultCode: 0x" + String(event, HEX) + ", BusID: "+ mb.slave()); 99 | return(true); 100 | } 101 | 102 | 103 | void mb_setup() { 104 | // setup SoftwareSerial and Modbus Master 105 | LOG(m, "HwVersion: %d", cfgHwVersion); 106 | if (cfgHwVersion == 10) { 107 | S.begin(19200, SWSERIAL_8E1, PIN_DI, PIN_RO); // inverted 108 | } else { 109 | S.begin(19200, SWSERIAL_8E1, PIN_RO, PIN_DI); // Wallbox Energy Control uses 19.200 bit/sec, 8 data bit, 1 parity bit (even), 1 stop bit 110 | } 111 | mb.begin(&S, PIN_DE_RE); 112 | mb.master(); 113 | for (uint8_t i = 0; i < WB_CNT; i++) { 114 | modbusFailureCnt[i] = 0; 115 | modbusResultCode[i] = 0; 116 | } 117 | } 118 | 119 | 120 | void mb_loop() { 121 | // When pointers of the ring buffer are not equal, then there is something to send 122 | if (rbOut != rbIn) { 123 | if (mb_available()) { // check, if bus available 124 | rbOut = (rbOut+1) % RINGBUF_SIZE; // increment pointer, but take care of overflow 125 | if (rb[rbOut].buf != NULL) { 126 | mb.readHreg (rb[rbOut].id + 1, rb[rbOut].reg, rb[rbOut].buf, 1, cbWrite); 127 | } else { 128 | mb.writeHreg(rb[rbOut].id + 1, rb[rbOut].reg, &rb[rbOut].val, 1, cbWrite); 129 | } 130 | modbusLastMsgSentTime = millis(); 131 | } 132 | } 133 | 134 | if (modbusLastTime == 0 || millis() - modbusLastTime > (cfgMbCycleTime*1000)) { 135 | if (mb_available()) { 136 | //Serial.print(millis());Serial.print(": Sending to BusID: ");Serial.print(id+1);Serial.print(" with msgCnt = ");Serial.println(msgCnt); 137 | if (msgCnt0_lastId != 255) { 138 | // msgCnt=0 was recently sent => content is updated => publish to MQTT 139 | mqtt_publish(msgCnt0_lastId); 140 | msgCnt0_lastId = 255; 141 | } 142 | if (!modbusResultCode[id]) { 143 | //log(m, String(millis()) + ": BusID=" + (id+1) + ",msgCnt=" + msgCnt); 144 | } 145 | switch(msgCnt) { 146 | case 0: mb.readIreg (id+1, 4, &content[id][0] , 15, cbWrite); msgCnt0_lastId = id; break; 147 | case 1: if (!modbusResultCode[id]) { mb.readIreg (id+1, 100, &content[id][15], 17, cbWrite); } break; 148 | case 2: if (!modbusResultCode[id]) { mb.readIreg (id+1, 117, &content[id][32], 17, cbWrite); } break; 149 | case 3: if (!modbusResultCode[id]) { mb.readHreg (id+1, REG_WD_TIME_OUT, &content[id][49], 1, cbWrite); } break; 150 | case 4: if (!modbusResultCode[id] && content[id][0] > 263) { mb.readHreg (id+1, REG_STANDBY_CTRL, &content[id][50], 1, cbWrite); } break; // Can't be read in FW 0x0107 = 263dec 151 | case 5: if (!modbusResultCode[id] && content[id][0] > 263) { mb.readHreg (id+1, REG_REMOTE_LOCK, &content[id][51], 1, cbWrite); } break; // Can't be read in FW 0x0107 = 263dec 152 | case 6: if (!modbusResultCode[id]) { mb.readHreg (id+1, REG_CURR_LIMIT, &content[id][53], 2, cbWrite); } break; 153 | case 7: if (!modbusResultCode[id]) { mb.writeHreg(id+1, REG_WD_TIME_OUT, &cfgMbTimeout, 1, cbWrite); } break; 154 | //case 8: if (!modbusResultCode[id]) { mb.writeHreg(id+1, REG_STANDBY_CTRL, &cfgStandby, 1, cbWrite); } break; // wbecPro Issue #11 155 | case 8: if (!modbusResultCode[id]) { mb.writeHreg(id+1, REG_CURR_LIMIT_FS,&cfgFailsafeCurrent,1, cbWrite); } break; 156 | default: ; // do nothing, should not happen 157 | } 158 | modbusLastMsgSentTime = millis(); 159 | id++; 160 | if (id >= cfgCntWb) { 161 | id = 0; 162 | msgCnt++; 163 | } 164 | if (msgCnt > 8 || 165 | (msgCnt > 6 && modbusLastTime != 0)) { // write the REG_WD_TIME_OUT and REG_STANDBY_CTRL and REG_CURR_LIMIT_FS only on the very first loop 166 | msgCnt = 0; 167 | //Serial.print("Time:");Serial.println(millis()-modbusLastTime); 168 | modbusLastTime = millis(); 169 | } 170 | } 171 | } 172 | mb.task(); 173 | yield(); 174 | } 175 | 176 | 177 | void mb_writeReg(uint8_t id, uint16_t reg, uint16_t val) { 178 | if (pc_switchInProgress() && id == 0 && reg == REG_CURR_LIMIT) { 179 | // when switching of phases is in progress, then just backup the requested current 180 | pc_backupRequest(val); 181 | return; 182 | } 183 | rbIn = (rbIn+1) % RINGBUF_SIZE; // increment pointer, but take care of overflow 184 | rb[rbIn].id = id; 185 | rb[rbIn].reg = reg; 186 | rb[rbIn].val = val; 187 | rb[rbIn].buf = 0; 188 | if (rbIn == rbOut) { 189 | // we have overwritten an not-sent value -> set rbOut to next element, otherwise complete ring would be skipped 190 | rbOut = (rbOut+1) % RINGBUF_SIZE; // increment pointer, but take care of overflow 191 | LOG(m, "Overflow of ring buffer", ""); 192 | } 193 | 194 | // direct read back, when current register was modified 195 | if (reg == REG_CURR_LIMIT && ((rbIn+1) % RINGBUF_SIZE != rbOut)) { // ... but reading is not worth an overflow (with loosing data) 196 | rbIn = (rbIn+1) % RINGBUF_SIZE; // increment pointer, but take care of overflow 197 | rb[rbIn].id = id; 198 | rb[rbIn].reg = reg; 199 | rb[rbIn].val = 0; 200 | rb[rbIn].buf = &content[id][53]; 201 | } 202 | } 203 | 204 | 205 | uint8_t mb_getFailureCnt(uint8_t id) { 206 | return(modbusFailureCnt[id]); 207 | } 208 | -------------------------------------------------------------------------------- /src/mbComm.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393 2 | 3 | #include 4 | 5 | #ifndef MBCOMM_H 6 | #define MBCOMM_H 7 | 8 | extern void mb_setup(); 9 | extern void mb_loop(); 10 | extern void mb_writeReg(uint8_t id, uint16_t reg, uint16_t val); 11 | extern void mb_getAscii(uint8_t id, uint8_t from, uint8_t len, char *result); 12 | extern uint8_t mb_getFailureCnt(uint8_t id); 13 | 14 | extern uint16_t content[WB_CNT][55]; 15 | extern uint32_t modbusLastTime; 16 | extern uint8_t modbusResultCode[WB_CNT]; 17 | 18 | 19 | #endif /* MBCOMM_H */ 20 | -------------------------------------------------------------------------------- /src/mqtt.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | 15 | 16 | const uint8_t m = 2; 17 | const char* lastWillTopic = "wbec/connection"; 18 | const char* lastWillMsgOff = "offline"; 19 | const char* lastWillMsgOn = "online"; 20 | const uint8_t lastWillQos = 1; 21 | const bool lastWillRetain = true; 22 | 23 | WiFiClient espClient; 24 | PubSubClient client(espClient); 25 | uint32_t lastMsg = 0; 26 | uint32_t lastReconnect = 0; 27 | uint8_t maxcurrent[WB_CNT]; 28 | boolean callbackActive = false; 29 | 30 | 31 | void callback(char* topic, byte* payload, uint8_t length) { 32 | callbackActive = true; 33 | // handle received message 34 | char buffer[length+1]; // +1 for string termination 35 | for (uint8_t i = 0; i < length; i++) { 36 | buffer[i] = (char)payload[i]; 37 | } 38 | buffer[length] = '\0'; // add string termination 39 | LOGEXT(m, "Received: %s, Payload: %s", topic, buffer) 40 | 41 | // topics for openWB 42 | if (strstr_P(topic, PSTR("openWB/lp/")) && strstr_P(topic, PSTR("/AConfigured"))) { 43 | uint16_t val = atoi(buffer); 44 | uint8_t lp = topic[10] - '0'; // loadpoint nr. 45 | uint8_t i; 46 | // search, which index fits to loadpoint, first element will be selected 47 | for (i = 0; i < cfgCntWb; i++) { 48 | if (cfgMqttLp[i] == lp) {break;} 49 | } 50 | if (cfgMqttLp[i] == lp) { 51 | // openWB has 1A resolution, wbec has 0.1A resolution 52 | val = val * 10; 53 | // set current 54 | if (val == 0 || (val >= CURR_ABS_MIN && val <= CURR_ABS_MAX)) { 55 | LOG(0, ", Write to box: %d Value: %d", i, val) 56 | lm_storeRequest(i, val); 57 | } 58 | } else { 59 | LOG(0, ", no box assigned", ""); 60 | } 61 | } 62 | 63 | // topics for openWB 2.0 (#75) 64 | if (strstr_P(topic, PSTR("openWB/chargepoint/")) && strstr_P(topic, PSTR("/set/current"))) { 65 | float val = atof(buffer); 66 | uint8_t lp = topic[19] - '0'; // loadpoint nr. 67 | uint8_t i; 68 | // search, which index fits to loadpoint, first element will be selected 69 | for (i = 0; i < cfgCntWb; i++) { 70 | if (cfgMqttLp[i] == lp) {break;} 71 | } 72 | if (cfgMqttLp[i] == lp) { 73 | // openWB resolution is unclear (float with example value 12.34), wbec has 0.1A resolution 74 | val = val * 10; 75 | // set current 76 | if (val == 0 || (val >= CURR_ABS_MIN && val <= CURR_ABS_MAX)) { 77 | LOG(0, ", Write to box: %d Value: %d", i, (uint8_t)val) 78 | lm_storeRequest(i, (uint8_t)val); 79 | } 80 | } else { 81 | LOG(0, ", no box assigned", ""); 82 | } 83 | } 84 | 85 | // topics for EVCC 86 | if (strstr_P(topic, PSTR("wbec/lp/")) && strstr_P(topic, PSTR("/maxcurrent"))) { 87 | float val = atof(buffer); 88 | uint8_t lp = topic[8] - '0'; // loadpoint nr. 89 | uint8_t i; 90 | // search, which index fits to loadpoint, first element will be selected 91 | for (i = 0; i < cfgCntWb; i++) { 92 | if (cfgMqttLp[i] == lp) {break;} 93 | } 94 | if (cfgMqttLp[i] == lp) { 95 | // EVCC has 1A resolution, wbec has 0.1A resolution 96 | val = val * 10; 97 | // set current 98 | if (val == 0 || (val >= CURR_ABS_MIN && val <= CURR_ABS_MAX)) { 99 | LOG(0, ", Write to box: %d Value: %d", i, (uint16_t) val) 100 | maxcurrent[i] = (uint8_t)val; 101 | lm_storeRequest(i, val); 102 | } 103 | } else { 104 | LOG(0, ", no box assigned", ""); 105 | } 106 | } 107 | 108 | if (strstr_P(topic, PSTR("wbec/lp/")) && strstr_P(topic, PSTR("/enable"))) { 109 | uint8_t lp = topic[8] - '0'; // loadpoint nr. 110 | uint8_t i; 111 | // search, which index fits to loadpoint, first element will be selected 112 | for (i = 0; i < cfgCntWb; i++) { 113 | if (cfgMqttLp[i] == lp) {break;} 114 | } 115 | if (cfgMqttLp[i] == lp) { 116 | if (strstr_P(buffer, PSTR("true"))) { 117 | LOG(0, ", Enable box: %d", i) 118 | lm_storeRequest(i, maxcurrent[i]); 119 | } else { 120 | LOG(0, ", Disable box: %d", i) 121 | lm_storeRequest(i, 0); 122 | } 123 | } else { 124 | LOG(0, ", no box assigned", ""); 125 | } 126 | } 127 | 128 | // set the watt value via MQTT (#54) 129 | if (strcmp(topic, cfgMqttWattTopic) == 0) { 130 | if (strcmp(cfgMqttWattJson, "") == 0) { 131 | // directly take the value from the buffer 132 | pv_setWatt(atol(buffer)); 133 | } else { 134 | // extract the value from a JSON string (only 1st occurence) 135 | // Example: {"Time":"2022-12-10T17:25:46","Main":{"power":-123,"from_grid":441.231,"to_grid":9578.253}} 136 | // cfgMqttWattJson = power\": | |------> 137 | // the slash \ will escape the quote " sign 138 | char * pch; 139 | pch = strstr(buffer, cfgMqttWattJson) + strlen(cfgMqttWattJson); // search the index of cfgMqttWattJson, then add it's length 140 | pv_setWatt(atol(pch)); 141 | } 142 | } 143 | 144 | callbackActive = false; 145 | } 146 | 147 | 148 | void mqtt_begin() { 149 | if (strcmp(cfgMqttIp, "") != 0) { 150 | client.setServer(cfgMqttIp, cfgMqttPort); 151 | client.setCallback(callback); 152 | } 153 | for (uint8_t i = 0; i < cfgCntWb; i++) { 154 | maxcurrent[i] = CURR_ABS_MIN; 155 | } 156 | } 157 | 158 | void reconnect() { 159 | LOGN(m, "Attempting MQTT connection...", ""); 160 | // Create a random client ID 161 | char clientId[10]; 162 | if (cfgMqttClientId) { 163 | snprintf_P(clientId, sizeof(clientId), PSTR("wbec-%d"), cfgMqttClientId); 164 | } else { 165 | snprintf_P(clientId, sizeof(clientId), PSTR("wbec-%d"), (uint8_t)random(255)); 166 | } 167 | 168 | 169 | // Attempt to connect 170 | boolean con = false; 171 | if (strcmp(cfgMqttUser, "") != 0 && strcmp(cfgMqttPass, "") != 0) { 172 | con = client.connect(clientId, cfgMqttUser, cfgMqttPass, lastWillTopic, lastWillQos, lastWillRetain, lastWillMsgOff); 173 | } else { 174 | con = client.connect(clientId, lastWillTopic, lastWillQos, lastWillRetain, lastWillMsgOff); 175 | } 176 | if (con) 177 | { 178 | LOG(0, "connected", ""); 179 | //once connected to MQTT broker, subscribe command if any 180 | for (uint8_t i = 0; i < cfgCntWb; i++) { 181 | char topic[40]; 182 | if (cfgMqttLp[i] != 0) { 183 | snprintf_P(topic, sizeof(topic), PSTR("openWB/lp/%d/AConfigured"), cfgMqttLp[i]); 184 | client.subscribe(topic); 185 | snprintf_P(topic, sizeof(topic), PSTR("wbec/lp/%d/enable"), cfgMqttLp[i]); 186 | client.subscribe(topic); 187 | snprintf_P(topic, sizeof(topic), PSTR("wbec/lp/%d/maxcurrent"), cfgMqttLp[i]); 188 | client.subscribe(topic); 189 | } 190 | } 191 | client.subscribe(cfgMqttWattTopic); 192 | } else { 193 | LOG(m, "failed, rc=%d try again in 5 seconds", client.state()) 194 | } 195 | } 196 | 197 | void mqtt_handle() { 198 | if (strcmp(cfgMqttIp, "") != 0) { 199 | uint32_t now = millis(); 200 | 201 | if (!client.connected()) { 202 | if (now - lastReconnect > 5000 || lastReconnect == 0) { 203 | reconnect(); 204 | lastReconnect = now; 205 | } 206 | } 207 | 208 | client.loop(); 209 | } 210 | } 211 | 212 | 213 | void mqtt_publish(uint8_t i) { 214 | if (strcmp(cfgMqttIp, "") == 0 || cfgMqttLp[i] == 0) { 215 | return; // do nothing, when Mqtt is not configured, or box has no loadpoint assigned 216 | } 217 | 218 | uint8_t ps = 0; 219 | uint8_t cs = 0; 220 | char status; 221 | 222 | switch(content[i][1]) { 223 | case 0: ps = 0; cs = 0; status = 'A'; break; // e.g. wallbox offline (#120) 224 | case 2: ps = 0; cs = 0; status = 'A'; break; 225 | case 3: ps = 0; cs = 0; status = 'A'; break; 226 | case 4: ps = 1; cs = 0; status = 'B'; break; 227 | case 5: ps = 1; cs = 0; status = 'B'; break; 228 | case 6: ps = 1; cs = 0; status = 'C'; break; 229 | case 7: ps = 1; cs = 1; status = 'C'; break; 230 | default: ps = 0; cs = 0; status = 'F'; break; 231 | } 232 | 233 | // publish the contents of box i 234 | char header[30]; 235 | char topic[50]; 236 | char value[20]; 237 | 238 | // topics for openWB 239 | snprintf_P(header, sizeof(header), PSTR("openWB/set/lp/%d"), cfgMqttLp[i]); 240 | boolean retain = true; 241 | 242 | snprintf_P(topic, sizeof(topic), PSTR("%s/plugStat"), header); 243 | snprintf_P(value, sizeof(value), PSTR("%d"), ps); 244 | client.publish(topic, value, retain); 245 | 246 | snprintf_P(topic, sizeof(topic), PSTR("%s/chargeStat"), header); 247 | snprintf_P(value, sizeof(value), PSTR("%d"), cs); 248 | client.publish(topic, value, retain); 249 | 250 | snprintf_P(topic, sizeof(topic), PSTR("%s/W"), header); 251 | snprintf_P(value, sizeof(value), PSTR("%d"), content[i][10]); 252 | client.publish(topic, value, retain); 253 | 254 | snprintf_P(topic, sizeof(topic), PSTR("%s/kWhCounter"), header); 255 | snprintf_P(value, sizeof(value), PSTR("%.3f"), (float)((uint32_t) content[i][13] << 16 | (uint32_t)content[i][14]) / 1000.0); 256 | client.publish(topic, value, retain); 257 | 258 | for (uint8_t ph = 1; ph <= 3; ph++) { 259 | snprintf_P(topic, sizeof(topic), PSTR("%s/VPhase%d"), header, ph); 260 | snprintf_P(value, sizeof(value), PSTR("%d"), content[i][ph+5]); // L1 = 6, L2 = 7, L3 = 8 261 | client.publish(topic, value, retain); 262 | } 263 | 264 | for (uint8_t ph = 1; ph <= 3; ph++) { 265 | snprintf_P(topic, sizeof(topic), PSTR("%s/APhase%d"), header, ph); 266 | snprintf_P(value, sizeof(value), PSTR("%.1f"), (float)content[i][ph+1]/10.0); // L1 = 2, L2 = 3, L3 = 4 267 | client.publish(topic, value, retain); 268 | } 269 | 270 | LOG(m, "Publish to %s", header) 271 | 272 | // topics for openWB 2.0 (#75) 273 | snprintf_P(header, sizeof(header), PSTR("openWB/set/chargepoint/%d"), cfgMqttLp[i]); 274 | 275 | snprintf_P(topic, sizeof(topic), PSTR("%s/get/plug_state"), header); 276 | snprintf_P(value, sizeof(value), PSTR("%s"), ps?"true":"false"); 277 | client.publish(topic, value, retain); 278 | 279 | snprintf_P(topic, sizeof(topic), PSTR("%s/get/charge_state"), header); 280 | snprintf_P(value, sizeof(value), PSTR("%s"), cs?"true":"false"); 281 | client.publish(topic, value, retain); 282 | 283 | snprintf_P(topic, sizeof(topic), PSTR("%s/get/power"), header); 284 | snprintf_P(value, sizeof(value), PSTR("%d"), content[i][10]); 285 | client.publish(topic, value, retain); 286 | 287 | snprintf_P(topic, sizeof(topic), PSTR("%s/get/imported"), header); 288 | snprintf_P(value, sizeof(value), PSTR("%ld"), ((uint32_t) content[i][13] << 16 | (uint32_t)content[i][14]) ); 289 | client.publish(topic, value, retain); 290 | 291 | snprintf_P(topic, sizeof(topic), PSTR("%s/get/voltages"), header); 292 | snprintf_P(value, sizeof(value), PSTR("[%d,%d,%d]"), content[i][6], content[i][7], content[i][8]); // L1 = 6, L2 = 7, L3 = 8 293 | client.publish(topic, value, retain); 294 | 295 | snprintf_P(topic, sizeof(topic), PSTR("%s/get/currents"), header); 296 | snprintf_P(value, sizeof(value), PSTR("[%.1f,%.1f,%.1f]"), (float)content[i][2]/10.0, (float)content[i][3]/10.0, (float)content[i][4]/10.0); // L1 = 2, L2 = 3, L3 = 4 297 | client.publish(topic, value, retain); 298 | 299 | snprintf_P(topic, sizeof(topic), PSTR("%s/get/phases_in_use"), header); 300 | snprintf_P(value, sizeof(value), PSTR("%d"), cfgPvPhFactor / 23); 301 | client.publish(topic, value, retain); 302 | 303 | snprintf_P(topic, sizeof(topic), PSTR("%s/get/rfid_tag"), header); 304 | snprintf_P(value, sizeof(value), PSTR("%s"), rfid_getLastID()); 305 | client.publish(topic, value, retain); 306 | 307 | // topics for EVCC 308 | snprintf_P(header, sizeof(header), PSTR("wbec/lp/%d"), cfgMqttLp[i]); 309 | 310 | snprintf_P(topic, sizeof(topic), PSTR("%s/status"), header); 311 | snprintf_P(value, sizeof(value), PSTR("%c"), status); 312 | client.publish(topic, value, retain); 313 | 314 | snprintf_P(topic, sizeof(topic), PSTR("%s/enabled"), header); 315 | if (content[i][53] > 0) { 316 | client.publish(topic, "true", retain); 317 | maxcurrent[i] = content[i][53]; // memorize the current limit if not 0 318 | } else { 319 | client.publish(topic, "false", retain); 320 | } 321 | 322 | snprintf_P(topic, sizeof(topic), PSTR("%s/power"), header); 323 | snprintf_P(value, sizeof(value), PSTR("%d"), content[i][10]); 324 | client.publish(topic, value, retain); 325 | 326 | snprintf_P(topic, sizeof(topic), PSTR("%s/energy"), header); 327 | snprintf_P(value, sizeof(value), PSTR("%.3f"), (float)((uint32_t) content[i][13] << 16 | (uint32_t)content[i][14]) / 1000.0); 328 | client.publish(topic, value, retain); 329 | 330 | snprintf_P(topic, sizeof(topic), PSTR("%s/energyC"), header); 331 | snprintf_P(value, sizeof(value), PSTR("%.3f"), (float)goE_getEnergySincePlugged(i) / 1000.0); 332 | client.publish(topic, value, retain); 333 | 334 | for (uint8_t ph = 1; ph <= 3; ph++) { 335 | snprintf_P(topic, sizeof(topic), PSTR("%s/currL%d"), header, ph); 336 | snprintf_P(value, sizeof(value), PSTR("%.1f"), (float)content[i][ph+1]/10.0); // L1 = 2, L2 = 3, L3 = 4 337 | client.publish(topic, value, retain); 338 | } 339 | 340 | for (uint8_t ph = 1; ph <= 3; ph++) { 341 | snprintf_P(topic, sizeof(topic), PSTR("%s/voltL%d"), header, ph); 342 | snprintf_P(value, sizeof(value), PSTR("%d"), content[i][ph+5]); // L1 = 2, L2 = 3, L3 = 4 343 | client.publish(topic, value, retain); 344 | } 345 | snprintf_P(topic, sizeof(topic), PSTR("%s/currLimit"), header); 346 | snprintf_P(value, sizeof(value), PSTR("%.1f"), (float)content[i][53]/10.0); 347 | client.publish(topic, value, retain); 348 | 349 | snprintf_P(topic, sizeof(topic), PSTR("%s/pcbTemp"), header); 350 | snprintf_P(value, sizeof(value), PSTR("%.1f"), (float)content[i][5]/10.0); 351 | client.publish(topic, value, retain); 352 | 353 | snprintf_P(topic, sizeof(topic), PSTR("%s/resCode"), header); 354 | snprintf_P(value, sizeof(value), PSTR("%s"), String(modbusResultCode[i], HEX)); 355 | client.publish(topic, value, retain); 356 | 357 | int qrssi = WiFi.RSSI(); 358 | snprintf_P(topic, sizeof(topic), PSTR("%s/wifiRssi"), header); 359 | snprintf_P(value, sizeof(value), PSTR("%d"), qrssi); 360 | client.publish(topic, value, retain); 361 | snprintf_P(topic, sizeof(topic), PSTR("%s/wifiChannel"), header); 362 | snprintf_P(value, sizeof(value), PSTR("%d"), WiFi.channel()); 363 | client.publish(topic, value, retain); 364 | 365 | snprintf_P(topic, sizeof(topic), PSTR("%s/plugState"), header); 366 | snprintf_P(value, sizeof(value), PSTR("%s"), ps?"true":"false"); 367 | client.publish(topic, value, retain); 368 | 369 | snprintf_P(topic, sizeof(topic), PSTR("%s/chargeState"), header); 370 | snprintf_P(value, sizeof(value), PSTR("%s"), cs?"true":"false"); 371 | client.publish(topic, value, retain); 372 | 373 | 374 | // publish values from inverter 375 | if (strcmp(cfgInverterIp, "") != 0) { 376 | snprintf_P(header, sizeof(header), PSTR("wbec/inverter")); 377 | 378 | snprintf_P(topic, sizeof(topic), PSTR("%s/pwrInv"), header); 379 | snprintf_P(value, sizeof(value), PSTR("%d"), inverter_getPwrInv()); 380 | client.publish(topic, value, retain); 381 | 382 | snprintf_P(topic, sizeof(topic), PSTR("%s/pwrMet"), header); 383 | snprintf_P(value, sizeof(value), PSTR("%d"), inverter_getPwrMet()); 384 | client.publish(topic, value, retain); 385 | } 386 | 387 | // publish values from pvAlgo 388 | if (pv_getMode()) { 389 | snprintf_P(header, sizeof(header), PSTR("wbec/pv")); 390 | 391 | snprintf_P(topic, sizeof(topic), PSTR("%s/mode"), header); 392 | snprintf_P(value, sizeof(value), PSTR("%d"), pv_getMode()); 393 | client.publish(topic, value, retain); 394 | 395 | snprintf_P(topic, sizeof(topic), PSTR("%s/watt"), header); 396 | snprintf_P(value, sizeof(value), PSTR("%ld"), pv_getWatt()); 397 | client.publish(topic, value, retain); 398 | } 399 | 400 | // Wbec-Connection Status 401 | client.publish(lastWillTopic, lastWillMsgOn, lastWillRetain); 402 | } 403 | 404 | void mqtt_log(const char *output, const char *msg) { 405 | if (strcmp(cfgMqttIp, "") == 0 || callbackActive) { 406 | return; // do nothing, when Mqtt is not configured OR when request comes from mqtt callback (#13) 407 | } 408 | 409 | boolean retain = true; 410 | char topic[10]; 411 | char value[150]; 412 | 413 | snprintf_P(topic, sizeof(topic), PSTR("wbec/log"), ""); 414 | snprintf_P(value, sizeof(value), PSTR("%s%s"), output, msg); 415 | client.publish(topic, value, retain); 416 | } -------------------------------------------------------------------------------- /src/mqtt.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #ifndef MQTT_H 4 | #define MQTT_H 5 | 6 | extern void mqtt_begin(); 7 | extern void mqtt_handle(); 8 | extern void mqtt_publish(uint8_t i); 9 | extern void mqtt_log(const char *output, const char *msg); 10 | 11 | #endif /* MQTT_H */ 12 | -------------------------------------------------------------------------------- /src/phaseCtrl.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | const uint8_t m = 6; 11 | const uint8_t id = 0; 12 | 13 | #define LIMIT_230V 200 14 | #define LIMIT_0V 15 15 | #define LIMIT_0A 0 16 | #define CYCLE_TIME 500 // 500 ms 17 | #define VOLT_DEB_TIME 60000 // wait 60s until voltage is considered stable 18 | #define AMPS_DEB_TIME 60000 // wait 60s until current is considered stable 19 | #define MAX_WAIT_TIME 300000 // go back to INIT latest after 5 min in WAIT_0AMP --> abort switching 20 | 21 | 22 | enum pcState_enum {INIT, NORMAL_1P, NORMAL_3P, WAIT_0AMP}; 23 | 24 | uint8_t pcState = INIT; 25 | uint8_t pcRequest = 0; 26 | uint16_t currentBackup = 0; 27 | uint32_t lastHandleCall = 0; 28 | uint32_t timerCheck1p = 0; 29 | uint32_t timerCheck3p = 0; 30 | uint32_t timerWait0Amp = 0; 31 | uint32_t timerWait0Amp_Entry = 0; 32 | 33 | 34 | uint8_t pc_checkVoltages() { 35 | uint32_t now = millis(); 36 | if (content[id][6] > LIMIT_230V && 37 | content[id][7] > LIMIT_230V && 38 | content[id][8] > LIMIT_230V && 39 | modbusResultCode[id] == 0) { 40 | if (now - timerCheck3p > VOLT_DEB_TIME) { 41 | return(3); 42 | } 43 | } else { timerCheck3p = now; } 44 | 45 | if (content[id][6] > LIMIT_230V && 46 | content[id][7] < LIMIT_0V && 47 | content[id][8] < LIMIT_0V && 48 | modbusResultCode[id] == 0) { 49 | if (now - timerCheck1p > VOLT_DEB_TIME) { 50 | return(1); 51 | } 52 | } else { timerCheck1p = now; } 53 | return(0); 54 | } 55 | 56 | 57 | bool pc_check0Amp() { 58 | uint32_t now = millis(); 59 | if (content[id][2] <= LIMIT_0A && 60 | content[id][3] <= LIMIT_0A && 61 | content[id][4] <= LIMIT_0A && 62 | modbusResultCode[id] == 0) { 63 | if (now - timerWait0Amp > AMPS_DEB_TIME) { 64 | return(true); 65 | } 66 | } else { timerWait0Amp = now; } 67 | return(false); 68 | } 69 | 70 | 71 | uint8_t getRequestedPhases() { 72 | return(pcRequest); 73 | // read available power from e.g. openWB 74 | /* if (power > 4kW) { 75 | return(3); 76 | } else { 77 | return(1); 78 | } 79 | --> Add debouncing, e.g. 10 minutes ?? 80 | */ 81 | } 82 | 83 | 84 | void httpCall(boolean state) { 85 | WiFiClient client; 86 | HTTPClient http; 87 | String serverPath; 88 | if (state) { 89 | serverPath = F("http://shelly-ip/relay/0?turn=on"); 90 | } else { 91 | serverPath = F("http://shelly-ip/relay/0?turn=off"); 92 | } 93 | log(m, F("url:") + serverPath); 94 | 95 | http.begin(client, serverPath); 96 | int16_t httpResponseCode = http.GET(); 97 | if (httpResponseCode > 0) { 98 | log(m, F("HTTP Response code: ") + String(httpResponseCode) + ", " + http.getString()); 99 | } 100 | else { 101 | log(m, F("Error code: ") + String(httpResponseCode)); 102 | } 103 | http.end(); 104 | } 105 | 106 | 107 | void trans_INIT() { 108 | log(m, F("--> INIT")); 109 | pcState = INIT; 110 | timerCheck1p = millis(); 111 | timerCheck3p = millis(); 112 | } 113 | 114 | 115 | void trans_NORMAL_1P() { 116 | log(m, F("--> NORMAL_1P")); 117 | pcState = NORMAL_1P; 118 | if (currentBackup > 0) { 119 | lm_storeRequest(id, currentBackup); 120 | } 121 | } 122 | 123 | 124 | void trans_NORMAL_3P() { 125 | log(m, F("--> NORMAL_3P")); 126 | pcState = NORMAL_3P; 127 | if (currentBackup > 0) { 128 | lm_storeRequest(id, currentBackup); 129 | } 130 | } 131 | 132 | 133 | void trans_WAIT_0AMP() { 134 | log(m, F("--> WAIT_0AMP")); 135 | pcState = WAIT_0AMP; 136 | timerWait0Amp = millis(); 137 | timerWait0Amp_Entry = millis(); 138 | } 139 | 140 | 141 | void pc_handle() { 142 | if ((millis() - lastHandleCall < CYCLE_TIME) || (cfgCntWb > 1)) { 143 | // avoid unnecessary frequent calls and block the feature, when more than 1 wallbox is connected, due to timing reasons 144 | return; 145 | } 146 | lastHandleCall = millis(); 147 | switch (pcState) { 148 | case INIT: 149 | // Check how many phases are active 150 | switch (pc_checkVoltages()) { 151 | case 1: 152 | trans_NORMAL_1P(); 153 | break; 154 | case 3: 155 | trans_NORMAL_3P(); 156 | break; 157 | default: ; // remain 158 | } 159 | break; 160 | case NORMAL_1P: 161 | if (getRequestedPhases() == 3) { 162 | lm_storeRequest(0, 0); 163 | trans_WAIT_0AMP(); 164 | } 165 | break; 166 | case NORMAL_3P: 167 | if (getRequestedPhases() == 1) { 168 | lm_storeRequest(0, 0); 169 | trans_WAIT_0AMP(); 170 | } 171 | break; 172 | case WAIT_0AMP: 173 | if (pc_check0Amp()) { 174 | if (getRequestedPhases() == 1) { 175 | log(m, F("Call Shelly OFF")); 176 | httpCall(false); 177 | } 178 | if (getRequestedPhases() == 3) { 179 | log(m, F("Call Shelly ON")); 180 | httpCall(true); 181 | } 182 | trans_INIT(); 183 | } 184 | if (millis() - timerWait0Amp_Entry > MAX_WAIT_TIME) { 185 | // abort without switching 186 | trans_INIT(); 187 | } 188 | break; 189 | default: 190 | trans_INIT(); // should not happen 191 | } 192 | } 193 | 194 | 195 | void pc_requestPhase(uint8_t val) { 196 | if (val == 1 || val == 3) { 197 | pcRequest = val; 198 | } 199 | } 200 | 201 | 202 | uint8_t pc_getState() { 203 | return(pcState); 204 | } 205 | 206 | boolean pc_switchInProgress() { 207 | if (lastHandleCall == 0) { 208 | return(false); // as long as feature is disabled, don't block current modifications! 209 | } 210 | // no modification of register 261 allowed, when phase switch is in progress 211 | if (pcState == INIT || pcState == WAIT_0AMP) { 212 | return(true); 213 | } else { 214 | return(false); 215 | } 216 | } 217 | 218 | 219 | void pc_backupRequest(uint16_t val) { 220 | currentBackup = val; 221 | } 222 | -------------------------------------------------------------------------------- /src/phaseCtrl.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #ifndef PHASECTRL_H 4 | #define PHASECTRL_H 5 | 6 | extern void pc_handle(); 7 | extern void pc_requestPhase(uint8_t val); 8 | extern uint8_t pc_getState(); 9 | extern boolean pc_switchInProgress(); 10 | extern void pc_backupRequest(uint16_t val); 11 | 12 | #endif /* PHASECTRL_H */ 13 | -------------------------------------------------------------------------------- /src/powerfox.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | #include 12 | #include 13 | 14 | 15 | const uint8_t m = 9; 16 | 17 | #define MAX_API_LEN 150 // Max accepted length of API response 18 | #define OUTDATED 600000 // 10 min, after this time the value is considered outdated 19 | 20 | static uint32_t lastHandleCall = 0; 21 | static boolean powerfoxActive = false; 22 | 23 | HTTPClient *http; 24 | BearSSL::WiFiClientSecure *httpsClient; 25 | 26 | 27 | void powerfox_setup() { 28 | // check config values 29 | if (strcmp(cfgFoxUser, "") && strcmp(cfgFoxPass, "") && strcmp(cfgFoxDevId, "") && // look for credentials (all need a value) 30 | (cfgCntWb == 1)) { // more wallboxes need too much heap, e.g. for web server 31 | powerfoxActive = true; 32 | } else { 33 | powerfoxActive = false; 34 | } 35 | } 36 | 37 | 38 | void powerfox_loop() { 39 | if ((millis() - lastHandleCall < (uint16_t)cfgPvCycleTime * 1000) || // avoid unnecessary frequent calls 40 | (powerfoxActive == false)) { 41 | return; 42 | } 43 | lastHandleCall = millis(); 44 | 45 | Serial.print(F("Heap before new: ")); Serial.println(ESP.getFreeHeap()); 46 | { 47 | HeapSelectIram ephemeral; 48 | Serial.printf("IRAM free: %6d bytes\r\n", ESP.getFreeHeap()); 49 | http = new HTTPClient(); 50 | httpsClient = new BearSSL::WiFiClientSecure(); 51 | 52 | Serial.print(F("Heap after new : ")); Serial.println(ESP.getFreeHeap()); 53 | 54 | httpsClient->setInsecure(); 55 | httpsClient->setBufferSizes(512,512); // must be between 512 and 16384 56 | http->begin(*httpsClient, F("https://backend.powerfox.energy/api/2.0/my/") + String(cfgFoxDevId) + F("/current")); 57 | 58 | http->setAuthorization(cfgFoxUser, cfgFoxPass); 59 | http->setReuse(false); 60 | uint32_t tm = millis(); 61 | http->GET(); 62 | Serial.print(F("Duration of GET: ")); Serial.println(millis() - tm); 63 | 64 | char response[MAX_API_LEN]; 65 | while (httpsClient->connected() || httpsClient->available()) { 66 | if (httpsClient->available()) { 67 | httpsClient->read((uint8_t*)response, MAX_API_LEN-1); 68 | } 69 | } 70 | Serial.println(response); 71 | Serial.print(F("Heap befor del : ")); Serial.println(ESP.getFreeHeap()); 72 | delete http; 73 | delete httpsClient; 74 | Serial.print(F("Heap after del : ")); Serial.println(ESP.getFreeHeap()); 75 | 76 | StaticJsonDocument<256> doc; 77 | // Parse JSON object 78 | DeserializationError error = deserializeJson(doc, response); 79 | if (error) { 80 | LOG(m, "deserializeJson() failed: %s", error.f_str()) 81 | return; 82 | } 83 | 84 | uint32_t timestamp = 0; 85 | int32_t watt = 0; // power from powerfox API (neg. = 'Einspeisung', pos. = 'Bezug') 86 | timestamp = doc[F("Timestamp")] | 0; 87 | watt = (int) doc[F("Watt")].as(); 88 | LOG(m, "Timestamp=%d, Watt=%d", timestamp, watt) 89 | 90 | if (log_unixTime() - timestamp <= OUTDATED) { 91 | pv_setWatt(watt); 92 | } 93 | } 94 | HeapSelectDram ephemeral; 95 | Serial.printf("DRAM free: %6d bytes\r\n", ESP.getFreeHeap()); 96 | } 97 | -------------------------------------------------------------------------------- /src/powerfox.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #ifndef POWERFOX_H 4 | #define POWERFOX_H 5 | 6 | extern void powerfox_setup(); 7 | extern void powerfox_loop(); 8 | 9 | #endif /* POWERFOX_H */ 10 | -------------------------------------------------------------------------------- /src/pvAlgo.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | 14 | const uint8_t m = 11; 15 | 16 | #define WATT_MIN -100000 // 100kW Feed-in 17 | #define WATT_MAX 100000 // 100kW Consumption 18 | 19 | RTCVars rtc; // used to memorize a few global variables over reset (not for cold boot / power on reset) 20 | 21 | static uint32_t lastCall = 0; 22 | static uint32_t lastActivation = 0; // timestamp of the recent switch-on (#71), to avoid to frequent on/off 23 | static int32_t watt = 0; // power from powerfox API (neg. = 'Einspeisung', pos. = 'Bezug') 24 | static int32_t availPowerPrev = 0; // availPower from previous cycle 25 | static uint8_t pvWbId = 0; // id to be controlled by pv algo 26 | static pvMode_t pvMode = PV_OFF; 27 | static pvMode_t pvModePrev = PV_OFF; 28 | 29 | 30 | void pvAlgo() { 31 | int32_t availPower = 0; 32 | 33 | uint16_t targetCurr = 0; 34 | uint8_t actualCurr = content[pvWbId][53]; 35 | 36 | if (content[pvWbId][1] >= 4 && content[pvWbId][1] <= 7) { // Car is connected 37 | 38 | // available power for charging is 'Einspeisung + akt. Ladeleistung' = -watt + content[0][10] 39 | // negative 'watt' means 'Einspeisung' 40 | availPower = (int16_t)(content[pvWbId][10] - watt - cfgPvOffset); 41 | 42 | // Simple filter (average of this and previous value) 43 | availPower = (availPowerPrev + availPower) / 2; 44 | availPowerPrev = availPower; 45 | 46 | // Calculate the new target current 47 | if (availPower > 0 && cfgPvPhFactor != 0) { 48 | targetCurr = (uint16_t) (availPower / (int32_t) cfgPvPhFactor); 49 | } 50 | LOG(m, "Target current: %.1fA", (float)targetCurr/10.0) 51 | // Hysteresis 52 | if ((actualCurr == 0 && targetCurr < cfgPvLimStart) || 53 | (actualCurr != 0 && targetCurr < cfgPvLimStop)) { 54 | targetCurr = 0; 55 | 56 | // MIN+PV, don't switch off, but ... 57 | if ((pvMode == PV_MIN_PV) || 58 | (cfgPvMinTime != 0 && lastActivation != 0 && (millis() - lastActivation < ((uint32_t)cfgPvMinTime) * 60 * 1000))) { // also if MinTime not elapsed (#71) 59 | targetCurr = content[pvWbId][16] * 10; // ... set minimal current configured in box 60 | } 61 | } 62 | 63 | // Saturation to 0 or 6..16A 64 | if (targetCurr != 0) { 65 | if (targetCurr < CURR_ABS_MIN) { 66 | targetCurr = CURR_ABS_MIN; 67 | } else if (targetCurr > CURR_ABS_MAX) { 68 | targetCurr = CURR_ABS_MAX; 69 | } 70 | } 71 | 72 | if (actualCurr == 0 && targetCurr >= CURR_ABS_MIN) { 73 | // switch on => remember timestamp for cfgPvMinTime (#71) 74 | lastActivation = millis(); 75 | } 76 | } else { 77 | // no car connected 78 | targetCurr = 0; 79 | availPowerPrev = 0; 80 | } 81 | Serial.print("Watt="); Serial.print(watt); Serial.print(", availPower="); Serial.print(availPower); Serial.print(", targetCurr="); Serial.println(targetCurr); 82 | 83 | 84 | FSInfo fs_info; 85 | LittleFS.info(fs_info); 86 | uint32_t time = log_unixTime(); 87 | if ((time < 2085000000UL) && // 26.01.2036 --> sometimes there are large values (e.g. 2085985724) which are wrong -> ignore them 88 | (fs_info.totalBytes - fs_info.usedBytes > 512000)) { // 500kB should remain free 89 | File logFile = LittleFS.open(F("/pv.txt"), "a"); // Write the time and the temperature to the csv file 90 | logFile.print(time); 91 | logFile.print(";"); 92 | logFile.print(watt); 93 | logFile.print(";"); 94 | logFile.print(content[pvWbId][10]); 95 | logFile.print(";"); 96 | logFile.print(actualCurr); 97 | logFile.print(";"); 98 | logFile.println(targetCurr); 99 | logFile.close(); 100 | } 101 | 102 | if ((targetCurr != actualCurr)) { // update the value not too often 103 | lm_storeRequest(pvWbId, targetCurr); 104 | } 105 | } 106 | 107 | 108 | void pv_setup() { 109 | // check config values 110 | if (cfgPvActive == 0) { 111 | pvMode = PV_DISABLED; 112 | } else { 113 | rtc.registerVar((char *)&pvMode); 114 | rtc.registerVar(&availPowerPrev); 115 | rtc.loadFromRTC(); // we load the values from rtc memory back into the registered variables 116 | } 117 | } 118 | 119 | 120 | void pv_loop() { 121 | if ((millis() - lastCall < (uint16_t)cfgPvCycleTime * 1000) || // avoid unnecessary frequent calls 122 | (pvMode == PV_DISABLED)) { 123 | return; 124 | } 125 | lastCall = millis(); 126 | 127 | // Call algo 128 | if (pvMode > PV_OFF) { // PV algo active 129 | pvAlgo(); 130 | } else { 131 | availPowerPrev = 0; 132 | } 133 | if (pvModePrev > PV_OFF && pvMode == PV_OFF) { // Feature from #119 134 | if (cfgPvOffCurrent == 0 || (cfgPvOffCurrent >= CURR_ABS_MIN && cfgPvOffCurrent <= CURR_ABS_MAX)) { 135 | lm_storeRequest(pvWbId, cfgPvOffCurrent); 136 | } 137 | } 138 | pvModePrev = pvMode; 139 | 140 | rtc.saveToRTC(); // memorize over reset 141 | } 142 | 143 | 144 | int32_t pv_getWatt() { 145 | return(watt); 146 | } 147 | 148 | 149 | void pv_setWatt(int32_t val) { 150 | if ((val >= WATT_MIN) && (val <= WATT_MAX)) { 151 | if (cfgPvInvert) { 152 | watt = -val; // possibility to invert the value (#61) 153 | } else { 154 | watt = val; 155 | } 156 | } 157 | } 158 | 159 | 160 | pvMode_t pv_getMode() { 161 | return(pvMode); 162 | } 163 | 164 | 165 | void pv_setMode(pvMode_t val) { 166 | pvMode = val; 167 | rtc.saveToRTC(); // memorize over reset 168 | lastCall = 0; // make sure to call pv_Algo() in the next pv_loop() call 169 | } 170 | 171 | 172 | uint8_t pv_getWbId() { 173 | return(pvWbId); 174 | } 175 | 176 | 177 | void pv_setWbId(uint8_t val) { 178 | pvWbId = val; 179 | rtc.saveToRTC(); // memorize over reset 180 | lastCall = 0; // make sure to call pv_Algo() in the next pv_loop() call 181 | } 182 | 183 | 184 | -------------------------------------------------------------------------------- /src/pvAlgo.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #ifndef PVALGO_H 4 | #define PVALGO_H 5 | 6 | #include 7 | 8 | 9 | typedef enum { 10 | PV_DISABLED = 0, 11 | PV_OFF = 1, 12 | PV_ACTIVE = 2, 13 | PV_MIN_PV = 3 14 | } pvMode_t; 15 | 16 | 17 | extern void pv_setup(); 18 | extern void pv_loop(); 19 | extern int32_t pv_getWatt(); 20 | extern void pv_setWatt(int32_t val); 21 | extern pvMode_t pv_getMode(); 22 | extern void pv_setMode(pvMode_t val); 23 | extern uint8_t pv_getWbId(); 24 | extern void pv_setWbId(uint8_t val); 25 | 26 | #endif /* PVALGO_H */ 27 | -------------------------------------------------------------------------------- /src/pvHttp.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 steff393 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | 12 | const uint8_t m = 13; 13 | 14 | #define MAX_API_LEN 2048 // Max accepted length of API response 15 | 16 | static IPAddress remote; 17 | static bool pvHttpActive = false; 18 | static uint32_t lastHandleCall = 0; 19 | 20 | 21 | void pvHttp_setup() { 22 | if (strcmp(cfgPvHttpIp, "") != 0) { 23 | if (remote.fromString(cfgPvHttpIp) && cfgPvHttpPath[0]=='/') { 24 | pvHttpActive = true; 25 | } 26 | } 27 | } 28 | 29 | 30 | void pvHttp_loop() { 31 | if ((millis() - lastHandleCall < (uint16_t)cfgPvCycleTime * 1000) || // avoid unnecessary frequent calls 32 | (pvHttpActive == false)) { 33 | return; 34 | } 35 | lastHandleCall = millis(); 36 | 37 | String response; 38 | 39 | WiFiClient client; 40 | HTTPClient http; 41 | http.begin(client, cfgPvHttpIp, cfgPvHttpPort, cfgPvHttpPath); 42 | int16_t httpResponseCode = http.GET(); 43 | if (httpResponseCode > 0) { 44 | response = http.getString(); 45 | LOG(m, "HTTP Response code: %d, %s", httpResponseCode, response); 46 | } else { 47 | LOG(m, "Error code: %d", httpResponseCode); 48 | } 49 | http.end(); 50 | 51 | int32_t watt = 0; 52 | 53 | if (strcmp(cfgPvHttpJson, "") == 0) { 54 | watt = response.toInt(); 55 | } else { 56 | // extract the value from a JSON string (only 1st occurence) 57 | // Example: {"Time":"2022-12-10T17:25:46","Main":{"power":-123,"from_grid":441.231,"to_grid":9578.253}} 58 | // cfgPvHttpJson = power\": | |------> 59 | // the slash \ will escape the quote " sign 60 | char *pch = strstr(response.c_str(), cfgPvHttpJson); // search the index of cfgPvHttpJson, then add it's length 61 | if (pch != NULL) { 62 | pch += strlen(cfgPvHttpJson); 63 | watt = atol(pch); 64 | } 65 | } 66 | LOG(m, "Watt=%d", watt) 67 | 68 | int32_t batt = 0; 69 | 70 | if (strcmp(cfgPvHttpJsonBatt, "") == 0) { 71 | // no search-string configured --> do nothing 72 | ; 73 | } else { 74 | char *pch = strstr(response.c_str(), cfgPvHttpJsonBatt); // search the index of cfgPvHttpJsonBatt, then add it's length 75 | if (pch != NULL) { 76 | pch += strlen(cfgPvHttpJsonBatt); 77 | batt = -atol(pch); // battery power (pos. = discharging battery, neg. = charging battery) 78 | watt += batt; // adding means, that watt will become smaller (=> availPower will become higher), when battery is charging 79 | } 80 | LOG(m, "Batt=%d", batt) 81 | } 82 | pv_setWatt(watt); 83 | } 84 | -------------------------------------------------------------------------------- /src/pvHttp.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 steff393 2 | #ifndef PVHTTP_H 3 | #define PVHTTP_H 4 | 5 | 6 | extern void pvHttp_setup(); 7 | extern void pvHttp_loop(); 8 | 9 | #endif /* PVHTTP_H */ 10 | -------------------------------------------------------------------------------- /src/rfid.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | const uint8_t m = 8; 13 | const uint8_t id = 0; 14 | 15 | #define CYCLE_TIME 500 // 500ms 16 | #define INHIBIT_AFTER_DETECTION 3000 // 3s wait time after a card was detected 17 | #define RELEASE_TIME 60000 // lock again, if car was not connected within 60s 18 | #define RFID_CHIP_MAX 10 // different RFID cards 19 | #define RFID_CHIP_LEN 9 // 4 Hex values, e.g. "0ab5c780" + string termination 20 | 21 | char chipID[RFID_CHIP_LEN]; 22 | char chip[RFID_CHIP_MAX][RFID_CHIP_LEN]; 23 | uint32_t rfid_lastCall = 0; 24 | uint32_t rfid_lastDetect = 0; 25 | uint32_t rfid_lastReleased = 0; 26 | boolean rfid_enabled = false; 27 | boolean rfid_released = false; 28 | uint16_t rfid_chgStat_old = 0; 29 | 30 | MFRC522 mfrc522(PIN_SS, PIN_RST); 31 | 32 | 33 | boolean readCards() { 34 | File file = LittleFS.open(F("/rfid.txt"), "r"); 35 | if (!file) { 36 | log(m, F("Disabled (rfid.txt not found)")); 37 | return(false); 38 | } 39 | 40 | uint8_t k = 0; 41 | log(m, F("Cards: "), false); 42 | while (file.available() && k < RFID_CHIP_MAX) { 43 | // read the first characters from each line 44 | strncpy(chip[k], file.readStringUntil('\n').c_str(), RFID_CHIP_LEN - 1); 45 | if (k > 0 ) { log(0, F(", "), false); } 46 | log(0, String(chip[k]), false); 47 | k++; 48 | } 49 | 50 | file.close(); 51 | return(true); 52 | } 53 | 54 | 55 | boolean rfid_plugged(uint16_t chgStat) { 56 | if (chgStat >= 4 && chgStat <= 7) { 57 | return(true); 58 | } else { 59 | return(false); 60 | } 61 | } 62 | 63 | 64 | void rfid_setup() { 65 | if (!readCards()) { 66 | // there is no rfid.txt file => function disabled 67 | rfid_enabled = false; 68 | return; 69 | } 70 | rfid_enabled = true; 71 | 72 | SPI.begin(); 73 | 74 | // Initialize MFRC522 75 | mfrc522.PCD_Init(); 76 | 77 | delay(10); 78 | Serial.println(""); 79 | mfrc522.PCD_DumpVersionToSerial(); // dump some details 80 | } 81 | 82 | 83 | void rfid_loop() { 84 | if ((millis() - rfid_lastCall < CYCLE_TIME) || (rfid_enabled == false) || (millis() - rfid_lastDetect < INHIBIT_AFTER_DETECTION)) { 85 | // avoid unnecessary frequent calls 86 | return; 87 | } 88 | rfid_lastCall = millis(); 89 | 90 | if ((rfid_plugged(rfid_chgStat_old) && !rfid_plugged(content[id][1])) || 91 | (!rfid_plugged(content[id][1]) && (rfid_lastReleased != 0) && (millis() - rfid_lastReleased > RELEASE_TIME))) { 92 | // vehicle unplugged or not plugged within RELEASE_TIME --> RFID chip no longer allowed 93 | rfid_released = false; 94 | rfid_lastReleased = 0; 95 | lm_storeRequest(id, 0); 96 | } 97 | rfid_chgStat_old = content[id][1]; 98 | 99 | 100 | // Check for new card 101 | if (mfrc522.PICC_IsNewCardPresent()) { 102 | // wait a little longer this time to avoid multiple reads 103 | rfid_lastDetect = millis(); 104 | 105 | mfrc522.PICC_ReadCardSerial(); 106 | 107 | // First 4 byte should be sufficient 108 | sprintf(chipID, "%02x%02x%02x%02x", mfrc522.uid.uidByte[0], mfrc522.uid.uidByte[1], mfrc522.uid.uidByte[2], mfrc522.uid.uidByte[3]); 109 | log(m, F("Detected: ") + String(chipID) + F(" ... "), false); 110 | 111 | // Search if this fits to a known chip 112 | uint8_t k = 0; 113 | while (strncasecmp(chipID, chip[k], RFID_CHIP_LEN - 1) != 0 && k < RFID_CHIP_MAX) { 114 | k++; 115 | }; 116 | if (k < RFID_CHIP_MAX) { 117 | log(0, F("found: idx=") + String(k)); 118 | rfid_released = true; 119 | rfid_lastReleased = millis(); 120 | // set current to max value 121 | lm_storeRequest(id, content[id][15]); 122 | } else { 123 | log(0, F("unknown")); 124 | } 125 | } 126 | } 127 | 128 | 129 | boolean rfid_getEnabled() { 130 | return(rfid_enabled); 131 | } 132 | 133 | 134 | boolean rfid_getReleased() { 135 | return(rfid_released); 136 | } 137 | 138 | 139 | char * rfid_getLastID() { 140 | return(chipID); 141 | } 142 | -------------------------------------------------------------------------------- /src/rfid.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #ifndef RFID_H 4 | #define RFID_H 5 | 6 | extern void rfid_setup(); 7 | extern void rfid_loop(); 8 | extern boolean rfid_getEnabled(); 9 | extern boolean rfid_getReleased(); 10 | extern char * rfid_getLastID(); 11 | 12 | #endif /* RFID_H */ 13 | -------------------------------------------------------------------------------- /src/shelly.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 steff393, MIT license 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | const uint8_t m = 12; 14 | 15 | #define MAX_API_LEN 2048 // Max accepted length of API response 16 | 17 | static IPAddress remote; // Address of Modbus Slave device 18 | 19 | static bool shellyActive = false; 20 | static uint32_t lastHandleCall = 0; 21 | 22 | 23 | void shelly_setup() { 24 | if (strcmp(cfgShellyIp, "") != 0) { 25 | if (remote.fromString(cfgShellyIp)) { 26 | shellyActive = true; 27 | } 28 | } 29 | } 30 | 31 | 32 | void shelly_loop() { 33 | if ((millis() - lastHandleCall < (uint16_t)cfgPvCycleTime * 1000) || // avoid unnecessary frequent calls 34 | (shellyActive == false)) { 35 | return; 36 | } 37 | lastHandleCall = millis(); 38 | 39 | WiFiClient client; 40 | HTTPClient http; 41 | char serverPath[35]; 42 | String response; 43 | sprintf(serverPath, "http://%s/status", cfgShellyIp); 44 | LOG(m, "url: %s", serverPath); 45 | 46 | http.begin(client, serverPath); 47 | int16_t httpResponseCode = http.GET(); 48 | if (httpResponseCode > 0) { 49 | response = http.getString(); 50 | LOG(m, "HTTP Response code: %d, %s", httpResponseCode, response); 51 | } 52 | else { 53 | LOG(m, "Error code: %d", httpResponseCode); 54 | } 55 | http.end(); 56 | 57 | StaticJsonDocument doc; 58 | // Parse JSON object 59 | DeserializationError error = deserializeJson(doc, response); 60 | if (error) { 61 | LOG(m, "deserializeJson() failed: %s", error.f_str()) 62 | return; 63 | } 64 | 65 | uint32_t timestamp = 0; 66 | int32_t watt = 0; // (neg. = 'Einspeisung', pos. = 'Bezug') 67 | timestamp = doc[F("unixtime")] | 0; 68 | watt = (int) doc[F("total_power")].as(); 69 | LOG(m, "Timestamp=%d, Watt=%d", timestamp, watt) 70 | 71 | pv_setWatt(watt); 72 | } 73 | -------------------------------------------------------------------------------- /src/shelly.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 steff393, MIT license 2 | #ifndef SHELLY_H 3 | #define SHELLY_H 4 | 5 | 6 | extern void shelly_setup(); 7 | extern void shelly_loop(); 8 | 9 | #endif /* SHELLY_H */ 10 | -------------------------------------------------------------------------------- /src/webServer.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #include 4 | #include 5 | #include 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 | #define WIFI_MANAGER_USE_ASYNC_WEB_SERVER 26 | #include 27 | 28 | #define PFOX_JSON_LEN 256 29 | #define GPIO_JSON_LEN 64 30 | 31 | static const uint8_t m = 3; 32 | 33 | 34 | static AsyncWebServer server(80); 35 | static boolean resetRequested = false; 36 | static boolean resetwifiRequested = false; 37 | 38 | 39 | static void onRequest(AsyncWebServerRequest *request){ 40 | //Handle Unknown Request 41 | request->send_P(404, PSTR("text/plain"), PSTR("Not found")); 42 | } 43 | 44 | 45 | static void onBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total){ 46 | //Handle body 47 | } 48 | 49 | 50 | static void onUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final){ 51 | //Handle upload 52 | } 53 | 54 | 55 | static uint8_t getSignalQuality(int rssi) 56 | { 57 | int quality = 0; 58 | if (rssi <= -100) { 59 | quality = 0; 60 | } else if (rssi >= -50) { 61 | quality = 100; 62 | } else { 63 | quality = 2 * (rssi + 100); 64 | } 65 | return quality; 66 | } 67 | 68 | 69 | void webServer_setup() { 70 | server.on("/heap", HTTP_GET, [](AsyncWebServerRequest *request){ 71 | request->send(200, F("text/plain"), String(ESP.getFreeHeap())); 72 | }); 73 | 74 | server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) { 75 | request->send(LittleFS, F("/web.html"), F("text/html")); 76 | }); 77 | 78 | server.on("/cfg", HTTP_GET, [](AsyncWebServerRequest *request){ 79 | request->send(LittleFS, F("/cfg.json"), F("application/json")); 80 | }); 81 | 82 | server.on("/bootlog", HTTP_GET, [](AsyncWebServerRequest *request){ 83 | request->send(200, F("text/plain"), log_getBuffer()); 84 | }); 85 | 86 | server.on("/bootlog_reset", HTTP_GET, [](AsyncWebServerRequest *request){ 87 | log_freeBuffer(); 88 | request->send(200, F("text/plain"), F("Cleared")); 89 | }); 90 | 91 | server.on("/gpio", HTTP_GET, [](AsyncWebServerRequest *request){ 92 | StaticJsonDocument data; 93 | 94 | if (rfid_getEnabled()) { 95 | // if RFID is enabled, then it's not possible to use the GPIOs for other purposes 96 | request->send(200, F("text/plain"), F("Not possible, RFID active!")); 97 | } else { 98 | // Set GPIO as an OUTPUT 99 | if (request->hasParam(F("on"))) { 100 | digitalWrite(PIN_RST, HIGH); 101 | } 102 | if (request->hasParam(F("off"))) { 103 | digitalWrite(PIN_RST, LOW); 104 | } 105 | data[F("D3")] = digitalRead(PIN_RST); 106 | if (cfgBtnDebounce==0) { 107 | data[F("D7")] = digitalRead(PIN_PV_SWITCH); 108 | } else { 109 | data[F("D7")] = btn_getState() ? 1 : 0; 110 | } 111 | char response[GPIO_JSON_LEN]; 112 | serializeJson(data, response, GPIO_JSON_LEN); 113 | request->send(200, F("application/json"), response); 114 | } 115 | }); 116 | 117 | server.on("/reset", HTTP_GET, [](AsyncWebServerRequest *request){ 118 | request->send(200, F("text/plain"), F("Resetting the ESP8266...")); 119 | resetRequested = true; 120 | }); 121 | 122 | server.on("/resetwifi", HTTP_GET, [](AsyncWebServerRequest *request){ 123 | request->send(200, F("text/plain"), F("WiFi credentials deleted!")); 124 | resetwifiRequested = true; 125 | }); 126 | 127 | server.on("/json", HTTP_GET, [](AsyncWebServerRequest *request) { 128 | uint8_t id = 0; 129 | uint8_t from = 0; // used in 'for loop' 130 | uint8_t to = cfgCntWb; // used in 'for loop' 131 | uint16_t jsonSize = (cfgCntWb+2)/3 * 2048; // always 2048 byte for 3 wallboxes 132 | // modify values 133 | if (request->hasParam(F("id"))) { 134 | id = request->getParam(F("id"))->value().toInt(); 135 | from = id; // if id is provided, then only 136 | to = id+1; // those values are returned (-> save RAM) 137 | jsonSize = 2048; // for one wallbox 2048 are sufficient 138 | } 139 | 140 | if (request->hasParam(F("wdTmOut"))) { 141 | mb_writeReg(id, REG_WD_TIME_OUT, request->getParam(F("wdTmOut"))->value().toInt()); 142 | } 143 | if (request->hasParam(F("standby"))) { 144 | uint16_t val = request->getParam(F("standby"))->value().toInt(); 145 | if (val == 0 || val == 4) { // Heidelberg allows only 0 and 4, others are reserved by manufacturer 146 | if (request->hasParam(F("id"))) { 147 | mb_writeReg(id, REG_STANDBY_CTRL, val); // if id is provided, then use it 148 | } else { 149 | for (uint8_t i = 0; i < WB_CNT; i++) { // ... else write it for all boxes 150 | mb_writeReg(i, REG_STANDBY_CTRL, val); 151 | } 152 | } 153 | } 154 | } 155 | if (request->hasParam(F("remLock"))) { 156 | uint16_t val = request->getParam(F("remLock"))->value().toInt(); 157 | if (val <= 1) { 158 | mb_writeReg(id, REG_REMOTE_LOCK, val); 159 | } 160 | } 161 | if (request->hasParam(F("currLim"))) { 162 | uint16_t val = request->getParam(F("currLim"))->value().toInt(); 163 | if (val == 0 || (val >= CURR_ABS_MIN && val <= CURR_ABS_MAX)) { 164 | lm_storeRequest(id, val); 165 | } 166 | } 167 | if (request->hasParam(F("currFs"))) { 168 | uint16_t val = request->getParam(F("currFs"))->value().toInt(); 169 | if (val == 0 || (val >= CURR_ABS_MIN && val <= CURR_ABS_MAX)) { 170 | mb_writeReg(id, REG_CURR_LIMIT_FS, val); 171 | } 172 | } 173 | if (request->hasParam(F("pvMode"))) { 174 | pvMode_t val = (pvMode_t) request->getParam(F("pvMode"))->value().toInt(); 175 | if (val <= PV_MIN_PV) { 176 | pv_setMode(val); 177 | } 178 | } 179 | if (request->hasParam(F("pvWbId"))) { 180 | uint8_t val = (uint8_t) request->getParam(F("pvWbId"))->value().toInt(); 181 | if (val < cfgCntWb) { 182 | pv_setWbId(val); 183 | } 184 | } 185 | if (request->hasParam(F("pvWatt"))) { 186 | pv_setWatt(request->getParam(F("pvWatt"))->value().toInt()); 187 | } 188 | 189 | DynamicJsonDocument data(jsonSize); 190 | // provide the complete content 191 | data[F("wbec")][F("version")] = cfgWbecVersion; 192 | data[F("wbec")][F("bldDate")] = cfgBuildDate; 193 | data[F("wbec")][F("timeNow")] = log_time(); 194 | for (int i = from; i < to; i++) { 195 | data[F("box")][i][F("busId")] = i+1; 196 | data[F("box")][i][F("version")] = String(content[i][0], HEX); 197 | data[F("box")][i][F("chgStat")] = content[i][1]; 198 | data[F("box")][i][F("currL1")] = content[i][2]; 199 | data[F("box")][i][F("currL2")] = content[i][3]; 200 | data[F("box")][i][F("currL3")] = content[i][4]; 201 | data[F("box")][i][F("pcbTemp")] = content[i][5]; 202 | data[F("box")][i][F("voltL1")] = content[i][6]; 203 | data[F("box")][i][F("voltL2")] = content[i][7]; 204 | data[F("box")][i][F("voltL3")] = content[i][8]; 205 | data[F("box")][i][F("extLock")] = content[i][9]; 206 | data[F("box")][i][F("power")] = content[i][10]; 207 | data[F("box")][i][F("energyP")] = (float)((uint32_t) content[i][11] << 16 | (uint32_t)content[i][12]) / 1000.0; 208 | data[F("box")][i][F("energyI")] = (float)((uint32_t) content[i][13] << 16 | (uint32_t)content[i][14]) / 1000.0; 209 | data[F("box")][i][F("energyC")] = (float)goE_getEnergySincePlugged(i) / 1000.0; 210 | data[F("box")][i][F("currMax")] = content[i][15]; 211 | data[F("box")][i][F("currMin")] = content[i][16]; 212 | char txt[65]; mb_getAscii(i, 17, 32, txt); 213 | data[F("box")][i][F("logStr")] = txt; 214 | data[F("box")][i][F("wdTmOut")] = content[i][49]; 215 | data[F("box")][i][F("standby")] = content[i][50]; 216 | data[F("box")][i][F("remLock")] = content[i][51]; 217 | data[F("box")][i][F("currLim")] = content[i][53]; 218 | data[F("box")][i][F("currFs")] = content[i][54]; 219 | data[F("box")][i][F("lmReq")] = lm_getLastRequest(i); 220 | data[F("box")][i][F("lmLim")] = lm_getWbLimit(i); 221 | data[F("box")][i][F("resCode")] = String(modbusResultCode[i], HEX); 222 | data[F("box")][i][F("failCnt")] = mb_getFailureCnt(i); 223 | } 224 | data[F("modbus")][F("state")][F("lastTm")] = modbusLastTime; 225 | data[F("modbus")][F("state")][F("millis")] = millis(); 226 | data[F("rfid")][F("enabled")] = rfid_getEnabled(); 227 | data[F("rfid")][F("release")] = rfid_getReleased(); 228 | data[F("rfid")][F("lastId")] = rfid_getLastID(); 229 | data[F("pv")][F("mode")] = pv_getMode(); 230 | data[F("pv")][F("watt")] = pv_getWatt(); 231 | data[F("pv")][F("wbId")] = pv_getWbId(); 232 | data[F("wifi")][F("mac")] = WiFi.macAddress(); 233 | int qrssi = WiFi.RSSI(); 234 | data[F("wifi")][F("rssi")] = qrssi; 235 | data[F("wifi")][F("signal")] = getSignalQuality(qrssi); 236 | data[F("wifi")][F("channel")] = WiFi.channel(); 237 | String response; 238 | serializeJson(data, response); 239 | log(m, response); 240 | request->send(200, F("application/json"), response); 241 | }); 242 | 243 | server.on("/status", HTTP_GET, [](AsyncWebServerRequest *request) { 244 | uint8_t id = 0; 245 | boolean fromApp = false; 246 | if (request->hasParam(F("box"))) { 247 | fromApp = true; 248 | id = request->getParam(F("box"))->value().toInt(); 249 | if (id >= WB_CNT) { 250 | id = 0; 251 | } 252 | } 253 | request->send(200, F("application/json"), goE_getStatus(id, fromApp)); 254 | }); 255 | 256 | server.on("/mqtt", HTTP_GET, [](AsyncWebServerRequest *request) { 257 | // set values 258 | uint8_t id = 0; 259 | boolean fromApp = false; 260 | if (request->hasParam(F("box"))) { 261 | fromApp = true; 262 | id = request->getParam(F("box"))->value().toInt(); 263 | if (id >= WB_CNT) { 264 | id = 0; 265 | } 266 | } 267 | if (request->hasParam(F("payload"))) { 268 | log(m, F("/mqtt payload: ") + request->getParam(F("payload"))->value()); 269 | goE_setPayload(request->getParam(F("payload"))->value(), id); 270 | } 271 | // response 272 | request->send(200, F("application/json"), goE_getStatus(id, fromApp)); 273 | }); 274 | 275 | server.on("/phaseCtrl", HTTP_GET, [](AsyncWebServerRequest *request){ 276 | if (request->hasParam(F("ph"))) { 277 | pc_requestPhase(request->getParam(F("ph"))->value().toInt()); 278 | } 279 | request->send(200, F("text/plain"), String(pc_getState())); 280 | }); 281 | 282 | server.on("/pv", HTTP_GET, [](AsyncWebServerRequest *request) { 283 | StaticJsonDocument data; 284 | uint8_t id = 0; 285 | // modify values 286 | if (request->hasParam(F("pvMode"))) { 287 | pvMode_t val = (pvMode_t) request->getParam(F("pvMode"))->value().toInt(); 288 | if (val <= PV_MIN_PV) { 289 | pv_setMode(val); 290 | } 291 | } 292 | if (request->hasParam(F("pvWbId"))) { 293 | uint8_t val = (uint8_t) request->getParam(F("pvWbId"))->value().toInt(); 294 | if (val < cfgCntWb) { 295 | pv_setWbId(val); 296 | } 297 | } 298 | if (request->hasParam(F("pvWatt"))) { 299 | pv_setWatt(request->getParam(F("pvWatt"))->value().toInt()); 300 | } 301 | 302 | data[F("box")][F("chgStat")] = content[id][1]; 303 | data[F("box")][F("power")] = content[id][10]; 304 | data[F("box")][F("currLim")] = content[id][53]; 305 | data[F("box")][F("resCode")] = String(modbusResultCode[id], HEX); 306 | data[F("modbus")][F("millis")] = millis(); 307 | data[F("pv")][F("mode")] = pv_getMode(); 308 | data[F("pv")][F("watt")] = pv_getWatt(); 309 | data[F("pv")][F("wbId")] = pv_getWbId(); 310 | char response[PFOX_JSON_LEN]; 311 | serializeJson(data, response, PFOX_JSON_LEN); 312 | request->send(200, F("application/json"), response); 313 | }); 314 | 315 | server.on("/inverter", HTTP_GET, [](AsyncWebServerRequest *request){ 316 | request->send(200, F("application/json"), inverter_getStatus()); 317 | }); 318 | 319 | 320 | // add the SPIFFSEditor, which can be opened via "/edit" 321 | server.addHandler(new SPIFFSEditor("" ,"" ,LittleFS));//http_username,http_password)); 322 | 323 | server.serveStatic("/", LittleFS, "/"); 324 | 325 | // Catch-All Handlers 326 | // Any request that can not find a Handler that canHandle it 327 | // ends in the callbacks below. 328 | server.onNotFound(onRequest); 329 | server.onFileUpload(onUpload); 330 | server.onRequestBody(onBody); 331 | 332 | AsyncElegantOTA.begin(&server); // Start ElegantOTA 333 | 334 | server.begin(); 335 | } 336 | 337 | void webServer_loop() { 338 | if (resetRequested || 339 | ((cfgKnockOutTimer >= 20) && (millis() > ((uint32_t)cfgKnockOutTimer) * 60 * 1000))) { 340 | ESP.restart(); 341 | } 342 | if (resetwifiRequested) { 343 | WiFi.disconnect(true); 344 | ESP.eraseConfig(); 345 | ESP.restart(); 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /src/webServer.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #ifndef WEBSERVER_H 4 | #define WEBSERVER_H 5 | 6 | extern void webServer_setup(); 7 | extern void webServer_loop(); 8 | 9 | #endif /* WEBSERVER_H */ 10 | -------------------------------------------------------------------------------- /src/webSocket.cpp: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | 15 | #define CYCLE_TIME 1000 16 | #define JSON_LEN 256 17 | 18 | static const uint8_t m = 10; 19 | 20 | static WebSocketsServer webSocket = WebSocketsServer(81); 21 | static uint32_t lastCall = 0; 22 | static uint8_t id = 0; 23 | 24 | 25 | static void webSocketEvent(byte num, WStype_t type, uint8_t * payload, size_t length) { 26 | if(type == WStype_TEXT) { 27 | LOG(m, "Payload %s", (char *)payload) 28 | if (length >= 9 && !strncmp((char *)payload, "currLim=", 8)) { 29 | char * pch; 30 | pch = strtok((char *)payload, "="); 31 | pch = strtok(NULL, "="); 32 | lm_storeRequest(id, atoi(pch)); 33 | } else if (length >= 4 && !strncmp((char *)payload, "id=", 3)) { 34 | char * pch; 35 | pch = strtok((char *)payload, "="); 36 | pch = strtok(NULL, "="); 37 | if (atoi(pch) < cfgCntWb) { 38 | id = atoi(pch); 39 | } 40 | } else if (strstr_P((char *)payload, PSTR("PV_OFF"))) { 41 | pv_setMode(PV_OFF); 42 | } else if (strstr_P((char *)payload, PSTR("PV_ACTIVE"))) { 43 | pv_setMode(PV_ACTIVE); 44 | } else if (strstr_P((char *)payload, PSTR("PV_MIN_PV"))) { 45 | pv_setMode(PV_MIN_PV); 46 | } 47 | } 48 | } 49 | 50 | 51 | void webSocket_setup() { 52 | // start the WebSocket connection 53 | webSocket.begin(); 54 | webSocket.onEvent(webSocketEvent); 55 | } 56 | 57 | 58 | void webSocket_loop() { 59 | webSocket.loop(); 60 | if ((millis() - lastCall < CYCLE_TIME)) { 61 | return; 62 | } 63 | lastCall = millis(); 64 | 65 | StaticJsonDocument data; 66 | data[F("id")] = id; 67 | data[F("chgStat")] = content[id][1]; 68 | data[F("power")] = content[id][10]; 69 | data[F("energyI")] = (float)((uint32_t) content[id][13] << 16 | (uint32_t)content[id][14]) / 1000.0; 70 | data[F("energyC")] = (float)goE_getEnergySincePlugged(id) / 1000.0; 71 | data[F("currLim")] = (float)content[id][53]/10.0; 72 | data[F("failCnt")] = mb_getFailureCnt(id); 73 | data[F("watt")] = pv_getWatt(); 74 | data[F("pvMode")] = pv_getMode(); 75 | data[F("timeNow")] = log_time(); 76 | char response[JSON_LEN]; 77 | serializeJson(data, response, JSON_LEN); 78 | webSocket.broadcastTXT(response); 79 | } 80 | -------------------------------------------------------------------------------- /src/webSocket.h: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 steff393, MIT license 2 | 3 | #ifndef WEBSOCKET_H 4 | #define WEBSOCKET_H 5 | 6 | extern void webSocket_setup(); 7 | extern void webSocket_loop(); 8 | 9 | #endif /* WEBSOCKET_H */ 10 | --------------------------------------------------------------------------------