// Unit tests for the Modbus-RTU protocol code in src/modules/SerialModbus.{h,cpp}, used by SerialModule's // MODBUS mode to poll a SenseCAP ONE weather sensor on RS485 and to tunnel the client API over the same bus. // // What is pinned, and why: // - crc16() / buildRead() / checkResponse() against frames whose CRCs were recomputed with a reference // CRC-16/Modbus (poly 0xA001 reflected, init 0xFFFF). A wrong CRC or byte order makes every poll fail // on real hardware while the code still looks plausible. Three response examples in the Seeed manual // carry wrong CRCs; the vectors here are the recomputed ones. // - checkResponse() must reject a frame with a bad CRC, a foreign address, or the wrong register count, // and must report a partial frame as RESP_INCOMPLETE rather than RESP_INVALID: SerialModule polls it every 10 ms // while bytes are still arriving, and a host tunnel frame on the shared bus must never be decoded as a // sensor reading. // - storeRegisters() decodes big-endian signed int32 register pairs; a sign or byte-order slip turns // -1.0 C into a huge positive value. // - Aggregate scales raw milli-units to telemetry units (pressure Pa*1000 -> hPa) and takes the vector // mean of wind direction. An arithmetic mean of 350 and 10 degrees gives 180, the opposite direction. // The mean uses polynomial sin/atan2 (libm's cost 4 KB of STM32 flash); a whole degree in must come // back as the same degree, or the approximation has drifted. // - Tunnel implements the provisioning tunnel (function codes 0x41 write / 0x42 read, address 240): // a retained API frame larger than one read must arrive complete over several reads, a host retry // (same seq and function code) must get the identical answer without advancing the output or applying // input twice, and frames for other addresses must be ignored. Losing or duplicating bytes here // corrupts the 0x94C3 StreamAPI framing the stock client relies on. #include "TestUtil.h" #include #if !MESHTASTIC_EXCLUDE_MODBUS #include "modules/SerialModbus.h" #include #include #include using namespace modbus; static std::vector hex(const char *s) { std::vector out; unsigned v; int n; while (sscanf(s, " %2x%n", &v, &n) == 1) { out.push_back((uint8_t)v); s += n; } return out; } static void assertCrc(const char *frame) { std::vector f = hex(frame); uint16_t crc = crc16(f.data(), f.size() - 2); TEST_ASSERT_EQUAL_HEX8_MESSAGE(crc & 0xFF, f[f.size() - 2], frame); TEST_ASSERT_EQUAL_HEX8_MESSAGE(crc >> 8, f[f.size() - 1], frame); } static void assertRequest(const char *frame, uint8_t addr, uint16_t reg, uint16_t count) { std::vector want = hex(frame); uint8_t out[8]; TEST_ASSERT_EQUAL(8, buildRead(out, addr, READ_INPUT, reg, count)); TEST_ASSERT_EQUAL_HEX8_ARRAY_MESSAGE(want.data(), out, 8, frame); } void setUp(void) {} void tearDown(void) {} // --- CRC and frame builder --- void test_crc_reference_vectors() { const char *frames[] = {"01 04 00 00 00 02 71 CB", "01 04 04 00 00 6E 8C D6 41", "01 04 04 FF FF FC 18 BA AA", "0A 04 00 00 00 06 71 73", "0A 04 00 08 00 0C 70 B6", "0A 04 18 00 00 00 00 00 03 6E 84 00 03 C8 C0 00 00 00 00 00 00 04 BC 00 00 02 10 BC 78", "14 04 00 00 00 20 F3 17", "2B 04 00 00 00 20 F6 18", "2B 04 00 30 00 04 F6 0C", "2B 04 00 40 00 02 77 D5", "2B 04 04 00 0C EC 98 FD 2F", "01 84 01 82 C0", "01 84 04 42 C3", "F0 42 00 41 53", "F0 42 00 00 93 30", "F0 42 01 80 93", "F0 41 05 06 94 C3 00 02 18 01 DD 6D", "F0 41 05 06 E0 62", "F0 C1 01 E1 A3"}; for (const char *f : frames) assertCrc(f); } void test_build_read_requests() { assertRequest("01 04 00 00 00 02 71 CB", 1, 0x0000, 2); assertRequest("0A 04 00 00 00 06 71 73", 10, 0x0000, 6); assertRequest("0A 04 00 08 00 0C 70 B6", 10, 0x0008, 12); assertRequest("14 04 00 00 00 20 F3 17", 20, 0x0000, 0x20); assertRequest("2B 04 00 00 00 20 F6 18", 43, 0x0000, 0x20); assertRequest("2B 04 00 30 00 04 F6 0C", 43, 0x0030, 4); assertRequest("2B 04 00 40 00 02 77 D5", 43, 0x0040, 2); } // --- Response validation and register decode --- void test_temperature_responses_decode_signed() { int32_t raw[REG_SLOTS] = {}; std::vector pos = hex("01 04 04 00 00 6E 8C D6 41"); TEST_ASSERT_EQUAL(RESP_OK, checkResponse(pos.data(), pos.size(), 1, READ_INPUT, 2)); storeRegisters(raw, 0, pos.data() + 3, pos[2]); TEST_ASSERT_EQUAL_INT32(28300, raw[0]); std::vector neg = hex("01 04 04 FF FF FC 18 BA AA"); TEST_ASSERT_EQUAL(RESP_OK, checkResponse(neg.data(), neg.size(), 1, READ_INPUT, 2)); storeRegisters(raw, 0, neg.data() + 3, neg[2]); TEST_ASSERT_EQUAL_INT32(-1000, raw[0]); } void test_s500_wind_block_decodes() { int32_t raw[REG_SLOTS] = {}; std::vector f = hex("0A 04 18 00 00 00 00 00 03 6E 84 00 03 C8 C0 00 00 00 00 00 00 04 BC 00 00 02 10 BC 78"); TEST_ASSERT_EQUAL(RESP_OK, checkResponse(f.data(), f.size(), 10, READ_INPUT, 12)); storeRegisters(raw, 0x0008, f.data() + 3, f[2]); TEST_ASSERT_EQUAL_INT32(0, raw[0x08 / 2]); // min direction TEST_ASSERT_EQUAL_INT32(224900, raw[0x0A / 2]); // max direction TEST_ASSERT_EQUAL_INT32(248000, raw[0x0C / 2]); // avg direction TEST_ASSERT_EQUAL_INT32(0, raw[0x0E / 2]); // min speed TEST_ASSERT_EQUAL_INT32(1212, raw[0x10 / 2]); // max speed TEST_ASSERT_EQUAL_INT32(528, raw[0x12 / 2]); // avg speed } void test_co2_response_decodes() { int32_t raw[REG_SLOTS] = {}; std::vector f = hex("2B 04 04 00 0C EC 98 FD 2F"); TEST_ASSERT_EQUAL(RESP_OK, checkResponse(f.data(), f.size(), 43, READ_INPUT, 2)); storeRegisters(raw, 0x0040, f.data() + 3, f[2]); TEST_ASSERT_EQUAL_INT32(847000, raw[0x40 / 2]); } void test_exception_frames_reported() { std::vector e1 = hex("01 84 01 82 C0"); std::vector e4 = hex("01 84 04 42 C3"); TEST_ASSERT_EQUAL(RESP_EXCEPTION, checkResponse(e1.data(), e1.size(), 1, READ_INPUT, 2)); TEST_ASSERT_EQUAL(RESP_EXCEPTION, checkResponse(e4.data(), e4.size(), 1, READ_INPUT, 2)); } void test_crc_mismatch_rejected() { std::vector f = hex("01 04 04 00 00 6E 8C D6 41"); f[5] ^= 0x01; TEST_ASSERT_EQUAL(RESP_INVALID, checkResponse(f.data(), f.size(), 1, READ_INPUT, 2)); } void test_wrong_address_rejected() { // A tunnel frame for address 240 arriving while a sensor answer is awaited. std::vector f = hex("F0 42 00 41 53"); TEST_ASSERT_EQUAL(RESP_INVALID, checkResponse(f.data(), f.size(), 1, READ_INPUT, 2)); TEST_ASSERT_EQUAL(RESP_INVALID, checkResponse(f.data(), 1, 1, READ_INPUT, 2)); } void test_wrong_register_count_rejected() { std::vector f = hex("01 04 04 00 00 6E 8C D6 41"); TEST_ASSERT_EQUAL(RESP_INVALID, checkResponse(f.data(), f.size(), 1, READ_INPUT, 4)); } void test_short_frame_incomplete() { std::vector f = hex("01 04 04 00 00 6E 8C D6 41"); for (size_t len = 0; len < f.size(); len++) TEST_ASSERT_EQUAL_MESSAGE(RESP_INCOMPLETE, checkResponse(f.data(), len, 1, READ_INPUT, 2), "partial frame"); } // --- Profiles and poll plan --- void test_unknown_address_gets_s700_set() { TEST_ASSERT_EQUAL_HEX8(CAP_THP | CAP_LIGHT | CAP_WIND | CAP_RAIN, capsForAddress(5)); TEST_ASSERT_EQUAL_HEX8(CAP_WIND, capsForAddress(44)); } void test_poll_plan_full_and_split() { uint8_t step = 0; uint16_t reg, count; uint8_t s1000 = capsForAddress(43); TEST_ASSERT_TRUE(nextRead(s1000, false, step, reg, count)); TEST_ASSERT_EQUAL(0x00, reg); TEST_ASSERT_EQUAL(0x20, count); step++; TEST_ASSERT_TRUE(nextRead(s1000, false, step, reg, count)); TEST_ASSERT_EQUAL(0x30, reg); step++; TEST_ASSERT_TRUE(nextRead(s1000, false, step, reg, count)); TEST_ASSERT_EQUAL(0x40, reg); step++; TEST_ASSERT_FALSE(nextRead(s1000, false, step, reg, count)); step = 0; uint8_t s500 = capsForAddress(10); TEST_ASSERT_TRUE(nextRead(s500, true, step, reg, count)); TEST_ASSERT_EQUAL(0x00, reg); TEST_ASSERT_EQUAL(6, count); step++; TEST_ASSERT_TRUE(nextRead(s500, true, step, reg, count)); TEST_ASSERT_EQUAL(0x08, reg); TEST_ASSERT_EQUAL(12, count); step++; TEST_ASSERT_FALSE(nextRead(s500, true, step, reg, count)); } // --- Aggregation --- void test_pressure_scaled_to_hpa() { int32_t raw[REG_SLOTS] = {}; raw[0x04 / 2] = 101160000; Aggregate agg; agg.add(raw); meshtastic_EnvironmentMetrics e = meshtastic_EnvironmentMetrics_init_zero; agg.environment(e, CAP_THP); TEST_ASSERT_TRUE(e.has_barometric_pressure); TEST_ASSERT_FLOAT_WITHIN(0.01f, 1011.6f, e.barometric_pressure); } void test_wind_direction_vector_mean_wraps() { int32_t raw[REG_SLOTS] = {}; Aggregate agg; raw[0x0C / 2] = 350000; agg.add(raw); raw[0x0C / 2] = 10000; agg.add(raw); meshtastic_EnvironmentMetrics e = meshtastic_EnvironmentMetrics_init_zero; agg.environment(e, CAP_WIND); TEST_ASSERT_TRUE(e.has_wind_direction); TEST_ASSERT_EQUAL_UINT16(0, e.wind_direction); } void test_wind_direction_single_sample_round_trips() { // The mean uses polynomial sin/atan2 instead of libm; every whole degree must come back unchanged. int32_t raw[REG_SLOTS] = {}; for (uint16_t deg = 0; deg < 360; deg++) { Aggregate agg; raw[0x0C / 2] = deg * 1000; agg.add(raw); meshtastic_EnvironmentMetrics e = meshtastic_EnvironmentMetrics_init_zero; agg.environment(e, CAP_WIND); TEST_ASSERT_EQUAL_UINT16(deg, e.wind_direction); } } void test_wind_gust_and_lull_are_extremes() { int32_t raw[REG_SLOTS] = {}; Aggregate agg; raw[0x0E / 2] = 500; // min speed raw[0x10 / 2] = 3000; raw[0x12 / 2] = 1000; agg.add(raw); raw[0x0E / 2] = 200; raw[0x10 / 2] = 2000; raw[0x12 / 2] = 2000; agg.add(raw); meshtastic_EnvironmentMetrics e = meshtastic_EnvironmentMetrics_init_zero; agg.environment(e, CAP_WIND); TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.2f, e.wind_lull); TEST_ASSERT_FLOAT_WITHIN(0.001f, 3.0f, e.wind_gust); TEST_ASSERT_FLOAT_WITHIN(0.001f, 1.5f, e.wind_speed); } void test_rain_24h_survives_counter_reset() { // Accumulated counter 10.0 -> 12.5 mm, then the sensor restarts from 0 and reaches 1.0 mm. int32_t raw[REG_SLOTS] = {}; Aggregate agg; raw[0x14 / 2] = 10000; agg.add(raw); raw[0x14 / 2] = 12500; agg.add(raw); agg.nextHour(); raw[0x14 / 2] = 0; agg.add(raw); raw[0x14 / 2] = 1000; agg.add(raw); meshtastic_EnvironmentMetrics e = meshtastic_EnvironmentMetrics_init_zero; agg.environment(e, CAP_RAIN); TEST_ASSERT_FLOAT_WITHIN(0.001f, 3.5f, e.rainfall_24h); } // --- API tunnel --- static const char *const writeFrame = "F0 41 05 06 94 C3 00 02 18 01 DD 6D"; void test_tunnel_reference_frames() { Tunnel t(240); uint8_t resp[256]; const uint8_t *in; size_t inLen; std::vector read0 = hex("F0 42 00 41 53"); std::vector empty = hex("F0 42 00 00 93 30"); TEST_ASSERT_EQUAL(empty.size(), t.handle(read0.data(), read0.size(), resp, in, inLen)); TEST_ASSERT_EQUAL_HEX8_ARRAY(empty.data(), resp, empty.size()); std::vector w = hex(writeFrame); std::vector ack = hex("F0 41 05 06 E0 62"); TEST_ASSERT_EQUAL(ack.size(), t.handle(w.data(), w.size(), resp, in, inLen)); TEST_ASSERT_EQUAL_HEX8_ARRAY(ack.data(), resp, ack.size()); TEST_ASSERT_EQUAL(6, inLen); TEST_ASSERT_EQUAL_HEX8_ARRAY(w.data() + 4, in, 6); } void test_tunnel_frame_length() { Tunnel t(240); std::vector w = hex(writeFrame); TEST_ASSERT_EQUAL(0, t.frameLength(w.data(), 1)); TEST_ASSERT_EQUAL(0, t.frameLength(w.data(), 3)); // length byte not yet seen TEST_ASSERT_EQUAL(w.size(), t.frameLength(w.data(), 4)); std::vector r = hex("F0 42 01 80 93"); TEST_ASSERT_EQUAL(5, t.frameLength(r.data(), 2)); std::vector other = hex("14 04 00 00 00 20 F3 17"); TEST_ASSERT_EQUAL(0, t.frameLength(other.data(), other.size())); } static size_t readSeq(Tunnel &t, uint8_t seq, uint8_t *resp) { uint8_t req[5] = {240, TUNNEL_READ, seq}; uint16_t crc = crc16(req, 3); req[3] = crc & 0xFF; req[4] = crc >> 8; const uint8_t *in; size_t inLen; return t.handle(req, sizeof(req), resp, in, inLen); } void test_tunnel_large_frame_over_three_reads() { Tunnel t(240); uint8_t frame[516]; for (size_t i = 0; i < sizeof(frame); i++) frame[i] = (uint8_t)(i * 7 + 3); t.frame = frame; t.frameLen = sizeof(frame); std::vector got; uint8_t resp[256]; const size_t expect[] = {240, 240, 36}; for (uint8_t seq = 1; seq <= 3; seq++) { size_t n = readSeq(t, seq, resp); TEST_ASSERT_EQUAL(6 + expect[seq - 1], n); TEST_ASSERT_EQUAL(expect[seq - 1], resp[3]); TEST_ASSERT_EQUAL_HEX16(crc16(resp, n - 2), resp[n - 2] | resp[n - 1] << 8); got.insert(got.end(), resp + 4, resp + 4 + resp[3]); } TEST_ASSERT_NULL(t.frame); // released: the next API frame may be dequeued TEST_ASSERT_EQUAL(sizeof(frame), got.size()); TEST_ASSERT_EQUAL_HEX8_ARRAY(frame, got.data(), sizeof(frame)); TEST_ASSERT_EQUAL(6, readSeq(t, 4, resp)); // nothing pending } void test_tunnel_read_retry_resends_without_advancing() { Tunnel t(240); uint8_t frame[300]; for (size_t i = 0; i < sizeof(frame); i++) frame[i] = (uint8_t)i; t.frame = frame; t.frameLen = sizeof(frame); uint8_t first[256], again[256]; size_t n1 = readSeq(t, 9, first); memcpy(again, first, n1); // the caller hands the same buffer back, as SerialModule does size_t n2 = readSeq(t, 9, again); TEST_ASSERT_EQUAL(n1, n2); TEST_ASSERT_EQUAL_HEX8_ARRAY(first, again, n1); uint8_t next[256]; memcpy(next, again, n2); size_t n3 = readSeq(t, 10, next); TEST_ASSERT_EQUAL(6 + 60, n3); TEST_ASSERT_EQUAL_HEX8(240, next[4]); // continues at byte 240, not 480 TEST_ASSERT_NULL(t.frame); } void test_tunnel_repeated_write_applied_once() { Tunnel t(240); std::vector w = hex(writeFrame); uint8_t resp[256]; const uint8_t *in; size_t inLen; size_t n1 = t.handle(w.data(), w.size(), resp, in, inLen); TEST_ASSERT_EQUAL(6, inLen); size_t n2 = t.handle(w.data(), w.size(), resp, in, inLen); TEST_ASSERT_EQUAL(n1, n2); TEST_ASSERT_EQUAL(0, inLen); TEST_ASSERT_NULL(in); } void test_tunnel_ignores_other_address_and_bad_crc() { Tunnel t(240); uint8_t resp[256]; const uint8_t *in; size_t inLen; std::vector other = hex("01 04 00 00 00 02 71 CB"); TEST_ASSERT_EQUAL(0, t.handle(other.data(), other.size(), resp, in, inLen)); std::vector bad = hex(writeFrame); bad[6] ^= 0xFF; TEST_ASSERT_EQUAL(0, t.handle(bad.data(), bad.size(), resp, in, inLen)); TEST_ASSERT_EQUAL(0, inLen); } void test_tunnel_unknown_function_gets_exception() { Tunnel t(240); uint8_t req[4] = {240, 0x03}; uint16_t crc = crc16(req, 2); req[2] = crc & 0xFF; req[3] = crc >> 8; uint8_t resp[256]; const uint8_t *in; size_t inLen; TEST_ASSERT_EQUAL(5, t.handle(req, sizeof(req), resp, in, inLen)); TEST_ASSERT_EQUAL_HEX8(240, resp[0]); TEST_ASSERT_EQUAL_HEX8(0x83, resp[1]); TEST_ASSERT_EQUAL_HEX8(0x01, resp[2]); TEST_ASSERT_EQUAL_HEX16(crc16(resp, 3), resp[3] | resp[4] << 8); } void setup() { initializeTestEnvironment(); UNITY_BEGIN(); RUN_TEST(test_crc_reference_vectors); RUN_TEST(test_build_read_requests); RUN_TEST(test_temperature_responses_decode_signed); RUN_TEST(test_s500_wind_block_decodes); RUN_TEST(test_co2_response_decodes); RUN_TEST(test_exception_frames_reported); RUN_TEST(test_crc_mismatch_rejected); RUN_TEST(test_wrong_address_rejected); RUN_TEST(test_wrong_register_count_rejected); RUN_TEST(test_short_frame_incomplete); RUN_TEST(test_unknown_address_gets_s700_set); RUN_TEST(test_poll_plan_full_and_split); RUN_TEST(test_pressure_scaled_to_hpa); RUN_TEST(test_wind_direction_vector_mean_wraps); RUN_TEST(test_wind_direction_single_sample_round_trips); RUN_TEST(test_wind_gust_and_lull_are_extremes); RUN_TEST(test_rain_24h_survives_counter_reset); RUN_TEST(test_tunnel_reference_frames); RUN_TEST(test_tunnel_frame_length); RUN_TEST(test_tunnel_large_frame_over_three_reads); RUN_TEST(test_tunnel_read_retry_resends_without_advancing); RUN_TEST(test_tunnel_repeated_write_applied_once); RUN_TEST(test_tunnel_ignores_other_address_and_bad_crc); RUN_TEST(test_tunnel_unknown_function_gets_exception); exit(UNITY_END()); } #else void setUp(void) {} void tearDown(void) {} void setup() { initializeTestEnvironment(); UNITY_BEGIN(); exit(UNITY_END()); } #endif void loop() {}