├── .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 | 
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 | [](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 |
24 | Log
25 | Zeit
26 | Config
27 | Json
28 | Edit
29 | Update
30 | ×
31 |
32 | wbec
33 | Heidelberg Wallbox Energy Control
34 |
35 |
36 |
37 |
38 |
41 |
42 | Speichern
43 | Reset wbec
44 | Refresh
45 |
46 | (!) = Wert sollte in der Regel nicht verändert werden!
47 | Es findet keine Überprüfung auf plausible Werte statt!
48 |
49 |
50 |
Löschen der WLAN-Daten
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 |
24 | Log
25 | Zeit
26 | Config
27 | Json
28 | Edit
29 | Update
30 | ×
31 |
32 | wbec
33 | Heidelberg Wallbox Energy Control
34 |
35 |
36 |
37 |
38 |
41 |
42 |
43 |
44 |
Start
45 |
Ende
46 |
Geladene kWh
47 |
Ladedauer hh:mm
48 |
Wallbox
49 |
50 |
51 |
Export CSV
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 |
17 |
18 |
<
19 |
>
20 |
-
21 |
+
22 |
Reset
23 |
Refresh
24 |
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 |
28 | Log
29 | Zeit
30 | Config
31 | Json
32 | Edit
33 | Update
34 | ×
35 |
36 | wbec
37 | Heidelberg Wallbox Energy Control
38 |
39 |
40 |
41 |
42 |
43 | Wallbox 1
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
55 |
56 |
57 |
0h
58 |
65 |
24h
66 |
67 |
68 |
69 |
70 |
73 |
74 |
75 | 0A
76 |
77 | 16A
78 |
79 |
80 |
81 |
82 | Zeit nur einmalig
83 |
84 |
85 |
86 |
87 |
90 |
91 |
92 | 0A
93 |
94 | 16A
95 |
96 |
97 |
98 |
99 | nur Aus -Schalten
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
110 |
115 |
116 |
117 | Energielimit bei jeder An-Phase neustarten
118 |
119 |
120 |
121 |
123 |
124 | Laden
125 | Speichern
126 | Deaktivieren
127 |
128 |
129 |
130 |
131 |
Geschätztes Ergebnis der An-Phase
132 |
133 |
134 |
135 |
138 |
139 | ca. - kW
140 |
141 |
142 |
143 |
144 |
147 |
148 | + ca. - %
149 |
150 |
151 |
152 |
153 |
156 |
157 | + ca. - kWh (- €)
158 |
159 |
160 |
161 |
162 |
165 |
166 | + ca. - km
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
178 |
179 | - kWh
180 |
181 |
182 |
183 |
184 |
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 |
30 | Log
31 | Zeit
32 | Config
33 | Json
34 | Edit
35 | Update
36 | ×
37 |
38 | wbec
39 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | Wallbox 1
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
62 |
63 |
max - A
64 |
65 | 0A
66 |
67 | 16A
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
78 |
79 |
80 | Aus
81 |
82 |
83 | PV
84 |
85 |
86 | Min+PV
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
98 |
99 | -
100 |
101 |
102 |
103 |
104 |
107 |
108 | -
109 |
110 |
111 |
112 |
113 |
116 |
117 | - kWh
118 |
119 |
120 |
121 |
122 |
125 |
126 | - kWh
127 |
128 |
129 |
130 |
131 |
134 |
135 | - kW
136 |
137 |
138 |
139 |
140 |
143 |
144 | - kW
145 |
146 |
147 |
148 |
149 |
150 |
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 = "
";
41 | const char WIFI_MANAGER_HTTP_FORM_START[] 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 |
--------------------------------------------------------------------------------