Skip to content

Commit c9b38ff

Browse files
basmeermanclaude
andcommitted
feat: MQTT, REST, HA discovery for CircuitMeter (Plan 14, Increment 4) (#109)
Publish circuit current/power/energy via MQTT with HA auto-discovery. Add Set/MaxCircuitMains and Set/CircuitMeter topics. REST /settings integration for circuit meter configuration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e6ec5ee commit c9b38ff

File tree

6 files changed

+280
-2
lines changed

6 files changed

+280
-2
lines changed

SmartEVSE-3/src/esp32.cpp

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,28 @@ void mqtt_receive_callback(const String topic, const String payload) {
837837
break;
838838
// END PLAN-09
839839

840+
// BEGIN PLAN-14: CircuitMeter MQTT commands
841+
case MQTT_CMD_MAX_CIRCUIT_MAINS:
842+
if (LoadBl < 2)
843+
MaxCircuitMains = cmd.max_circuit_mains;
844+
break;
845+
846+
case MQTT_CMD_CIRCUIT_METER:
847+
if (CircuitMeter.Type != EM_API || LoadBl >= 2)
848+
return;
849+
#if SMARTEVSE_VERSION < 40
850+
CircuitMeter.setTimeout(COMM_TIMEOUT);
851+
CircuitMeter.Irms[0] = cmd.circuit_meter.L1;
852+
CircuitMeter.Irms[1] = cmd.circuit_meter.L2;
853+
CircuitMeter.Irms[2] = cmd.circuit_meter.L3;
854+
CircuitMeter.CalcImeasured();
855+
#else
856+
Serial1.printf("@Irms:%03u,%d,%d,%d\n", CircuitMeter.Address,
857+
(int)cmd.circuit_meter.L1, (int)cmd.circuit_meter.L2, (int)cmd.circuit_meter.L3);
858+
#endif
859+
break;
860+
// END PLAN-14
861+
840862
// BEGIN PLAN-09: HomeWizard manual IP fallback
841863
case MQTT_CMD_HOMEWIZARD_IP:
842864
homeWizardManualIP = cmd.homewizard_ip;
@@ -1029,6 +1051,29 @@ void SetupMQTTClient() {
10291051
MQTTclient.announce("EV Total Energy Charged", "sensor", optional_payload);
10301052
}
10311053

1054+
// BEGIN PLAN-14: CircuitMeter HA discovery
1055+
if (CircuitMeter.Type) {
1056+
// Circuit current sensors
1057+
optional_payload = MQTTclient.jsna("device_class","current") + MQTTclient.jsna("state_class","measurement") + MQTTclient.jsna("unit_of_measurement","A") + MQTTclient.jsna("value_template", R"({{ value | int / 10 }})");
1058+
MQTTclient.announce("Circuit Current L1", "sensor", optional_payload);
1059+
MQTTclient.announce("Circuit Current L2", "sensor", optional_payload);
1060+
MQTTclient.announce("Circuit Current L3", "sensor", optional_payload);
1061+
1062+
// Circuit power sensor
1063+
optional_payload = MQTTclient.jsna("device_class","power") + MQTTclient.jsna("state_class","measurement") + MQTTclient.jsna("unit_of_measurement","W");
1064+
MQTTclient.announce("Circuit Power", "sensor", optional_payload);
1065+
1066+
// Circuit energy sensors
1067+
optional_payload = MQTTclient.jsna("device_class","energy") + MQTTclient.jsna("unit_of_measurement","Wh") + MQTTclient.jsna("state_class","total_increasing");
1068+
MQTTclient.announce("Circuit Import Energy", "sensor", optional_payload);
1069+
MQTTclient.announce("Circuit Export Energy", "sensor", optional_payload);
1070+
}
1071+
1072+
// MaxCircuitMains number entity (always announced, even when CircuitMeter disabled)
1073+
optional_payload = MQTTclient.jsna("device_class","current") + MQTTclient.jsna("unit_of_measurement","A") + MQTTclient.jsna("command_topic", String(MQTTprefix + "/Set/MaxCircuitMains")) + MQTTclient.jsna("min", "0") + MQTTclient.jsna("max", "600") + MQTTclient.jsna("mode","box");
1074+
MQTTclient.announce("Max Circuit Mains", "number", optional_payload);
1075+
// END PLAN-14
1076+
10321077
//set the parameters for and MQTTclient.announce sensor entities without device_class or unit_of_measurement:
10331078
optional_payload = "";
10341079
MQTTclient.announce("EV Plug State", "sensor", optional_payload);
@@ -1241,6 +1286,19 @@ void mqttPublishData() {
12411286
if (EVMeter.EnergyPhase[2] > 0)
12421287
mqtt_pub_int(MQTT_SLOT_EV_ENERGY_L3, "/EVEnergyL3", EVMeter.EnergyPhase[2], false, now_s);
12431288
}
1289+
// BEGIN PLAN-14: CircuitMeter publishing
1290+
if (CircuitMeter.Type) {
1291+
mqtt_pub_int(MQTT_SLOT_CIRCUIT_L1, "/CircuitCurrentL1", CircuitMeter.Irms[0], false, now_s);
1292+
mqtt_pub_int(MQTT_SLOT_CIRCUIT_L2, "/CircuitCurrentL2", CircuitMeter.Irms[1], false, now_s);
1293+
mqtt_pub_int(MQTT_SLOT_CIRCUIT_L3, "/CircuitCurrentL3", CircuitMeter.Irms[2], false, now_s);
1294+
mqtt_pub_int(MQTT_SLOT_CIRCUIT_POWER, "/CircuitPower", CircuitMeter.PowerMeasured, false, now_s);
1295+
if (CircuitMeter.Import_active_energy > 0)
1296+
mqtt_pub_int(MQTT_SLOT_CIRCUIT_IMPORT_ENERGY, "/CircuitImportEnergy", CircuitMeter.Import_active_energy, false, now_s);
1297+
if (CircuitMeter.Export_active_energy > 0)
1298+
mqtt_pub_int(MQTT_SLOT_CIRCUIT_EXPORT_ENERGY, "/CircuitExportEnergy", CircuitMeter.Export_active_energy, false, now_s);
1299+
}
1300+
mqtt_pub_int(MQTT_SLOT_MAX_CIRCUIT_MAINS, "/MaxCircuitMains", MaxCircuitMains, true, now_s);
1301+
// END PLAN-14
12441302
mqtt_pub_int(MQTT_SLOT_ESP_TEMP, "/ESPTemp", TempEVSE, false, now_s);
12451303
mqtt_pub_str(MQTT_SLOT_MODE, "/Mode", AccessStatus == OFF ? "Off" : AccessStatus == PAUSE ? "Pause" : Mode > 3 ? "N/A" : StrMode[Mode], true, now_s);
12461304
mqtt_pub_int(MQTT_SLOT_MAX_CURRENT, "/MaxCurrent", MaxCurrent * 10, true, now_s);

SmartEVSE-3/src/http_handlers.cpp

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ extern capacity_state_t CapacityState;
6363
extern uint16_t MaxCurrent;
6464
extern uint16_t MinCurrent;
6565
extern uint16_t MaxCircuit;
66+
extern uint16_t MaxCircuitMains;
6667
extern uint16_t StartCurrent;
6768
extern uint16_t StopTime;
6869
extern uint16_t ImportCurrent;
@@ -166,7 +167,7 @@ bool handle_URI(struct mg_connection *c, struct mg_http_message *hm, webServerR
166167

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

169-
DynamicJsonDocument doc(3700); // https://arduinojson.org/v6/assistant/ (3200 + nodes array)
170+
DynamicJsonDocument doc(4096); // https://arduinojson.org/v6/assistant/ (3200 + nodes + circuit_meter)
170171
doc["version"] = String(VERSION);
171172
doc["serialnr"] = serialnr;
172173
doc["mode"] = mode;
@@ -223,6 +224,9 @@ bool handle_URI(struct mg_connection *c, struct mg_http_message *hm, webServerR
223224
doc["settings"]["current_main"] = MaxMains;
224225
doc["settings"]["current_max_circuit"] = MaxCircuit;
225226
doc["settings"]["current_max_sum_mains"] = MaxSumMains;
227+
doc["settings"]["circuit_meter_type"] = CircuitMeter.Type;
228+
doc["settings"]["circuit_meter_address"] = CircuitMeter.Address;
229+
doc["settings"]["max_circuit_mains"] = MaxCircuitMains;
226230
doc["settings"]["max_sum_mains_time"] = MaxSumMainsTime;
227231
doc["settings"]["solar_max_import"] = ImportCurrent;
228232
doc["settings"]["solar_start_current"] = StartCurrent;
@@ -344,6 +348,20 @@ bool handle_URI(struct mg_connection *c, struct mg_http_message *hm, webServerR
344348
doc["mains_meter"]["host"] = !homeWizardHost.isEmpty() ? homeWizardHost : "HomeWizard P1 Not Found";
345349
}
346350

351+
// BEGIN PLAN-14: CircuitMeter data in /settings GET
352+
if (CircuitMeter.Type) {
353+
doc["circuit_meter"]["description"] = EMConfig[CircuitMeter.Type].Desc;
354+
doc["circuit_meter"]["address"] = CircuitMeter.Address;
355+
doc["circuit_meter"]["import_active_energy"] = CircuitMeter.Import_active_energy;
356+
doc["circuit_meter"]["export_active_energy"] = CircuitMeter.Export_active_energy;
357+
doc["circuit_meter"]["power"] = CircuitMeter.PowerMeasured;
358+
doc["circuit_meter"]["currents"]["TOTAL"] = CircuitMeter.Irms[0] + CircuitMeter.Irms[1] + CircuitMeter.Irms[2];
359+
doc["circuit_meter"]["currents"]["L1"] = CircuitMeter.Irms[0];
360+
doc["circuit_meter"]["currents"]["L2"] = CircuitMeter.Irms[1];
361+
doc["circuit_meter"]["currents"]["L3"] = CircuitMeter.Irms[2];
362+
}
363+
// END PLAN-14
364+
347365
doc["phase_currents"]["TOTAL"] = MainsMeter.Irms[0] + MainsMeter.Irms[1] + MainsMeter.Irms[2];
348366
doc["phase_currents"]["L1"] = MainsMeter.Irms[0];
349367
doc["phase_currents"]["L2"] = MainsMeter.Irms[1];
@@ -431,6 +449,18 @@ bool handle_URI(struct mg_connection *c, struct mg_http_message *hm, webServerR
431449
}
432450
}
433451

452+
// BEGIN PLAN-14: MaxCircuitMains via REST
453+
if(request->hasParam("max_circuit_mains")) {
454+
int current = request->getParam("max_circuit_mains")->value().toInt();
455+
if (LoadBl < 2 && (current == 0 || (current >= 10 && current <= 600))) {
456+
MaxCircuitMains = current;
457+
doc["max_circuit_mains"] = MaxCircuitMains;
458+
} else {
459+
doc["max_circuit_mains"] = "Value not allowed!";
460+
}
461+
}
462+
// END PLAN-14
463+
434464
if(request->hasParam("max_sum_mains_timer")) {
435465
int time = request->getParam("max_sum_mains_timer")->value().toInt();
436466
if(time >= 0 && time <= 60 && LoadBl < 2) {
@@ -896,6 +926,28 @@ bool handle_URI(struct mg_connection *c, struct mg_http_message *hm, webServerR
896926
}
897927
}
898928

929+
// BEGIN PLAN-14: CircuitMeter API feed via /currents POST
930+
if(CircuitMeter.Type == EM_API) {
931+
if(request->hasParam("circuit_L1") && request->hasParam("circuit_L2") && request->hasParam("circuit_L3")) {
932+
if (LoadBl < 2) {
933+
#if SMARTEVSE_VERSION < 40 //v3
934+
CircuitMeter.Irms[0] = request->getParam("circuit_L1")->value().toInt();
935+
CircuitMeter.Irms[1] = request->getParam("circuit_L2")->value().toInt();
936+
CircuitMeter.Irms[2] = request->getParam("circuit_L3")->value().toInt();
937+
CircuitMeter.CalcImeasured();
938+
CircuitMeter.setTimeout(COMM_TIMEOUT);
939+
#else //v4
940+
Serial1.printf("@Irms:%03u,%d,%d,%d\n", CircuitMeter.Address, (int16_t) request->getParam("circuit_L1")->value().toInt(), (int16_t) request->getParam("circuit_L2")->value().toInt(), (int16_t) request->getParam("circuit_L3")->value().toInt());
941+
#endif
942+
for (int x = 0; x < 3; x++)
943+
doc["circuit"]["L" + x] = CircuitMeter.Irms[x];
944+
doc["circuit"]["TOTAL"] = CircuitMeter.Irms[0] + CircuitMeter.Irms[1] + CircuitMeter.Irms[2];
945+
} else
946+
doc["circuit"]["TOTAL"] = "not allowed on slave";
947+
}
948+
}
949+
// END PLAN-14
950+
899951
String json;
900952
serializeJson(doc, json);
901953
mg_http_reply(c, 200, "Content-Type: application/json\r\n", "%s\r\n", json.c_str()); // Yes. Respond JSON

SmartEVSE-3/src/mqtt_parser.c

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,24 @@ bool mqtt_parse_command(const char *prefix, const char *topic,
360360
return true;
361361
}
362362

363+
/* CircuitMeter: max circuit mains current (0 = disabled, 10-600 A) */
364+
if (match_topic(prefix, topic, "/Set/MaxCircuitMains")) {
365+
out->cmd = MQTT_CMD_MAX_CIRCUIT_MAINS;
366+
int val = atoi(payload);
367+
if (val == 0 || (val >= 10 && val <= 600)) {
368+
out->max_circuit_mains = (uint16_t)val;
369+
return true;
370+
}
371+
return false;
372+
}
373+
374+
/* CircuitMeter API feed: L1:L2:L3 format (same as MainsMeter) */
375+
if (match_topic(prefix, topic, "/Set/CircuitMeter")) {
376+
out->cmd = MQTT_CMD_CIRCUIT_METER;
377+
return mqtt_parse_mains_meter(payload, &out->circuit_meter.L1,
378+
&out->circuit_meter.L2, &out->circuit_meter.L3);
379+
}
380+
363381
if (match_topic(prefix, topic, "/Set/DiagProfile")) {
364382
out->cmd = MQTT_CMD_DIAG_PROFILE;
365383
if (strcmp(payload, "off") == 0 || strcmp(payload, "0") == 0) {

SmartEVSE-3/src/mqtt_parser.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ typedef enum {
3838
MQTT_CMD_ENERGY_REQUEST,
3939
MQTT_CMD_EVCCID_SET,
4040
MQTT_CMD_CAPACITY_LIMIT,
41+
MQTT_CMD_MAX_CIRCUIT_MAINS,
42+
MQTT_CMD_CIRCUIT_METER,
4143
} mqtt_cmd_type_t;
4244

4345
// Mode values matching firmware MODE_NORMAL/MODE_SOLAR/MODE_SMART
@@ -83,6 +85,8 @@ typedef struct {
8385
int32_t energy_capacity; // MQTT_CMD_ENERGY_CAPACITY (-1 or 0-200000 Wh)
8486
int32_t energy_request; // MQTT_CMD_ENERGY_REQUEST (-1 or 0-200000 Wh)
8587
uint16_t capacity_limit; // MQTT_CMD_CAPACITY_LIMIT (0=disabled, max 25000W)
88+
uint16_t max_circuit_mains; // MQTT_CMD_MAX_CIRCUIT_MAINS (0-600)
89+
struct { int32_t L1, L2, L3; } circuit_meter; // MQTT_CMD_CIRCUIT_METER
8690
};
8791
} mqtt_command_t;
8892

SmartEVSE-3/src/mqtt_publish.h

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
extern "C" {
1010
#endif
1111

12-
#define MQTT_CACHE_MAX_SLOTS 88 /* 85 current topics + headroom */
12+
#define MQTT_CACHE_MAX_SLOTS 96 /* 92 current topics + headroom */
1313

1414
/* One slot per published MQTT topic, in mqttPublishData() call order */
1515
typedef enum {
@@ -98,6 +98,13 @@ typedef enum {
9898
MQTT_SLOT_CAPACITY_WINDOW_AVG,
9999
MQTT_SLOT_CAPACITY_MONTHLY_PEAK,
100100
MQTT_SLOT_CAPACITY_HEADROOM,
101+
MQTT_SLOT_CIRCUIT_L1,
102+
MQTT_SLOT_CIRCUIT_L2,
103+
MQTT_SLOT_CIRCUIT_L3,
104+
MQTT_SLOT_CIRCUIT_POWER,
105+
MQTT_SLOT_CIRCUIT_IMPORT_ENERGY,
106+
MQTT_SLOT_CIRCUIT_EXPORT_ENERGY,
107+
MQTT_SLOT_MAX_CIRCUIT_MAINS,
101108
MQTT_SLOT_COUNT /* must be <= MQTT_CACHE_MAX_SLOTS */
102109
} mqtt_slot_t;
103110

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

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,132 @@ void test_capacity_limit_non_numeric(void) {
11881188
TEST_ASSERT_FALSE(mqtt_parse_command(PREFIX, PREFIX "/Set/CapacityLimit", "abc", &cmd));
11891189
}
11901190

1191+
// ---- MaxCircuitMains ----
1192+
1193+
/*
1194+
* @feature MQTT Command Parsing
1195+
* @req REQ-CIR-010
1196+
* @scenario Set MaxCircuitMains to valid value via MQTT
1197+
* @given A valid MQTT prefix
1198+
* @when Topic is prefix/Set/MaxCircuitMains with payload "25"
1199+
* @then Command type is MQTT_CMD_MAX_CIRCUIT_MAINS with value 25
1200+
*/
1201+
void test_max_circuit_mains_valid(void) {
1202+
TEST_ASSERT_TRUE(mqtt_parse_command(PREFIX, PREFIX "/Set/MaxCircuitMains", "25", &cmd));
1203+
TEST_ASSERT_EQUAL_INT(MQTT_CMD_MAX_CIRCUIT_MAINS, cmd.cmd);
1204+
TEST_ASSERT_EQUAL_INT(25, cmd.max_circuit_mains);
1205+
}
1206+
1207+
/*
1208+
* @feature MQTT Command Parsing
1209+
* @req REQ-CIR-010
1210+
* @scenario Set MaxCircuitMains to zero (disable) via MQTT
1211+
* @given A valid MQTT prefix
1212+
* @when Topic is prefix/Set/MaxCircuitMains with payload "0"
1213+
* @then Command type is MQTT_CMD_MAX_CIRCUIT_MAINS with value 0
1214+
*/
1215+
void test_max_circuit_mains_zero(void) {
1216+
TEST_ASSERT_TRUE(mqtt_parse_command(PREFIX, PREFIX "/Set/MaxCircuitMains", "0", &cmd));
1217+
TEST_ASSERT_EQUAL_INT(MQTT_CMD_MAX_CIRCUIT_MAINS, cmd.cmd);
1218+
TEST_ASSERT_EQUAL_INT(0, cmd.max_circuit_mains);
1219+
}
1220+
1221+
/*
1222+
* @feature MQTT Command Parsing
1223+
* @req REQ-CIR-010
1224+
* @scenario Set MaxCircuitMains to boundary max (600) via MQTT
1225+
* @given A valid MQTT prefix
1226+
* @when Topic is prefix/Set/MaxCircuitMains with payload "600"
1227+
* @then Command type is MQTT_CMD_MAX_CIRCUIT_MAINS with value 600
1228+
*/
1229+
void test_max_circuit_mains_max(void) {
1230+
TEST_ASSERT_TRUE(mqtt_parse_command(PREFIX, PREFIX "/Set/MaxCircuitMains", "600", &cmd));
1231+
TEST_ASSERT_EQUAL_INT(MQTT_CMD_MAX_CIRCUIT_MAINS, cmd.cmd);
1232+
TEST_ASSERT_EQUAL_INT(600, cmd.max_circuit_mains);
1233+
}
1234+
1235+
/*
1236+
* @feature MQTT Input Validation
1237+
* @req REQ-CIR-010
1238+
* @scenario Reject MaxCircuitMains below minimum (1-9 range)
1239+
* @given A valid MQTT prefix
1240+
* @when Topic is prefix/Set/MaxCircuitMains with payload "5"
1241+
* @then Parsing returns false (gap between 0 and 10)
1242+
*/
1243+
void test_max_circuit_mains_below_min(void) {
1244+
TEST_ASSERT_FALSE(mqtt_parse_command(PREFIX, PREFIX "/Set/MaxCircuitMains", "5", &cmd));
1245+
}
1246+
1247+
/*
1248+
* @feature MQTT Input Validation
1249+
* @req REQ-CIR-010
1250+
* @scenario Reject MaxCircuitMains above maximum
1251+
* @given A valid MQTT prefix
1252+
* @when Topic is prefix/Set/MaxCircuitMains with payload "601"
1253+
* @then Parsing returns false
1254+
*/
1255+
void test_max_circuit_mains_above_max(void) {
1256+
TEST_ASSERT_FALSE(mqtt_parse_command(PREFIX, PREFIX "/Set/MaxCircuitMains", "601", &cmd));
1257+
}
1258+
1259+
// ---- CircuitMeter ----
1260+
1261+
/*
1262+
* @feature MQTT Command Parsing
1263+
* @req REQ-CIR-011
1264+
* @scenario Set CircuitMeter API feed via MQTT with L1:L2:L3 format
1265+
* @given A valid MQTT prefix
1266+
* @when Topic is prefix/Set/CircuitMeter with payload "100:200:150"
1267+
* @then Command type is MQTT_CMD_CIRCUIT_METER with parsed phase currents
1268+
*/
1269+
void test_circuit_meter_valid(void) {
1270+
TEST_ASSERT_TRUE(mqtt_parse_command(PREFIX, PREFIX "/Set/CircuitMeter", "100:200:150", &cmd));
1271+
TEST_ASSERT_EQUAL_INT(MQTT_CMD_CIRCUIT_METER, cmd.cmd);
1272+
TEST_ASSERT_EQUAL_INT(100, cmd.circuit_meter.L1);
1273+
TEST_ASSERT_EQUAL_INT(200, cmd.circuit_meter.L2);
1274+
TEST_ASSERT_EQUAL_INT(150, cmd.circuit_meter.L3);
1275+
}
1276+
1277+
/*
1278+
* @feature MQTT Command Parsing
1279+
* @req REQ-CIR-011
1280+
* @scenario CircuitMeter API feed with negative values (export)
1281+
* @given A valid MQTT prefix
1282+
* @when Topic is prefix/Set/CircuitMeter with payload "-50:100:-25"
1283+
* @then Command type is MQTT_CMD_CIRCUIT_METER with correct phase currents
1284+
*/
1285+
void test_circuit_meter_negative(void) {
1286+
TEST_ASSERT_TRUE(mqtt_parse_command(PREFIX, PREFIX "/Set/CircuitMeter", "-50:100:-25", &cmd));
1287+
TEST_ASSERT_EQUAL_INT(MQTT_CMD_CIRCUIT_METER, cmd.cmd);
1288+
TEST_ASSERT_EQUAL_INT(-50, cmd.circuit_meter.L1);
1289+
TEST_ASSERT_EQUAL_INT(100, cmd.circuit_meter.L2);
1290+
TEST_ASSERT_EQUAL_INT(-25, cmd.circuit_meter.L3);
1291+
}
1292+
1293+
/*
1294+
* @feature MQTT Input Validation
1295+
* @req REQ-CIR-011
1296+
* @scenario Reject CircuitMeter with out of range values
1297+
* @given A valid MQTT prefix
1298+
* @when Topic is prefix/Set/CircuitMeter with payload "2001:0:0"
1299+
* @then Parsing returns false (exceeds +/-2000 dA range)
1300+
*/
1301+
void test_circuit_meter_out_of_range(void) {
1302+
TEST_ASSERT_FALSE(mqtt_parse_command(PREFIX, PREFIX "/Set/CircuitMeter", "2001:0:0", &cmd));
1303+
}
1304+
1305+
/*
1306+
* @feature MQTT Input Validation
1307+
* @req REQ-CIR-011
1308+
* @scenario Reject CircuitMeter with missing fields
1309+
* @given A valid MQTT prefix
1310+
* @when Topic is prefix/Set/CircuitMeter with payload "100:200"
1311+
* @then Parsing returns false (needs 3 fields)
1312+
*/
1313+
void test_circuit_meter_missing_fields(void) {
1314+
TEST_ASSERT_FALSE(mqtt_parse_command(PREFIX, PREFIX "/Set/CircuitMeter", "100:200", &cmd));
1315+
}
1316+
11911317
// ---- Unrecognized topic ----
11921318

11931319
/*
@@ -1351,6 +1477,19 @@ int main(void) {
13511477
RUN_TEST(test_capacity_limit_empty);
13521478
RUN_TEST(test_capacity_limit_non_numeric);
13531479

1480+
// MaxCircuitMains (Plan 14)
1481+
RUN_TEST(test_max_circuit_mains_valid);
1482+
RUN_TEST(test_max_circuit_mains_zero);
1483+
RUN_TEST(test_max_circuit_mains_max);
1484+
RUN_TEST(test_max_circuit_mains_below_min);
1485+
RUN_TEST(test_max_circuit_mains_above_max);
1486+
1487+
// CircuitMeter (Plan 14)
1488+
RUN_TEST(test_circuit_meter_valid);
1489+
RUN_TEST(test_circuit_meter_negative);
1490+
RUN_TEST(test_circuit_meter_out_of_range);
1491+
RUN_TEST(test_circuit_meter_missing_fields);
1492+
13541493
// Unrecognized
13551494
RUN_TEST(test_unrecognized_topic);
13561495
RUN_TEST(test_wrong_prefix);

0 commit comments

Comments
 (0)