Skip to content

Commit 7fda452

Browse files
committed
+feat: consumption stats bar chart overlay with y/x axis
- Add cons_stats_Wh[7][24] EMA tracking (α=0.1) per weekday×hour in Logic.h - Persist stats to flash hourly (no version bump) via saveConfig(false) - Reset stats on location change - Use per-hour stats in predictBatteryCapacityState() with fallback to cons_W_norm - Expose cons_stats nested array in /api/data JSON response - Frontend: bar chart overlay triggered from Consumption row (Stats link) - 24 CSS flex bars normalized to global max across all 7 days - Y-axis scale (max/mid/0 Wh), X-axis hour labels (0,6,12,18) below bars - Day selector buttons (Sun–Sat), current hour highlighted green - Dark mode support
1 parent 39588b6 commit 7fda452

10 files changed

Lines changed: 330 additions & 20 deletions

File tree

nginx/app.css

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ button {
115115
}
116116
}
117117

118+
.fg-s,
118119
.fg {
119120
display: grid;
120121
align-items: center;
@@ -134,6 +135,10 @@ button {
134135
}
135136
}
136137

138+
.fg-s {
139+
grid-template-columns: 60% 40%;
140+
}
141+
137142
span.switch {
138143
border-style: solid;
139144
border-width: 1px;
@@ -312,4 +317,127 @@ button.loading .spinner {
312317
100% {
313318
transform: translateY(-50%) rotate(360deg);
314319
}
320+
}
321+
322+
.day-selector {
323+
display: flex;
324+
gap: 4px;
325+
padding: 4px;
326+
flex-wrap: wrap;
327+
}
328+
329+
.day-btn {
330+
flex: 1;
331+
padding: 4px 2px;
332+
font-size: small;
333+
}
334+
335+
.day-btn.active {
336+
background: #007BFF;
337+
color: white;
338+
}
339+
340+
.chart-wrapper {
341+
display: flex;
342+
flex-direction: column;
343+
}
344+
345+
.bars-row {
346+
display: flex;
347+
}
348+
349+
.x-axis-row {
350+
display: flex;
351+
}
352+
353+
.x-axis-spacer {
354+
flex-shrink: 0;
355+
width: 32px; /* matches y-axis min-width + padding + border + margin */
356+
}
357+
358+
.stats-x-axis {
359+
flex: 1;
360+
min-width: 0;
361+
display: flex;
362+
gap: 1px;
363+
padding: 0 4px;
364+
}
365+
366+
.stats-x-axis > .bar-lbl {
367+
flex: 1;
368+
}
369+
370+
.stats-y-axis {
371+
display: flex;
372+
flex-direction: column;
373+
justify-content: space-between;
374+
font-size: 0.55rem;
375+
color: #888;
376+
text-align: right;
377+
min-width: 28px;
378+
padding: 0 2px 4px 0;
379+
border-right: 1px solid #ccc;
380+
margin-right: 2px;
381+
}
382+
383+
.stats-bars {
384+
flex: 1;
385+
min-width: 0;
386+
display: flex;
387+
align-items: flex-end;
388+
height: 120px;
389+
gap: 1px;
390+
padding: 4px;
391+
border-top: 1px solid #ccc;
392+
}
393+
394+
.bar-col {
395+
flex: 1;
396+
display: flex;
397+
flex-direction: column;
398+
align-items: center;
399+
height: 100%;
400+
justify-content: flex-end;
401+
}
402+
403+
.bar {
404+
width: 100%;
405+
background: #007BFF;
406+
min-height: 1px;
407+
border-radius: 2px 2px 0 0;
408+
transition: height 0.3s;
409+
}
410+
411+
.bar-now {
412+
background: #28a746;
413+
}
414+
415+
.bar-lbl {
416+
font-size: 0.6rem;
417+
color: #888;
418+
text-align: center;
419+
line-height: 1.2;
420+
}
421+
422+
@media (prefers-color-scheme: dark) {
423+
.bar {
424+
background: #4a9eff;
425+
}
426+
427+
.bar-now {
428+
background: #6f6;
429+
}
430+
431+
.bar-lbl {
432+
color: #aaa;
433+
}
434+
435+
.day-btn.active {
436+
background: #4a9eff;
437+
}
438+
439+
.stats-y-axis {
440+
color: #aaa;
441+
border-right-color: #555;
442+
}
315443
}

nginx/app.js

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11

22
var systemDataVersion = -1;
3+
var consStats = null;
34

45
async function fetchAPI(uri, cb) {
56

@@ -46,6 +47,7 @@ function fetchData() {
4647
if (response !== undefined && response.ok && json !== undefined) {
4748
systemDataVersion = json["version"];
4849
toggleLoadPowerFields(false);
50+
if (json["cons_stats"]) consStats = json["cons_stats"];
4951
}
5052
});
5153
}
@@ -124,9 +126,50 @@ function showLog() {
124126
}
125127
}
126128

127-
function closeOverlay() {
128-
const overlay = document.getElementById("logOverlay");
129-
overlay !== null && overlay.classList.remove("active");
129+
function closeOverlay(btn) {
130+
btn.closest('.overlay').classList.remove('active');
131+
}
132+
133+
function showConsStats() {
134+
document.getElementById("statsOverlay").classList.add("active");
135+
renderConsStats(new Date().getDay());
136+
}
137+
138+
function renderConsStats(dayIdx) {
139+
document.querySelectorAll('#statsOverlay .day-btn').forEach((btn, i) => {
140+
btn.classList.toggle('active', i === dayIdx);
141+
});
142+
if (!consStats) return;
143+
const data = consStats[dayIdx];
144+
const maxVal = Math.max(...consStats.flat(), 1);
145+
const yAxis = document.getElementById("statsYAxis");
146+
if (yAxis) {
147+
yAxis.innerHTML = "";
148+
[maxVal + " Wh", Math.round(maxVal / 2) + " Wh", "0 Wh"].forEach(v => {
149+
const s = document.createElement("span"); s.textContent = v; yAxis.appendChild(s);
150+
});
151+
}
152+
const bars = document.getElementById("statsBars");
153+
bars.innerHTML = "";
154+
data.forEach((v, h) => {
155+
const col = document.createElement("div");
156+
col.className = "bar-col";
157+
const bar = document.createElement("div");
158+
bar.className = "bar" + (h === new Date().getHours() && dayIdx === new Date().getDay() ? " bar-now" : "");
159+
bar.style.height = Math.round(v / maxVal * 100) + "%";
160+
bar.title = h + ":00 — " + v + " W";
161+
col.appendChild(bar);
162+
bars.appendChild(col);
163+
});
164+
const xAxis = document.getElementById("statsXAxis");
165+
if (xAxis && !xAxis.childElementCount) {
166+
for (let h = 0; h < 24; h++) {
167+
const lbl = document.createElement("div");
168+
lbl.className = "bar-lbl";
169+
lbl.textContent = (h % 6 === 0) ? h : "";
170+
xAxis.appendChild(lbl);
171+
}
172+
}
130173
}
131174

132175
function sendUpdate(payload){

nginx/index.html

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,35 +19,41 @@ <h1>Sonnen SmartSwitch <span class="release" name="release_tag"></span></h1>
1919
<legend>Status</legend>
2020
<div class="container">
2121
<div>
22-
<div class="fg">
22+
<div class="fg-s">
2323
<div>Switch:</div>
2424
<div class="value">
2525
<span class="switch" name="switch" data-value="false"></span>
2626
</div>
2727
</div>
28-
<div class="fg">
28+
<div class="fg-s">
2929
<div>Production:</div>
3030
<div class="value"><span name="prod"></span> W</div>
3131
</div>
32-
<div class="fg">
32+
<div class="fg-s">
3333
<div>Consumption:</div>
3434
<div class="value"><span name="cons_w"></span> W</div>
3535
</div>
36-
<div class="fg">
36+
<div class="fg-s">
3737
<div>Cons. (avg):</div>
3838
<div class="value"><span name="cons_avg_w"></span> W</div>
3939
</div>
40-
<div class="fg">
40+
<div class="fg-s">
4141
<div>Grid:</div>
4242
<div class="value"><span name="grid"></span> W</div>
4343
</div>
44-
<div class="fg">
44+
<div class="fg-s">
4545
<div>Battery Load:</div>
4646
<div class="value">
4747
<span data-value name="chrg" class="arrow-up"></span><span data-value name="chrg" class="arrow-down"></span>
4848
<span data-value class=""><span name="pac_total_w"></span> W</span>
4949
</div>
5050
</div>
51+
<div class="fg-s">
52+
<div>Cons. Statistic:</div>
53+
<div class="value">
54+
<a href="#" onclick="showConsStats()">Stats</a>
55+
</div>
56+
</div>
5157
</div>
5258
<div class="battery-container">
5359
<div class="battery">
@@ -123,7 +129,7 @@ <h1>Sonnen SmartSwitch <span class="release" name="release_tag"></span></h1>
123129
</div>
124130
<div class="fg">
125131
<div></div>
126-
<button id="calibrateBtn" type="button" onclick="javascript:triggerCalibrate();">
132+
<button id="calibrateBtn" type="button" onclick="triggerCalibrate()">
127133
<span>Calibrate</span>
128134
<div class="spinner"></div>
129135
</button>
@@ -192,7 +198,7 @@ <h1>Sonnen SmartSwitch <span class="release" name="release_tag"></span></h1>
192198
</div>
193199
</fieldset>
194200
</form>
195-
<form action="/api/update" method="post" onSubmit="return confirm('Sure?');">
201+
<form action="/api/update" method="post" onSubmit="return confirm('Sure?')">
196202
<fieldset>
197203
<legend>Software</legend>
198204
<div class="fg">
@@ -232,12 +238,12 @@ <h1>Sonnen SmartSwitch <span class="release" name="release_tag"></span></h1>
232238
</div>
233239
<div class="fg">
234240
<div></div>
235-
<input type="submit" name="restart" value="Restart" onclick="return confirm('Sure?');">
241+
<input type="submit" name="restart" value="Restart" onclick="return confirm('Sure?')">
236242
</div>
237243
<div class="fg">
238244
<div></div>
239245
<input type="submit" name="reset" value="Factory Reset"
240-
onclick="return confirm('Sure? WLAN Access Data will be erased!');">
246+
onclick="return confirm('Sure? WLAN Access Data will be erased!')">
241247
</div>
242248
</fieldset>
243249
</form>
@@ -248,6 +254,30 @@ <h1>Sonnen SmartSwitch <span class="release" name="release_tag"></span></h1>
248254
<div id="eventLog" class="eventLog"></div>
249255
</div>
250256
</div>
257+
<div id="statsOverlay" class="overlay">
258+
<button class="close-btn" onclick="closeOverlay(this)">X</button>
259+
<div class="overlay-content">
260+
<div class="day-selector">
261+
<button class="day-btn" onclick="renderConsStats(0)">Sun</button>
262+
<button class="day-btn" onclick="renderConsStats(1)">Mon</button>
263+
<button class="day-btn" onclick="renderConsStats(2)">Tue</button>
264+
<button class="day-btn" onclick="renderConsStats(3)">Wed</button>
265+
<button class="day-btn" onclick="renderConsStats(4)">Thu</button>
266+
<button class="day-btn" onclick="renderConsStats(5)">Fri</button>
267+
<button class="day-btn" onclick="renderConsStats(6)">Sat</button>
268+
</div>
269+
<div class="chart-wrapper">
270+
<div class="bars-row">
271+
<div id="statsYAxis" class="stats-y-axis"></div>
272+
<div id="statsBars" class="stats-bars"></div>
273+
</div>
274+
<div class="x-axis-row">
275+
<div class="x-axis-spacer"></div>
276+
<div id="statsXAxis" class="stats-x-axis"></div>
277+
</div>
278+
</div>
279+
</div>
280+
</div>
251281

252282
</body>
253283

smartswitch.espxx/include/Logic.h

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@ void updateSystemState(SystemConfig *systemConfig, SystemState *systemState)
5555
// consumption without load
5656
systemState->cons_W_norm = systemState->cons_W_nom - (systemState->switchEnabled && systemState->cons_W_nom > systemConfig->loadPower_W ? systemConfig->loadPower_W : 0);
5757
systemState->system_Power_W = systemState->prod_W + (systemState->fullChargeRequest || systemState->usoc == 0 ? 0 : systemState->inv_max_w);
58+
59+
// Update per-hour-per-weekday consumption statistics (EMA α=0.1)
60+
if (systemState->ts > 0)
61+
{
62+
time_t local_ts = (time_t)(systemState->ts + systemState->utc_offset);
63+
struct tm lt;
64+
gmtime_r(&local_ts, &lt);
65+
int16_t key = (int16_t)(lt.tm_wday * 24 + lt.tm_hour);
66+
uint16_t *slot = &systemConfig->cons_stats_Wh[lt.tm_wday][lt.tm_hour];
67+
*slot = (*slot == 0) ? systemState->cons_W_norm
68+
: (uint16_t)(((uint32_t)*slot * 9 + systemState->cons_W_norm) / 10);
69+
systemState->stat_hour_key = key;
70+
}
5871
}
5972

6073
enum BatteryLevel
@@ -92,6 +105,16 @@ static int findPvForecastData(SystemState *systemState)
92105
return foundPvData ? i : -1;
93106
}
94107

108+
static uint16_t getConsumptionWh(SystemConfig *systemConfig, SystemState *systemState, uint32_t utc_ts, uint16_t seconds)
109+
{
110+
time_t local_ts = (time_t)(utc_ts + systemState->utc_offset);
111+
struct tm lt;
112+
gmtime_r(&local_ts, &lt);
113+
uint16_t stat = systemConfig->cons_stats_Wh[lt.tm_wday][lt.tm_hour];
114+
uint16_t cons = (stat > 0) ? stat : systemState->cons_W_norm;
115+
return (uint16_t)((uint32_t)seconds * cons / SECONDS_PER_HOUR);
116+
}
117+
95118
static BatteryState predictBatteryCapacityState(SystemConfig *systemConfig, SystemState *systemState)
96119
{
97120
short index = findPvForecastData(systemState);
@@ -107,7 +130,7 @@ static BatteryState predictBatteryCapacityState(SystemConfig *systemConfig, Syst
107130

108131
uint16_t seconds = ts + SECONDS_PER_HOUR - systemState->ts; // remaining seconds in this hour
109132
uint32_t wh = seconds * systemState->pv_forecast_ts_wh[index][1] / SECONDS_PER_HOUR; // remaining pv production in this hour
110-
uint16_t cons_wh = seconds * systemState->cons_W_norm / SECONDS_PER_HOUR; // remaining consumption in this hour
133+
uint16_t cons_wh = getConsumptionWh(systemConfig, systemState, ts, seconds); // remaining consumption in this hour
111134

112135
DEBUGF("%d => %u %u (s) %s %u/%u (Wh)\n", index, ts, systemState->ts, toDate(ts), wh, systemState->pv_forecast_ts_wh[index][1]);
113136

@@ -137,7 +160,7 @@ static BatteryState predictBatteryCapacityState(SystemConfig *systemConfig, Syst
137160

138161
ts = systemState->pv_forecast_ts_wh[index][0];
139162
wh = systemState->pv_forecast_ts_wh[index][1];
140-
cons_wh = systemState->cons_W_norm; // next turn is full hour, so consumption is Wh
163+
cons_wh = getConsumptionWh(systemConfig, systemState, ts, SECONDS_PER_HOUR); // next turn is full hour, so consumption is Wh
141164
}
142165
DEBUGF("capacity %u Wh (bat) %u Wh (min) %u Wh (hys) %u Wh at %s\n", cap_bat_sim_wh, cap_bat_min_Wh, hysteresis_Wh, cons_wh, toDate(ts));
143166
return BatteryState{

0 commit comments

Comments
 (0)