Skip to content

Commit 4caf9a5

Browse files
basmeermanclaude
andcommitted
feat: circuit energy in session log for ERE reporting (Plan 14, Increment 5) (#110)
Add circuit_energy_wh to session_record_t. When CircuitMeter is active, session JSON includes circuit_kwh field for ERE compliance verification. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a34bd19 commit 4caf9a5

File tree

9 files changed

+378
-16
lines changed

9 files changed

+378
-16
lines changed

SmartEVSE-3/src/main.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2406,6 +2406,9 @@ static void timer10ms_ev_metering(uint8_t oldState, uint8_t pilot_val) {
24062406
EVMeter.ResetKwh = 1; // reset EV kWh meter on next B->C change
24072407
// End charge session on vehicle disconnect (A + 12V)
24082408
if (session_is_active()) {
2409+
if (CircuitMeter.Type) {
2410+
session_set_circuit_energy(0, CircuitMeter.Import_active_energy);
2411+
}
24092412
session_end((uint32_t)time(NULL), EVMeter.Import_active_energy,
24102413
(uint16_t)(Balanced[0]), Nr_Of_Phases_Charging);
24112414
#if MQTT
@@ -2421,6 +2424,9 @@ static void timer10ms_ev_metering(uint8_t oldState, uint8_t pilot_val) {
24212424
EVMeter.ResetKwh = 0;
24222425
// Start charge session on B->C transition
24232426
session_start((uint32_t)time(NULL), EVMeter.Import_active_energy, Mode);
2427+
if (CircuitMeter.Type) {
2428+
session_set_circuit_energy(CircuitMeter.Import_active_energy, 0);
2429+
}
24242430
}
24252431
}
24262432

SmartEVSE-3/src/session_log.c

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,19 @@ void session_set_ocpp_id(uint32_t ocpp_transaction_id) {
7373
s_active.ocpp_active = 1;
7474
}
7575

76+
void session_set_circuit_energy(int32_t start_wh, int32_t end_wh) {
77+
if (!s_session_active) {
78+
return;
79+
}
80+
if (start_wh != 0) {
81+
s_active.circuit_start_energy_wh = start_wh;
82+
}
83+
if (end_wh != 0) {
84+
s_active.circuit_end_energy_wh = end_wh;
85+
s_active.circuit_energy_wh = end_wh - s_active.circuit_start_energy_wh;
86+
}
87+
}
88+
7689
uint8_t session_is_active(void) {
7790
return s_session_active;
7891
}
@@ -167,7 +180,7 @@ int session_to_json(const session_record_t *rec, char *buf, size_t bufsz) {
167180
"\"max_current_a\":%u.%u,"
168181
"\"phases\":%u,"
169182
"\"mode\":\"%s\","
170-
"\"ocpp_tx_id\":%s}",
183+
"\"ocpp_tx_id\":%s",
171184
(unsigned)rec->session_id,
172185
start_iso,
173186
end_iso,
@@ -180,9 +193,31 @@ int session_to_json(const session_record_t *rec, char *buf, size_t bufsz) {
180193
ocpp_field);
181194

182195
if (n < 0 || (size_t)n >= bufsz) {
183-
/* Buffer too small — output was truncated */
184196
return -1;
185197
}
186198

199+
/* Append circuit_kwh when CircuitMeter was active */
200+
if (rec->circuit_energy_wh != 0) {
201+
int32_t ckwh_int = rec->circuit_energy_wh / 1000;
202+
int32_t ckwh_frac = rec->circuit_energy_wh % 1000;
203+
if (ckwh_frac < 0) ckwh_frac = -ckwh_frac;
204+
205+
int extra = snprintf(buf + n, bufsz - (size_t)n,
206+
",\"circuit_kwh\":%d.%03d}",
207+
(int)ckwh_int, (int)ckwh_frac);
208+
if (extra < 0 || (size_t)(n + extra) >= bufsz) {
209+
return -1;
210+
}
211+
n += extra;
212+
} else {
213+
/* Close the JSON object */
214+
if ((size_t)n + 1 >= bufsz) {
215+
return -1;
216+
}
217+
buf[n] = '}';
218+
n++;
219+
buf[n] = '\0';
220+
}
221+
187222
return n;
188223
}

SmartEVSE-3/src/session_log.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ typedef struct {
3636
uint8_t mode; /* MODE_NORMAL / MODE_SMART / MODE_SOLAR */
3737
uint8_t ocpp_active; /* Was OCPP controlling this session? */
3838
uint8_t _reserved[3]; /* Alignment padding */
39+
int32_t circuit_start_energy_wh; /* CircuitMeter energy at session start */
40+
int32_t circuit_end_energy_wh; /* CircuitMeter energy at session end */
41+
int32_t circuit_energy_wh; /* circuit end - start */
3942
} session_record_t;
4043

4144
/* Initialize session logger state. Call once at startup. */
@@ -51,6 +54,11 @@ void session_end(uint32_t timestamp, int32_t end_energy_wh,
5154
/* Set OCPP transaction ID on the active session. No-op if no session active. */
5255
void session_set_ocpp_id(uint32_t ocpp_transaction_id);
5356

57+
/* Set circuit energy on the active session. Call with start_wh at session start
58+
* and end_wh at session end. Calculates circuit_energy_wh = end - start when
59+
* end_wh > 0. No-op if no session active. */
60+
void session_set_circuit_energy(int32_t start_wh, int32_t end_wh);
61+
5462
/* Returns 1 if a session is currently active, 0 otherwise. */
5563
uint8_t session_is_active(void);
5664

SmartEVSE-3/test/native/tests/test_session_log.c

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,93 @@ void test_session_zero_energy(void) {
338338
TEST_ASSERT_EQUAL_INT(0, last->energy_charged_wh);
339339
}
340340

341+
/* ---- Circuit energy tests ---- */
342+
343+
/*
344+
* @feature Charge Session Logging
345+
* @req REQ-CIR-020
346+
* @scenario Session with circuit energy includes circuit_kwh in JSON
347+
* @given A completed session with CircuitMeter energy set
348+
* @when session_to_json is called
349+
* @then JSON includes circuit_kwh field with correct value
350+
*/
351+
void test_session_circuit_energy_json(void) {
352+
session_init();
353+
session_start(1773930600, 142300, 0);
354+
session_set_circuit_energy(200000, 0); /* start energy */
355+
session_set_circuit_energy(0, 212345); /* end energy */
356+
session_end(1773945900, 154645, 160, 3);
357+
358+
const session_record_t *last = session_get_last();
359+
TEST_ASSERT_TRUE(last != NULL);
360+
TEST_ASSERT_EQUAL_INT(200000, last->circuit_start_energy_wh);
361+
TEST_ASSERT_EQUAL_INT(212345, last->circuit_end_energy_wh);
362+
TEST_ASSERT_EQUAL_INT(12345, last->circuit_energy_wh);
363+
364+
int n = session_to_json(last, json_buf, sizeof(json_buf));
365+
TEST_ASSERT_GREATER_THAN(0, n);
366+
TEST_ASSERT_TRUE(strstr(json_buf, "\"circuit_kwh\":12.345") != NULL);
367+
}
368+
369+
/*
370+
* @feature Charge Session Logging
371+
* @req REQ-CIR-021
372+
* @scenario Session without circuit energy omits circuit_kwh from JSON
373+
* @given A completed session without CircuitMeter energy
374+
* @when session_to_json is called
375+
* @then JSON does not include circuit_kwh field
376+
*/
377+
void test_session_no_circuit_energy_json(void) {
378+
session_init();
379+
session_start(1773930600, 142300, 0);
380+
session_end(1773945900, 154645, 160, 3);
381+
382+
const session_record_t *last = session_get_last();
383+
TEST_ASSERT_TRUE(last != NULL);
384+
TEST_ASSERT_EQUAL_INT(0, last->circuit_energy_wh);
385+
386+
int n = session_to_json(last, json_buf, sizeof(json_buf));
387+
TEST_ASSERT_GREATER_THAN(0, n);
388+
TEST_ASSERT_TRUE(strstr(json_buf, "circuit_kwh") == NULL);
389+
}
390+
391+
/*
392+
* @feature Charge Session Logging
393+
* @req REQ-CIR-022
394+
* @scenario Circuit energy calculation: end minus start
395+
* @given A session with circuit start=100000 and circuit end=107500
396+
* @when The session ends
397+
* @then circuit_energy_wh equals 7500
398+
*/
399+
void test_session_circuit_energy_calculation(void) {
400+
session_init();
401+
session_start(1710000000, 50000, 2);
402+
session_set_circuit_energy(100000, 0);
403+
session_set_circuit_energy(0, 107500);
404+
session_end(1710001000, 55000, 80, 1);
405+
406+
const session_record_t *last = session_get_last();
407+
TEST_ASSERT_TRUE(last != NULL);
408+
TEST_ASSERT_EQUAL_INT(100000, last->circuit_start_energy_wh);
409+
TEST_ASSERT_EQUAL_INT(107500, last->circuit_end_energy_wh);
410+
TEST_ASSERT_EQUAL_INT(7500, last->circuit_energy_wh);
411+
}
412+
413+
/*
414+
* @feature Charge Session Logging
415+
* @req REQ-CIR-023
416+
* @scenario session_set_circuit_energy with no active session is ignored
417+
* @given No active session
418+
* @when session_set_circuit_energy is called
419+
* @then No crash and no state change
420+
*/
421+
void test_session_set_circuit_energy_no_active(void) {
422+
session_init();
423+
session_set_circuit_energy(100000, 0);
424+
TEST_ASSERT_FALSE(session_is_active());
425+
TEST_ASSERT_TRUE(session_get_last() == NULL);
426+
}
427+
341428
/* ---- NTP time validity guard tests ---- */
342429

343430
/*
@@ -443,6 +530,12 @@ int main(void) {
443530
RUN_TEST(test_session_json_smart_mode);
444531
RUN_TEST(test_session_zero_energy);
445532

533+
/* Circuit energy tests */
534+
RUN_TEST(test_session_circuit_energy_json);
535+
RUN_TEST(test_session_no_circuit_energy_json);
536+
RUN_TEST(test_session_circuit_energy_calculation);
537+
RUN_TEST(test_session_set_circuit_energy_no_active);
538+
446539
/* Minimum duration filter tests */
447540
RUN_TEST(test_session_short_duration_discarded);
448541
RUN_TEST(test_session_exact_min_duration_kept);

docs/configuration.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,57 @@ If [CONFIG](#config) is set to **Fixed**, configure MAX to be lower than or equa
195195
196196
Set the max current of the EVSE circuit: 10-160A per phase. When power sharing, this is the total current that will be split between connected and charging EVs.
197197

198+
## CircuitMeter
199+
200+
CircuitMeter is a third energy meter that monitors a subpanel circuit feeding the
201+
EVSE. It provides subpanel breaker protection and circuit-level energy data for ERE
202+
compliance. See [Features — CircuitMeter](features.md#circuitmeter--subpanel-metering)
203+
for background.
204+
205+
CircuitMeter is configured via the web UI, REST API, or MQTT — it does not appear
206+
in the LCD menu.
207+
208+
### CircuitMeter type
209+
210+
Set the meter type for the circuit meter. Uses the same meter type list as
211+
MAINS MET and EV METER (Eastron, ABB, Finder, Orno, Custom, API, etc.).
212+
213+
- **0** (default): Disabled — no circuit meter, zero runtime cost.
214+
- **1-19**: A supported Modbus energy meter type (same IDs as MAINS MET).
215+
- **9 (API)**: Circuit meter data is fed externally via MQTT (`Set/CircuitMeter`)
216+
or REST API (`/currents` with `circuit_L1`, `circuit_L2`, `circuit_L3` params).
217+
218+
**REST API:** `circuit_meter_type` field in GET/POST `/settings`
219+
**MQTT:** Not directly settable via MQTT (use REST API or web UI)
220+
**NVS key:** `CircuitMeter` (uint8_t)
221+
222+
### CircuitMeter address
223+
224+
Modbus address for the circuit meter: 10-247. Only relevant when CircuitMeter type
225+
is a Modbus meter (not Disabled or API).
226+
227+
- **Default:** 14
228+
229+
**REST API:** `circuit_meter_address` field in GET/POST `/settings`
230+
**NVS key:** `CirMeterAddr` (uint8_t)
231+
232+
### MaxCircuitMains
233+
234+
Maximum current allowed on the subpanel circuit (sum of all phases is per-phase,
235+
same convention as MAINS): 0-600A.
236+
237+
- **0** (default): Disabled — no circuit current limiting.
238+
- **10-600**: Maximum current in Amperes per phase. SmartEVSE will reduce charging
239+
current so that `CircuitMeter` measured current stays below this value.
240+
241+
When both `MaxCircuitMains` and `MaxMains` are configured, the most restrictive
242+
limit applies.
243+
244+
**REST API:** `max_circuit_mains` field in GET/POST `/settings`
245+
**MQTT:** `Set/MaxCircuitMains` (integer, 0-600)
246+
**NVS key:** `MaxCirMains` (uint16_t)
247+
**HA entity:** `Max Circuit Mains` (number, settable)
248+
198249
## START
199250
> Visible when: MODE = Solar, AND PWR SHARE = Disabled or Master
200251

docs/ere-session-logging.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,52 @@ mqtt:
136136
137137
Replace `SmartEVSE-XXXX` with your device's MQTT prefix.
138138

139+
## Circuit energy for ERE Path B
140+
141+
When a [CircuitMeter](features.md#circuitmeter--subpanel-metering) is configured
142+
and active during a charge session, the session JSON includes a `circuit_kwh` field
143+
representing the total energy measured on the subpanel circuit during the session.
144+
145+
### Payload with circuit energy
146+
147+
```json
148+
{
149+
"session_id": 1,
150+
"start": "2026-03-19T14:30:00Z",
151+
"end": "2026-03-19T18:45:00Z",
152+
"kwh": 12.345,
153+
"start_energy_wh": 142300,
154+
"end_energy_wh": 154645,
155+
"circuit_kwh": 12.380,
156+
"max_current_a": 16.0,
157+
"phases": 3,
158+
"mode": "solar",
159+
"ocpp_tx_id": null
160+
}
161+
```
162+
163+
The `circuit_kwh` field is only present when CircuitMeter is enabled and has
164+
non-zero energy data. When CircuitMeter is disabled, the field is omitted.
165+
166+
### ERE Path B compliance
167+
168+
ERE Path B requires an "exclusief bemeterd allocatiepunt" — a separately metered
169+
circuit that exclusively feeds the charger. The CircuitMeter enables verification:
170+
171+
- If `circuit_kwh` approximately equals `kwh` (within meter tolerance, typically
172+
1-2% for MID Class B meters), the circuit exclusively feeds the charger. No
173+
other loads consumed energy during the session.
174+
- If `circuit_kwh` is significantly higher than `kwh`, other loads are present on
175+
the circuit. The circuit is not exclusive, and Path B may not apply.
176+
177+
This comparison can be automated in Home Assistant or your ERE reporting workflow.
178+
The tolerance should account for meter accuracy class and any standby consumption
179+
of the EVSE itself.
180+
181+
**Note:** Increment 5 (session log integration) implements the `circuit_kwh` field.
182+
If you are on a firmware version before that increment, the field will not be
183+
present in session JSON output.
184+
139185
## OCPP alignment
140186

141187
When OCPP is active, charge sessions are aligned with OCPP transactions:

docs/features.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,47 @@ Configuration: [EVCC Integration](evcc-integration.md)
247247

248248
---
249249

250+
## CircuitMeter — Subpanel Metering
251+
252+
*New in this fork.*
253+
254+
A third energy meter instance for monitoring subpanel circuits. CircuitMeter
255+
addresses two needs:
256+
257+
- **Subpanel breaker protection** — limits EV charging current so the total
258+
subpanel load stays below the breaker rating (`MaxCircuitMains`). Without this,
259+
other loads on the same subpanel (heat pump, dryer) can cause the breaker to trip
260+
during charging.
261+
- **ERE 2027 compliance support** — provides circuit-level energy measurement for
262+
Dutch ERE Path B verification. If `circuit_kwh` matches `session_kwh` in the
263+
session log, the circuit exclusively feeds the charger.
264+
265+
**Key design points:**
266+
267+
- Reuses the existing `Meter` class — supports all 19 meter types (Eastron, ABB,
268+
Finder, Orno, Custom, etc.) with zero new meter code
269+
- Zero runtime cost when disabled (`CircuitMeter` type = 0, the default)
270+
- Integrates with load balancing: `MaxCircuitMains` acts as an additional current
271+
constraint alongside `MaxMains` and `MaxCircuit`
272+
- Full MQTT + Home Assistant auto-discovery for circuit current, power, and energy
273+
- API/MQTT external feed supported (`Set/CircuitMeter` with `L1:L2:L3` format)
274+
275+
**Typical wiring:**
276+
277+
```
278+
Grid meter ─── Main panel ─── [CircuitMeter] ─── Subpanel
279+
│ ├── EVSE (EVMeter)
280+
│ └── Other loads (heat pump, etc.)
281+
├── Kitchen
282+
└── Lighting
283+
```
284+
285+
Configuration: [Configuration](configuration.md#circuitmeter),
286+
[MQTT topics](mqtt-home-assistant.md#circuitmeter-topics),
287+
[Power Input Methods](power-input-methods.md)
288+
289+
---
290+
250291
## ERE Session Logging
251292

252293
*New in this fork.*

0 commit comments

Comments
 (0)