Skip to content

Commit 2a8046b

Browse files
authored
Merge pull request #76 from basmeerman/work/plan-05
feat: meter telemetry foundation (Plan-05 #40)
2 parents 57920da + be8f4d7 commit 2a8046b

23 files changed

+2946
-38
lines changed

.github/workflows/ci.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ jobs:
163163
SmartEVSE-3/test/native/test-specification.md
164164
- name: Commit updated test specification
165165
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
166+
continue-on-error: true
166167
run: |
167168
git config user.name "github-actions[bot]"
168169
git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -174,7 +175,7 @@ jobs:
174175
175176
Auto-generated from SbE annotations by CI pipeline.
176177
$(grep -c '###' SmartEVSE-3/test/native/test-specification.md) scenarios across $(grep -c '^## ' SmartEVSE-3/test/native/test-specification.md) features."
177-
git push
178+
git push || echo "::warning::Could not push traceability commit — branch protection may require a PAT with bypass permissions. Reports are available as artifacts."
178179
fi
179180
180181
bdd-tests:

SmartEVSE-3/src/esp32.cpp

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,19 @@ void SetupMQTTClient() {
898898
MQTTclient.announce("Home Battery Current", "sensor", optional_payload);
899899
}
900900

901+
//set the parameters for and announce sensors with device class 'power':
902+
optional_payload = MQTTclient.jsna("device_class","power") + MQTTclient.jsna("state_class","measurement") + MQTTclient.jsna("unit_of_measurement","W");
903+
if (MainsMeter.Type) {
904+
MQTTclient.announce("Mains Power L1", "sensor", optional_payload);
905+
MQTTclient.announce("Mains Power L2", "sensor", optional_payload);
906+
MQTTclient.announce("Mains Power L3", "sensor", optional_payload);
907+
}
908+
if (EVMeter.Type) {
909+
MQTTclient.announce("EV Power L1", "sensor", optional_payload);
910+
MQTTclient.announce("EV Power L2", "sensor", optional_payload);
911+
MQTTclient.announce("EV Power L3", "sensor", optional_payload);
912+
}
913+
901914
#if MODEM
902915
//set the parameters for modem/SoC sensor entities:
903916
optional_payload = MQTTclient.jsna("unit_of_measurement","%") + MQTTclient.jsna("value_template", R"({{ none if (value | int == -1) else (value | int) }})");
@@ -1094,6 +1107,9 @@ void mqttPublishData() {
10941107
mqtt_pub_int(MQTT_SLOT_MAINS_IMPORT_ENERGY, "/MainsImportActiveEnergy", MainsMeter.Import_active_energy, false, now_s);
10951108
if (MainsMeter.Export_active_energy > 0)
10961109
mqtt_pub_int(MQTT_SLOT_MAINS_EXPORT_ENERGY, "/MainsExportActiveEnergy", MainsMeter.Export_active_energy, false, now_s);
1110+
mqtt_pub_int(MQTT_SLOT_MAINS_POWER_L1, "/MainsPowerL1", MainsMeter.Power[0], false, now_s);
1111+
mqtt_pub_int(MQTT_SLOT_MAINS_POWER_L2, "/MainsPowerL2", MainsMeter.Power[1], false, now_s);
1112+
mqtt_pub_int(MQTT_SLOT_MAINS_POWER_L3, "/MainsPowerL3", MainsMeter.Power[2], false, now_s);
10971113
}
10981114
if (EVMeter.Type) {
10991115
mqtt_pub_int(MQTT_SLOT_EV_L1, "/EVCurrentL1", EVMeter.Irms[0], false, now_s);
@@ -1104,6 +1120,9 @@ void mqttPublishData() {
11041120
mqtt_pub_int(MQTT_SLOT_EV_IMPORT_ENERGY, "/EVImportActiveEnergy", EVMeter.Import_active_energy, false, now_s);
11051121
if (EVMeter.Export_active_energy > 0)
11061122
mqtt_pub_int(MQTT_SLOT_EV_EXPORT_ENERGY, "/EVExportActiveEnergy", EVMeter.Export_active_energy, false, now_s);
1123+
mqtt_pub_int(MQTT_SLOT_EV_POWER_L1, "/EVPowerL1", EVMeter.Power[0], false, now_s);
1124+
mqtt_pub_int(MQTT_SLOT_EV_POWER_L2, "/EVPowerL2", EVMeter.Power[1], false, now_s);
1125+
mqtt_pub_int(MQTT_SLOT_EV_POWER_L3, "/EVPowerL3", EVMeter.Power[2], false, now_s);
11071126
}
11081127
mqtt_pub_int(MQTT_SLOT_ESP_TEMP, "/ESPTemp", TempEVSE, false, now_s);
11091128
mqtt_pub_str(MQTT_SLOT_MODE, "/Mode", AccessStatus == OFF ? "Off" : AccessStatus == PAUSE ? "Pause" : Mode > 3 ? "N/A" : StrMode[Mode], true, now_s);

SmartEVSE-3/src/evse_state_machine.c

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,27 @@ void evse_set_access(evse_ctx_t *ctx, AccessStatus_t access) {
309309
}
310310
}
311311

312+
// ---- STATE_A entry logic (extracted for readability) ----
313+
static void evse_enter_state_a(evse_ctx_t *ctx) {
314+
ctx->ModemStage = 0;
315+
if (ctx->ModemEnabled && ctx->DisconnectTimeCounter == -1)
316+
ctx->DisconnectTimeCounter = 0; // Start disconnect counter
317+
evse_clear_error_flags(ctx, LESS_6A);
318+
ctx->ChargeDelay = 0;
319+
ctx->Node[0].Timer = 0;
320+
ctx->Node[0].IntTimer = 0;
321+
ctx->Node[0].Phases = 0;
322+
ctx->Node[0].MinCurrent = 0;
323+
// Clear authorization when returning to STATE_A after any charging-related
324+
// state. The OCPP layer detects the AccessStatus→OFF change on the next
325+
// tick and terminates the transaction.
326+
if (ctx->State == STATE_C || ctx->State == STATE_C1 ||
327+
ctx->State == STATE_B || ctx->State == STATE_B1) {
328+
ctx->AccessStatus = OFF;
329+
ctx->AccessTimer = 0;
330+
}
331+
}
332+
312333
// ---- State transition ----
313334
// Faithful to setState() in main.cpp:790-941
314335
void evse_set_state(evse_ctx_t *ctx, uint8_t new_state) {
@@ -334,32 +355,8 @@ void evse_set_state(evse_ctx_t *ctx, uint8_t new_state) {
334355
record_contactor1(ctx, false); // CONTACTOR1_OFF
335356
record_contactor2(ctx, false); // CONTACTOR2_OFF
336357
record_cp_duty(ctx, 1024); // PWM off, +12V
337-
338-
if (new_state == STATE_A) {
339-
ctx->ModemStage = 0; // line 827
340-
if (ctx->ModemEnabled && ctx->DisconnectTimeCounter == -1)
341-
ctx->DisconnectTimeCounter = 0; // Start disconnect counter
342-
evse_clear_error_flags(ctx, LESS_6A); // line 828
343-
ctx->ChargeDelay = 0; // line 829
344-
ctx->Node[0].Timer = 0;
345-
ctx->Node[0].IntTimer = 0;
346-
ctx->Node[0].Phases = 0;
347-
ctx->Node[0].MinCurrent = 0;
348-
// When ending an active charging session (STATE_C or C1 → STATE_A),
349-
// Clear authorization when returning to STATE_A after any charging-related
350-
// state. Without this, AccessStatus stays ON for up to RFIDLOCKTIME
351-
// seconds, causing the next RFID swipe to toggle it OFF (stopping a
352-
// session that was never started) instead of ON (starting a new session).
353-
// The normal Tesla disconnect path is C -> B -> A, so old_state is
354-
// STATE_B when we reach here — we must include B and B1 in the check.
355-
// The OCPP layer detects the AccessStatus→OFF change on the next tick
356-
// and terminates the transaction.
357-
if (ctx->State == STATE_C || ctx->State == STATE_C1 ||
358-
ctx->State == STATE_B || ctx->State == STATE_B1) {
359-
ctx->AccessStatus = OFF;
360-
ctx->AccessTimer = 0;
361-
}
362-
}
358+
if (new_state == STATE_A)
359+
evse_enter_state_a(ctx);
363360
break;
364361

365362
case STATE_MODEM_REQUEST:
@@ -1136,8 +1133,7 @@ void evse_schedule_tick_1s(evse_ctx_t *ctx) {
11361133
ctx->ConnectedTime[i] = ctx->Uptime;
11371134
} else if (ctx->BalancedState[i] != STATE_C) {
11381135
ctx->ConnectedTime[i] = 0;
1139-
if (ctx->ScheduleState[i] != SCHED_INACTIVE)
1140-
ctx->ScheduleState[i] = SCHED_INACTIVE;
1136+
ctx->ScheduleState[i] = SCHED_INACTIVE;
11411137
}
11421138
}
11431139

SmartEVSE-3/src/glcd.cpp

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1295,15 +1295,13 @@ void GLCDMenu(uint8_t Buttons) {
12951295
value = getItemValue(LCDNav);
12961296
switch (LCDNav) {
12971297
case MENU_MAINSMETER:
1298-
do {
1299-
value = MenuNavInt(Buttons, value, MenuStr[LCDNav].Min, MenuStr[LCDNav].Max);
1300-
} while (value >= EM_UNUSED_SLOT3 && value <= EM_UNUSED_SLOT4);
1298+
value = MenuNavInt(Buttons, value, MenuStr[LCDNav].Min, MenuStr[LCDNav].Max);
13011299
setItemValue(LCDNav, value);
13021300
break;
1303-
case MENU_EVMETER: // do not display the Sensorbox, HomeWizard P1 or unused slots here
1301+
case MENU_EVMETER: // do not display the Sensorbox or HomeWizard P1 here
13041302
do {
13051303
value = MenuNavInt(Buttons, value, MenuStr[LCDNav].Min, MenuStr[LCDNav].Max);
1306-
} while (value == EM_SENSORBOX || value == EM_HOMEWIZARD_P1 || (value >= EM_UNUSED_SLOT3 && value <= EM_UNUSED_SLOT4));
1304+
} while (value == EM_SENSORBOX || value == EM_HOMEWIZARD_P1);
13071305
setItemValue(LCDNav, value);
13081306
break;
13091307
case MENU_WIFI:

SmartEVSE-3/src/meter.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ struct EMstruct EMConfig[] = {
3434
{"Schneider", ENDIANESS_HBF_HWF, 3, MB_DATATYPE_FLOAT32, 0x0BD3, 0, 0x0BB7, 0, 0x0BF3,-3, 0xB02B, 0,0xB02D, 0}, // Schneider iEM3x5x series (V / A / kW / kWh) iEM3x50 counts only Energy Import, no Export
3535
{"Chint", ENDIANESS_HBF_HWF, 3, MB_DATATYPE_FLOAT32, 0x2000, 1, 0x200C, 3, 0x2012, 1, 0x101E, 0,0x1028, 0}, // Chint DTSU666 (0.1V / mA / 0.1W / kWh)
3636
{"C.Gavazzi", ENDIANESS_HBF_LWF, 4, MB_DATATYPE_INT32, 0x0, 1, 0xC, 3, 0x28, 1, 0x34, 1, 0x4E, 1}, // Carlo Gavazzi EM340 (0.1V / mA / 0.1W / 0.1kWh)
37-
{"Unused 3", ENDIANESS_LBF_LWF, 4, MB_DATATYPE_INT32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, // unused slot for future new meters
38-
{"Unused 4", ENDIANESS_LBF_LWF, 4, MB_DATATYPE_INT32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, // unused slot for future new meters
37+
{"Orno 3P", ENDIANESS_HBF_HWF, 4, MB_DATATYPE_FLOAT32, 0x0, 0, 0x0C, 0, 0x1C, 0, 0x0100, 0,0x0110, 0}, // Orno OR-WE-517 (V / A / W / kWh) 3-phase bidirectional
38+
{"Orno 1P", ENDIANESS_HBF_HWF, 4, MB_DATATYPE_FLOAT32, 0x0, 0, 0x06, 0, 0x0C, 0, 0x0100, 0,0x0110, 0}, // Orno OR-WE-516 (V / A / W / kWh) 1-phase bidirectional
3939
{"Custom", ENDIANESS_LBF_LWF, 4, MB_DATATYPE_INT32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} // Last entry!
4040
};
4141
// WARNING: ONLY ADD new meters to the END of this ARRAY. The row number is stored in the config of the user, if you change the order YOU WILL RUIN THE CONFIGS OF USERS !!!!!!!

SmartEVSE-3/src/meter.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@
4848
#define EM_SCHNEIDER 14
4949
#define EM_CHINT 15
5050
#define EM_CARLO_CAVAZZI 16
51-
#define EM_UNUSED_SLOT3 17
52-
#define EM_UNUSED_SLOT4 18
51+
#define EM_ORNO3P 17
52+
#define EM_ORNO1P 18
5353
#define EM_CUSTOM 19
5454

5555
typedef enum mb_datatype {

SmartEVSE-3/src/meter_decode.c

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* meter_decode.c - Pure C meter byte decoding for Modbus energy meters
3+
*
4+
* Extracted from meter.cpp combineBytes() and decodeMeasurement().
5+
* No platform dependencies.
6+
*/
7+
8+
#include "meter_decode.h"
9+
#include <string.h>
10+
11+
/* Power-of-10 lookup table (matches firmware's pow_10[]) */
12+
static const unsigned long pow10_table[10] = {
13+
1, 10, 100, 1000, 10000, 100000,
14+
1000000, 10000000, 100000000, 1000000000
15+
};
16+
17+
uint8_t meter_register_size(meter_datatype_t datatype)
18+
{
19+
return (datatype == METER_DATATYPE_INT16) ? 2 : 4;
20+
}
21+
22+
void meter_combine_bytes(void *out, const uint8_t *buf, uint8_t pos,
23+
uint8_t endianness, meter_datatype_t datatype)
24+
{
25+
if (!out || !buf) return;
26+
27+
/* Target is little-endian (ESP32, x86, ARM) */
28+
char *p = (char *)out;
29+
30+
switch (endianness) {
31+
case ENDIANNESS_LBF_LWF: /* low byte first, low word first (LE) */
32+
*p++ = (char)buf[pos + 0];
33+
*p++ = (char)buf[pos + 1];
34+
if (datatype != METER_DATATYPE_INT16) {
35+
*p++ = (char)buf[pos + 2];
36+
*p = (char)buf[pos + 3];
37+
}
38+
break;
39+
40+
case ENDIANNESS_LBF_HWF: /* low byte first, high word first */
41+
if (datatype != METER_DATATYPE_INT16) {
42+
*p++ = (char)buf[pos + 2];
43+
*p++ = (char)buf[pos + 3];
44+
}
45+
*p++ = (char)buf[pos + 0];
46+
*p = (char)buf[pos + 1];
47+
break;
48+
49+
case ENDIANNESS_HBF_LWF: /* high byte first, low word first */
50+
*p++ = (char)buf[pos + 1];
51+
*p++ = (char)buf[pos + 0];
52+
if (datatype != METER_DATATYPE_INT16) {
53+
*p++ = (char)buf[pos + 3];
54+
*p = (char)buf[pos + 2];
55+
}
56+
break;
57+
58+
case ENDIANNESS_HBF_HWF: /* high byte first, high word first (BE) */
59+
if (datatype != METER_DATATYPE_INT16) {
60+
*p++ = (char)buf[pos + 3];
61+
*p++ = (char)buf[pos + 2];
62+
}
63+
*p++ = (char)buf[pos + 1];
64+
*p = (char)buf[pos + 0];
65+
break;
66+
67+
default:
68+
break;
69+
}
70+
}
71+
72+
meter_reading_t meter_decode_value(const uint8_t *buf, uint8_t index,
73+
uint8_t endianness, meter_datatype_t datatype,
74+
int8_t divisor)
75+
{
76+
meter_reading_t result = {0, 0};
77+
78+
if (!buf) return result;
79+
if (datatype >= METER_DATATYPE_MAX) return result;
80+
81+
/* Validate divisor range for pow10 lookup */
82+
int8_t abs_div = (divisor >= 0) ? divisor : (int8_t)(-divisor);
83+
if (abs_div >= 10) return result;
84+
85+
uint8_t reg_size = meter_register_size(datatype);
86+
uint8_t pos = index * reg_size;
87+
88+
if (datatype == METER_DATATYPE_FLOAT32) {
89+
float f_combined = 0.0f;
90+
meter_combine_bytes(&f_combined, buf, pos, endianness, datatype);
91+
if (divisor >= 0) {
92+
result.value = (int32_t)(f_combined / (int32_t)pow10_table[divisor]);
93+
} else {
94+
result.value = (int32_t)(f_combined * (int32_t)pow10_table[-divisor]);
95+
}
96+
} else {
97+
int32_t i_combined = 0;
98+
meter_combine_bytes(&i_combined, buf, pos, endianness, datatype);
99+
if (datatype == METER_DATATYPE_INT16) {
100+
/* Sign extend 16-bit into 32-bit */
101+
i_combined = (int32_t)((int16_t)i_combined);
102+
}
103+
if (divisor >= 0) {
104+
result.value = i_combined / (int32_t)pow10_table[divisor];
105+
} else {
106+
result.value = i_combined * (int32_t)pow10_table[-divisor];
107+
}
108+
}
109+
110+
result.valid = 1;
111+
return result;
112+
}

SmartEVSE-3/src/meter_decode.h

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* meter_decode.h - Pure C meter byte decoding for Modbus energy meters
3+
*
4+
* Extracted from meter.cpp combineBytes() and decodeMeasurement()
5+
* for native testability. No platform dependencies.
6+
*/
7+
8+
#ifndef METER_DECODE_H
9+
#define METER_DECODE_H
10+
11+
#ifdef __cplusplus
12+
extern "C" {
13+
#endif
14+
15+
#include <stdint.h>
16+
17+
/* Endianness modes (byte order + word order) */
18+
#define ENDIANNESS_LBF_LWF 0 /* low byte first, low word first (little endian) */
19+
#define ENDIANNESS_LBF_HWF 1 /* low byte first, high word first */
20+
#define ENDIANNESS_HBF_LWF 2 /* high byte first, low word first */
21+
#define ENDIANNESS_HBF_HWF 3 /* high byte first, high word first (big endian) */
22+
23+
/* Modbus data types */
24+
typedef enum {
25+
METER_DATATYPE_INT32 = 0,
26+
METER_DATATYPE_FLOAT32 = 1,
27+
METER_DATATYPE_INT16 = 2,
28+
METER_DATATYPE_MAX
29+
} meter_datatype_t;
30+
31+
/*
32+
* Decoded measurement result.
33+
*/
34+
typedef struct {
35+
int32_t value; /* Decoded value after endianness conversion and divisor */
36+
uint8_t valid; /* 1 if decode succeeded, 0 on error */
37+
} meter_reading_t;
38+
39+
/*
40+
* Combine raw Modbus bytes into a 32-bit value according to endianness
41+
* and data type. Writes to *out_value (int32_t for INT32/INT16, or
42+
* reinterpreted float bits for FLOAT32).
43+
*
44+
* @param out Output: combined bytes (caller casts to int32_t or float)
45+
* @param buf Input: raw Modbus response data bytes
46+
* @param pos Byte offset into buf where this register starts
47+
* @param endianness ENDIANNESS_* constant
48+
* @param datatype METER_DATATYPE_* constant
49+
*
50+
* The caller must ensure buf has at least pos + 4 bytes available
51+
* (or pos + 2 for INT16).
52+
*/
53+
void meter_combine_bytes(void *out, const uint8_t *buf, uint8_t pos,
54+
uint8_t endianness, meter_datatype_t datatype);
55+
56+
/*
57+
* Decode a single measurement value from a Modbus response buffer.
58+
*
59+
* @param buf Raw data bytes from Modbus response
60+
* @param index Register index (0-based); byte offset = index * register_size
61+
* @param endianness ENDIANNESS_* constant
62+
* @param datatype METER_DATATYPE_* constant
63+
* @param divisor Power-of-10 divisor: positive = divide, negative = multiply
64+
* e.g., divisor=1 divides by 10, divisor=-3 multiplies by 1000
65+
* @return Decoded measurement result
66+
*/
67+
meter_reading_t meter_decode_value(const uint8_t *buf, uint8_t index,
68+
uint8_t endianness, meter_datatype_t datatype,
69+
int8_t divisor);
70+
71+
/*
72+
* Returns the byte size of a single register for the given data type.
73+
* INT16 = 2, INT32/FLOAT32 = 4.
74+
*/
75+
uint8_t meter_register_size(meter_datatype_t datatype);
76+
77+
#ifdef __cplusplus
78+
}
79+
#endif
80+
81+
#endif /* METER_DECODE_H */

0 commit comments

Comments
 (0)