Skip to content

Commit 3bc00d6

Browse files
authored
feat: Web UI modernization — all 8 increments (#51-#58)
feat: Web UI modernization — all 8 increments (#51-#58)
2 parents 06dc654 + a22f96b commit 3bc00d6

File tree

7 files changed

+4458
-3115
lines changed

7 files changed

+4458
-3115
lines changed

SmartEVSE-3/data/app.js

Lines changed: 1192 additions & 0 deletions
Large diffs are not rendered by default.

SmartEVSE-3/data/index.html

Lines changed: 147 additions & 1008 deletions
Large diffs are not rendered by default.

SmartEVSE-3/data/style.css

Lines changed: 513 additions & 0 deletions
Large diffs are not rendered by default.

SmartEVSE-3/data/styling.css

Lines changed: 0 additions & 137 deletions
This file was deleted.

SmartEVSE-3/src/http_handlers.cpp

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ extern uint8_t DelayedRepeat;
6767
extern uint8_t RFIDReader;
6868
extern uint16_t maxTemp;
6969
extern uint8_t ScheduleState[];
70+
extern uint8_t BalancedState[]; // PLAN-07: per-node state for node overview
7071
extern uint16_t RotationTimer;
7172
extern int8_t TempEVSE;
7273
extern const char StrRFIDReader[7][10];
@@ -134,7 +135,7 @@ bool handle_URI(struct mg_connection *c, struct mg_http_message *hm, webServerR
134135

135136
boolean evConnected = pilot != PILOT_12V; //when access bit = 1, p.ex. in OFF mode, the STATEs are no longer updated
136137

137-
DynamicJsonDocument doc(3200); // https://arduinojson.org/v6/assistant/
138+
DynamicJsonDocument doc(3700); // https://arduinojson.org/v6/assistant/ (3200 + nodes array)
138139
doc["version"] = String(VERSION);
139140
doc["serialnr"] = serialnr;
140141
doc["mode"] = mode;
@@ -213,6 +214,14 @@ bool handle_URI(struct mg_connection *c, struct mg_http_message *hm, webServerR
213214
doc["schedule"]["state"][i] = (ScheduleState[i] <= 2) ? StrSchedState[ScheduleState[i]] : "N/A";
214215
}
215216
doc["schedule"]["rotation_timer"] = RotationTimer;
217+
// BEGIN PLAN-07: Per-node load balancing data
218+
static const char *StrBalState[] = {"Idle", "Request", "Charging"};
219+
for (int i = 0; i < NR_EVSES; i++) {
220+
doc["nodes"][i]["current"] = Balanced[i];
221+
doc["nodes"][i]["state"] = (BalancedState[i] <= 2) ? StrBalState[BalancedState[i]] : "N/A";
222+
doc["nodes"][i]["sched"] = (ScheduleState[i] <= 2) ? StrSchedState[ScheduleState[i]] : "N/A";
223+
}
224+
// END PLAN-07
216225
}
217226
#if MODEM
218227
doc["settings"]["required_evccid"] = RequiredEVCCID;

SmartEVSE-3/src/network_common.cpp

Lines changed: 271 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
#endif
2424

2525
#include "firmware_manager.h"
26+
#include "meter.h"
2627

2728
#if SMARTEVSE_VERSION >=30
2829
#include "OneWire.h"
@@ -86,6 +87,225 @@ static bool isTrackedLcdWsConnection(const mg_connection *connection) {
8687
return false;
8788
}
8889

90+
// BEGIN PLAN-07: WebSocket data channel
91+
#define WS_DATA_MAX_CONNECTIONS 4
92+
#define WS_DATA_INTERVAL_MS 500
93+
#define WS_DATA_SYNC_TICKS 60 // 60 * 500ms = 30 seconds
94+
95+
// Externs for globals accessed by the data channel
96+
extern uint8_t pilot;
97+
extern uint32_t CurrentPWM;
98+
extern uint16_t OverrideCurrent;
99+
extern int8_t TempEVSE;
100+
extern uint16_t maxTemp;
101+
extern int16_t homeBatteryCurrent;
102+
extern time_t homeBatteryLastUpdate;
103+
extern int phasesLastUpdate;
104+
extern uint8_t ErrorFlags;
105+
extern uint16_t MinCurrent;
106+
extern uint16_t MaxCurrent;
107+
108+
std::vector<mg_connection*> wsDataConnections;
109+
static mg_timer *wsDataTimer = nullptr;
110+
static int wsDataSyncCounter = 0;
111+
112+
// Previous state for differential updates
113+
struct WsDataPrev {
114+
uint8_t mode_id;
115+
uint8_t state_id;
116+
uint8_t error_flags;
117+
uint16_t charge_current;
118+
int8_t temp;
119+
uint32_t pwm;
120+
uint16_t solar_stop_timer;
121+
bool car_connected;
122+
uint8_t loadbl;
123+
int32_t phase[3];
124+
int32_t evmeter_irms[3];
125+
int32_t evmeter_power;
126+
int32_t evmeter_charged_wh;
127+
int16_t battery_current;
128+
uint16_t override_current;
129+
bool initialized;
130+
};
131+
static WsDataPrev wsPrev = {};
132+
133+
static uint8_t wsGetModeId() {
134+
if (AccessStatus == OFF) return 0;
135+
if (AccessStatus == PAUSE) return 4;
136+
switch (Mode) {
137+
case MODE_NORMAL: return 1;
138+
case MODE_SOLAR: return 2;
139+
case MODE_SMART: return 3;
140+
default: return 255;
141+
}
142+
}
143+
144+
static void stopWsDataTimer(struct mg_mgr *manager) {
145+
if (wsDataTimer != nullptr && manager != nullptr) {
146+
mg_timer_free(&manager->timers, wsDataTimer);
147+
wsDataTimer = nullptr;
148+
_LOG_V("Stopped WS data timer\n");
149+
}
150+
}
151+
152+
static bool isTrackedDataWsConnection(const mg_connection *connection) {
153+
for (const auto *tracked : wsDataConnections) {
154+
if (tracked == connection) return true;
155+
}
156+
return false;
157+
}
158+
159+
// Build full state JSON for sync messages
160+
static void wsBuildFullState(DynamicJsonDocument &doc) {
161+
doc["type"] = "sync";
162+
JsonObject d = doc.createNestedObject("d");
163+
uint8_t mid = wsGetModeId();
164+
d["mode_id"] = mid;
165+
d["state_id"] = State;
166+
d["error_flags"] = ErrorFlags;
167+
d["charge_current"] = Balanced[0];
168+
d["temp"] = TempEVSE;
169+
d["temp_max"] = maxTemp;
170+
d["pwm"] = CurrentPWM;
171+
d["solar_stop_timer"] = SolarStopTimer;
172+
d["car_connected"] = (pilot != PILOT_12V);
173+
d["loadbl"] = LoadBl;
174+
d["override_current"] = OverrideCurrent;
175+
d["current_min"] = MinCurrent;
176+
d["current_max"] = MaxCurrent;
177+
d["phase_L1"] = MainsMeter.Irms[0];
178+
d["phase_L2"] = MainsMeter.Irms[1];
179+
d["phase_L3"] = MainsMeter.Irms[2];
180+
d["evmeter_L1"] = EVMeter.Irms[0];
181+
d["evmeter_L2"] = EVMeter.Irms[1];
182+
d["evmeter_L3"] = EVMeter.Irms[2];
183+
d["evmeter_power"] = EVMeter.PowerMeasured;
184+
d["evmeter_charged_wh"] = EVMeter.EnergyCharged;
185+
d["battery_current"] = homeBatteryCurrent;
186+
d["battery_last_update"] = homeBatteryLastUpdate;
187+
d["phases_last_update"] = phasesLastUpdate;
188+
}
189+
190+
// Timer function - sends state updates to all connected websocket clients
191+
static void ws_data_timer_fn(void *arg) {
192+
struct mg_mgr *mgr_ptr = (struct mg_mgr *) arg;
193+
194+
if (wsDataConnections.empty()) {
195+
stopWsDataTimer(mgr_ptr);
196+
return;
197+
}
198+
199+
// Remove stale connections
200+
for (size_t i = wsDataConnections.size(); i > 0; --i) {
201+
const size_t idx = i - 1;
202+
mg_connection *c = wsDataConnections[idx];
203+
if (c == nullptr || c->is_closing) {
204+
wsDataConnections.erase(wsDataConnections.begin() + idx);
205+
}
206+
}
207+
if (wsDataConnections.empty()) {
208+
stopWsDataTimer(mgr_ptr);
209+
return;
210+
}
211+
212+
wsDataSyncCounter++;
213+
bool fullSync = (wsDataSyncCounter >= WS_DATA_SYNC_TICKS);
214+
if (fullSync) wsDataSyncCounter = 0;
215+
216+
// Build JSON
217+
DynamicJsonDocument doc(fullSync ? 640 : 384);
218+
219+
if (fullSync) {
220+
wsBuildFullState(doc);
221+
} else {
222+
// Differential update - only send changed fields
223+
uint8_t mid = wsGetModeId();
224+
bool changed = false;
225+
JsonObject d = doc.createNestedObject("d");
226+
227+
#define WS_DIFF(field, val) do { \
228+
if (!wsPrev.initialized || wsPrev.field != (val)) { \
229+
d[#field] = (val); wsPrev.field = (val); changed = true; \
230+
} \
231+
} while(0)
232+
233+
WS_DIFF(mode_id, mid);
234+
WS_DIFF(state_id, State);
235+
WS_DIFF(error_flags, ErrorFlags);
236+
WS_DIFF(charge_current, Balanced[0]);
237+
WS_DIFF(temp, TempEVSE);
238+
WS_DIFF(pwm, CurrentPWM);
239+
WS_DIFF(solar_stop_timer, SolarStopTimer);
240+
241+
bool connected = (pilot != PILOT_12V);
242+
if (!wsPrev.initialized || wsPrev.car_connected != connected) {
243+
d["car_connected"] = connected; wsPrev.car_connected = connected; changed = true;
244+
}
245+
246+
WS_DIFF(loadbl, LoadBl);
247+
WS_DIFF(override_current, OverrideCurrent);
248+
249+
// Phase currents
250+
for (int i = 0; i < 3; i++) {
251+
if (!wsPrev.initialized || wsPrev.phase[i] != MainsMeter.Irms[i]) {
252+
const char *keys[] = {"phase_L1", "phase_L2", "phase_L3"};
253+
d[keys[i]] = MainsMeter.Irms[i];
254+
wsPrev.phase[i] = MainsMeter.Irms[i];
255+
changed = true;
256+
}
257+
}
258+
for (int i = 0; i < 3; i++) {
259+
if (!wsPrev.initialized || wsPrev.evmeter_irms[i] != EVMeter.Irms[i]) {
260+
const char *keys[] = {"evmeter_L1", "evmeter_L2", "evmeter_L3"};
261+
d[keys[i]] = EVMeter.Irms[i];
262+
wsPrev.evmeter_irms[i] = EVMeter.Irms[i];
263+
changed = true;
264+
}
265+
}
266+
267+
WS_DIFF(evmeter_power, EVMeter.PowerMeasured);
268+
WS_DIFF(evmeter_charged_wh, EVMeter.EnergyCharged);
269+
WS_DIFF(battery_current, homeBatteryCurrent);
270+
271+
#undef WS_DIFF
272+
273+
wsPrev.initialized = true;
274+
275+
if (!changed) return; // Nothing changed, skip sending
276+
doc["type"] = "state";
277+
}
278+
279+
String json;
280+
serializeJson(doc, json);
281+
for (auto *c : wsDataConnections) {
282+
mg_ws_send(c, json.c_str(), json.length(), WEBSOCKET_OP_TEXT);
283+
}
284+
285+
// After full sync, update prev state
286+
if (fullSync) {
287+
wsPrev.mode_id = wsGetModeId();
288+
wsPrev.state_id = State;
289+
wsPrev.error_flags = ErrorFlags;
290+
wsPrev.charge_current = Balanced[0];
291+
wsPrev.temp = TempEVSE;
292+
wsPrev.pwm = CurrentPWM;
293+
wsPrev.solar_stop_timer = SolarStopTimer;
294+
wsPrev.car_connected = (pilot != PILOT_12V);
295+
wsPrev.loadbl = LoadBl;
296+
wsPrev.override_current = OverrideCurrent;
297+
for (int i = 0; i < 3; i++) {
298+
wsPrev.phase[i] = MainsMeter.Irms[i];
299+
wsPrev.evmeter_irms[i] = EVMeter.Irms[i];
300+
}
301+
wsPrev.evmeter_power = EVMeter.PowerMeasured;
302+
wsPrev.evmeter_charged_wh = EVMeter.EnergyCharged;
303+
wsPrev.battery_current = homeBatteryCurrent;
304+
wsPrev.initialized = true;
305+
}
306+
}
307+
// END PLAN-07: WebSocket data channel
308+
89309
static void sendWsError(struct mg_connection *c, const char *reason) {
90310
DynamicJsonDocument response(96);
91311
response["error"] = reason;
@@ -1071,6 +1291,16 @@ static void fn_http_server(struct mg_connection *c, int ev, void *ev_data) {
10711291
}
10721292
}
10731293
// END PLAN-06
1294+
// BEGIN PLAN-07: Clean up data WS connections on close
1295+
for (auto it = wsDataConnections.begin(); it != wsDataConnections.end(); ++it) {
1296+
if (*it == c) {
1297+
wsDataConnections.erase(it);
1298+
_LOG_V("Removed websocket data connection, remaining: %d\n", wsDataConnections.size());
1299+
if (wsDataConnections.empty()) stopWsDataTimer(c->mgr);
1300+
break;
1301+
}
1302+
}
1303+
// END PLAN-07
10741304
} else if (ev == MG_EV_WS_OPEN) {
10751305
// Websocket connection opened - check if it's for /ws/lcd endpoint
10761306
struct mg_http_message *hm = (struct mg_http_message *) ev_data;
@@ -1090,16 +1320,44 @@ static void fn_http_server(struct mg_connection *c, int ev, void *ev_data) {
10901320
_LOG_V("New diag WS connection, total: %d\n", wsDiagConnections.size());
10911321
}
10921322
// END PLAN-06
1323+
// BEGIN PLAN-07: Track data WS connections
1324+
if (mg_match(hm->uri, mg_str("/ws/data"), NULL)) {
1325+
if ((int)wsDataConnections.size() >= WS_DATA_MAX_CONNECTIONS) {
1326+
_LOG_W("Max data WS connections reached (%d), rejecting\n", WS_DATA_MAX_CONNECTIONS);
1327+
c->is_closing = 1;
1328+
} else {
1329+
wsDataConnections.push_back(c);
1330+
_LOG_V("New websocket data connection, total: %d\n", wsDataConnections.size());
1331+
1332+
// Start timer if this is the first connection
1333+
if (wsDataConnections.size() == 1 && wsDataTimer == nullptr) {
1334+
wsDataSyncCounter = 0;
1335+
wsPrev.initialized = false;
1336+
wsDataTimer = mg_timer_add(&mgr, WS_DATA_INTERVAL_MS, MG_TIMER_REPEAT | MG_TIMER_RUN_NOW, ws_data_timer_fn, &mgr);
1337+
_LOG_V("Started WS data timer\n");
1338+
}
1339+
1340+
// Send immediate full sync to newly connected client
1341+
DynamicJsonDocument doc(640);
1342+
wsBuildFullState(doc);
1343+
String json;
1344+
serializeJson(doc, json);
1345+
mg_ws_send(c, json.c_str(), json.length(), WEBSOCKET_OP_TEXT);
1346+
}
1347+
}
1348+
// END PLAN-07
10931349
} else if (ev == MG_EV_WS_MSG) {
10941350
// Websocket message received - handle button commands
10951351
struct mg_ws_message *wm = (struct mg_ws_message *) ev_data;
1096-
if (!isTrackedLcdWsConnection(c)) return;
1097-
1098-
// Check if this is a text message (button commands are JSON text)
1099-
if ((wm->flags & 0x0f) == WEBSOCKET_OP_TEXT) {
1100-
handleButtonCommand(c, (const char*)wm->data.buf, wm->data.len);
1352+
if (isTrackedLcdWsConnection(c)) {
1353+
// Check if this is a text message (button commands are JSON text)
1354+
if ((wm->flags & 0x0f) == WEBSOCKET_OP_TEXT) {
1355+
handleButtonCommand(c, (const char*)wm->data.buf, wm->data.len);
1356+
}
1357+
// Binary messages are ignored (only server sends binary BMP images)
11011358
}
1102-
// Binary messages are ignored (only server sends binary BMP images)
1359+
// BEGIN PLAN-07: Data WS messages (subscribe, etc.) - acknowledged silently
1360+
// END PLAN-07
11031361
} else if (ev == MG_EV_HTTP_MSG) { // New HTTP request received
11041362
struct mg_http_message *hm = (struct mg_http_message *) ev_data; // Parsed HTTP request
11051363

@@ -1115,6 +1373,13 @@ static void fn_http_server(struct mg_connection *c, int ev, void *ev_data) {
11151373
}
11161374
// END PLAN-06
11171375

1376+
// BEGIN PLAN-07: WebSocket upgrade for data channel
1377+
if (mg_match(hm->uri, mg_str("/ws/data"), NULL)) {
1378+
mg_ws_upgrade(c, hm, NULL);
1379+
return;
1380+
}
1381+
// END PLAN-07
1382+
11181383
static webServerRequest requestObj; // Static to avoid heap allocation on every request
11191384
webServerRequest* request = &requestObj;
11201385
request->setMessage(hm);

0 commit comments

Comments
 (0)