diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 11ac8d9b0..281a1d799 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -224,6 +224,7 @@ jobs: python3 -B test/test_meshtracker_x1_battery.py -v python3 -B test/test_drv2605_power.py -v python3 -B test/test_radio_interrupt_recovery.py -v + python3 -B test/test_forwarding_profile_timing.py -v python3 -B test/test_bridge_recovery.py -v python3 -B test/test_bridge_ingress.py -v diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 0700d92da..fbe801f5e 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -722,11 +722,13 @@ int MyMesh::calcRxDelay(float score, uint32_t air_time) const { } uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); + uint32_t t = (_radio->getProfileAirtime(packet->radio_profile, + packet->getRawLength(), packet->tx_cr) * _prefs.tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); + uint32_t t = (_radio->getProfileAirtime(packet->radio_profile, + packet->getRawLength(), packet->tx_cr) * _prefs.direct_tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 83695245c..4b9d292b9 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1408,17 +1408,19 @@ int MyMesh::calcRxDelayForPacket(const mesh::Packet* packet, float score, } uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); + uint32_t t = (_radio->getProfileAirtime(packet->radio_profile, + packet->getRawLength(), packet->tx_cr) * _prefs.tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } uint32_t MyMesh::getSlowScopeRetransmitDelay(const mesh::Packet* packet) { uint32_t airtime = - _radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2); + _radio->getProfileAirtime(packet->radio_profile, packet->getRawLength(), packet->tx_cr); uint32_t max_delay = FloodFilterPolicy::slowScopeMaxDelay(airtime); return getRNG()->nextInt(0, max_delay + 1); } uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); + uint32_t t = (_radio->getProfileAirtime(packet->radio_profile, + packet->getRawLength(), packet->tx_cr) * _prefs.direct_tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 6b93f4ab4..36c235566 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1543,8 +1543,8 @@ int MyMesh::calcRxDelayForPacket(const mesh::Packet* packet, float score, uint32_t MyMesh::getSlowFloodRuleRetransmitDelay( const mesh::Packet* packet) { - uint32_t airtime = _radio->getEstAirtimeFor( - packet->getPathByteLen() + packet->payload_len + 2); + uint32_t airtime = _radio->getProfileAirtime( + packet->radio_profile, packet->getRawLength(), packet->tx_cr); uint32_t max_delay = FloodFilterPolicy::slowScopeMaxDelay(airtime); return getRNG()->nextInt(0, max_delay + 1); } @@ -1560,11 +1560,13 @@ const char *MyMesh::getLogDateTime() { } uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); + uint32_t t = (_radio->getProfileAirtime(packet->radio_profile, + packet->getRawLength(), packet->tx_cr) * _prefs.tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); + uint32_t t = (_radio->getProfileAirtime(packet->radio_profile, + packet->getRawLength(), packet->tx_cr) * _prefs.direct_tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index fdafb27cd..02e08852d 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -442,11 +442,13 @@ int SensorMesh::calcRxDelay(float score, uint32_t air_time) const { } uint32_t SensorMesh::getRetransmitDelay(const mesh::Packet* packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); + uint32_t t = (_radio->getProfileAirtime(packet->radio_profile, + packet->getRawLength(), packet->tx_cr) * _prefs.tx_delay_factor); return getRNG()->nextInt(0, 6)*t; } uint32_t SensorMesh::getDirectRetransmitDelay(const mesh::Packet* packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); + uint32_t t = (_radio->getProfileAirtime(packet->radio_profile, + packet->getRawLength(), packet->tx_cr) * _prefs.direct_tx_delay_factor); return getRNG()->nextInt(0, 6)*t; } diff --git a/test/test_forwarding_profile_timing.py b/test/test_forwarding_profile_timing.py new file mode 100644 index 000000000..a8cf5535a --- /dev/null +++ b/test/test_forwarding_profile_timing.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Run actual role forwarding delays against packet-specific radio metadata.""" +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +ROLES = ( + ("companion_radio", "MyMesh", None), + ("simple_repeater", "MyMesh", "getSlowScopeRetransmitDelay"), + ("simple_room_server", "MyMesh", "getSlowFloodRuleRetransmitDelay"), + ("simple_sensor", "SensorMesh", None), +) +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +struct Radio { + uint8_t active = 0, observed_profile = 255, observed_cr = 255; + int observed_length = -1; + unsigned legacy_calls = 0, profile_calls = 0; + static uint32_t airtime(uint8_t profile, int length, uint8_t cr) { + return (17U + 113U * profile) * uint32_t(length) + 7U * cr + 1; + } + uint32_t getEstAirtimeFor(int length) { + ++legacy_calls; + return airtime(active, length, 5); + } + uint32_t getProfileAirtime(uint8_t profile, int length, uint8_t cr = 0) { + ++profile_calls; + observed_profile = profile; observed_length = length; observed_cr = cr; + return airtime(profile, length, cr); + } +}; +struct Rng { + unsigned choice = 0, calls = 0; + uint32_t lower = 0, upper = 0; + uint32_t nextInt(uint32_t minimum, uint32_t maximum) { + assert(maximum > minimum); + ++calls; lower = minimum; upper = maximum; + return minimum + (choice == 0 ? 0 : choice == 1 + ? (maximum - minimum - 1) / 2 : maximum - minimum - 1); + } +}; +struct Prefs { float tx_delay_factor = 0, direct_tx_delay_factor = 0; }; +template +unsigned exercise(uint32_t (Node::*method)(const mesh::Packet*), bool sensor, bool slow) { + unsigned cases = 0; + for (uint8_t profile : {1, 0}) for (uint8_t active : {0, 1}) + for (uint8_t route : {ROUTE_TYPE_TRANSPORT_FLOOD, ROUTE_TYPE_FLOOD, + ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT}) + for (uint8_t hash_size : {3, 1, 2}) for (uint8_t hops : {7, 0}) + for (uint16_t payload : {77, 0, 184}) for (uint8_t cr : {8, 0, 4, 5}) + for (float factor : {1.5f, 0.25f, 0.0f}) for (unsigned choice : {2, 1, 0}) { + Node node; Radio radio; radio.active = active; node._radio = &radio; + node._prefs.tx_delay_factor = node._prefs.direct_tx_delay_factor = factor; + node.rng.choice = choice; + mesh::Packet packet; + packet.header = uint8_t(route | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT)); + packet.setPathHashSizeAndCount(hash_size, hops); + packet.payload_len = payload; packet.radio_profile = profile; packet.tx_cr = cr; + packet.transport_codes[0] = 0x1234; packet.transport_codes[1] = 0x5678; + memset(packet.path, 0x21, sizeof(packet.path)); + memset(packet.payload, 0x43, sizeof(packet.payload)); + // Serialization is an independent length boundary, including transport codes. + uint8_t wire[MAX_TRANS_UNIT]; + const unsigned wire_length = packet.writeTo(wire); + const uint32_t air = Radio::airtime(profile, wire_length, cr); + const uint32_t scaled = uint32_t(air * factor); + const uint32_t maximum = slow ? 10U * air : 5U * scaled; + const uint32_t upper = sensor ? 6 : maximum + 1; + const uint32_t picked = choice == 0 ? 0 : choice == 1 ? (upper - 1) / 2 : upper - 1; + const uint32_t expected = sensor ? picked * scaled : picked; + const uint32_t delay = (node.*method)(&packet); + if (!cases && delay != expected) fprintf(stderr, + "timing mismatch: observed=%u expected=%u profile=%u active=%u bytes=%u CR=%u\n", + delay, expected, profile, active, wire_length, cr); + assert(radio.profile_calls == 1 && radio.legacy_calls == 0 + && "forwarding must use packet-aware airtime"); + assert(radio.observed_profile == profile && "timing must follow packet profile"); + assert(radio.observed_length == int(wire_length) && "timing must include complete wire frame"); + assert(radio.observed_cr == cr && "timing must include packet coding rate"); + assert(node.rng.calls == 1 && node.rng.lower == 0 && node.rng.upper == upper); + assert(delay == expected && "existing role delay distribution must be preserved"); + ++cases; + } + return cases; +} +''' + + +class ForwardingProfileTimingTests(unittest.TestCase): + def run_fixture(self, mutation=None): + source, calls = HARNESS, [] + for index, (role, owner, slow) in enumerate(ROLES): + methods = ["getRetransmitDelay", "getDirectRetransmitDelay"] + ([slow] if slow else []) + production = (ROOT / f"examples/{role}/{owner}.cpp").read_text() + source += f"\nnamespace role{index} {{\nclass {owner} {{\npublic:\n" + source += "Radio* _radio = nullptr; Prefs _prefs; Rng rng; Rng* getRNG(){return &rng;}\n" + source += "\n".join(f"uint32_t {name}(const mesh::Packet*);" for name in methods) + source += "\n};\n" + for name in methods: + body = extract_braced(production, f"uint32_t {owner}::{name}(") + if mutation: + body = mutation(body) + source += body + "\n" + calls.append(f"cases += exercise(&role{index}::{owner}::{name}, " + f"{'true' if role == 'simple_sensor' else 'false'}, " + f"{'true' if name == slow else 'false'});") + source += "}\n" + source += "int main(){unsigned cases=0;\n" + "\n".join(calls) + source += '\nprintf("PASS: %u actual-role profile timing cases\\n", cases);}\n' + return self.compile_fixture(source) + + def compile_fixture(self, source): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="forwarding-profile-timing-") as directory: + work = Path(directory) + cpp, binary = work / "timing.cpp", work / "timing" + cpp.write_text(source, encoding="ascii") + sanitize = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", + *sanitize, "-I" + str(ROOT / "src"), "-I" + str(ROOT / "test/mocks"), + str(cpp), str(ROOT / "src/Packet.cpp"), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + return subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) + + def test_actual_role_delays_use_packet_profile_wire_length_and_coding_rate(self): + result = self.run_fixture() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS: 103680 actual-role profile timing cases", result.stdout) + + def test_suite_is_explicitly_wired_in_ci(self): + workflow = (ROOT / ".github/workflows/run-unit-tests.yml").read_text() + self.assertIn("python3 -B test/test_forwarding_profile_timing.py -v", workflow) + + def test_actual_driver_preserves_single_profile_default_cr_airtime(self): + driver = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + method = extract_braced(driver, "uint32_t RadioLibWrapper::getProfileAirtime(") + harness = r''' +#include +#include +#include +class RadioLibWrapper { +public: + mesh::RadioProfiles _profiles; + unsigned legacy_calls = 0; + uint32_t getEstAirtimeFor(int length) { ++legacy_calls; return uint32_t(17 * length + 3); } + uint16_t profilePreamble(uint8_t profile) { return _profiles.params(profile).preamble; } + uint32_t getProfileAirtime(uint8_t profile, int length, uint8_t cr); +}; +''' + harness += method + r''' +int main() { + RadioLibWrapper radio; + assert(!radio._profiles.enabled()); + for (int length = 0; length <= 255; ++length) { + radio.legacy_calls = 0; + assert(radio.getProfileAirtime(0, length, 0) == uint32_t(17 * length + 3)); + assert(radio.legacy_calls == 1 && "single-profile default-CR must use existing driver estimate"); + } + puts("PASS: 256 actual-driver legacy airtime cases"); +} +''' + result = self.compile_fixture(harness) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS: 256 actual-driver legacy airtime cases", result.stdout) + + def test_metadata_regressions_are_detected(self): + def replace(body, pattern, replacement): + changed, count = re.subn(pattern, replacement, body) + self.assertEqual(count, 1, "production timing boundary changed") + return changed + mutations = ( + (r"_radio->getProfileAirtime\(\s*packet->radio_profile,\s*packet->getRawLength\(\),\s*packet->tx_cr\)", + "_radio->getEstAirtimeFor(packet->getRawLength())", "forwarding must use packet-aware airtime"), + (r"packet->radio_profile,", "_radio->active,", "timing must follow packet profile"), + (r"packet->getRawLength\(\)", "packet->getPathByteLen() + packet->payload_len + 2", + "timing must include complete wire frame"), + (r"packet->tx_cr", "0", "timing must include packet coding rate"), + ) + for pattern, replacement, oracle in mutations: + with self.subTest(oracle=oracle): + result = self.run_fixture(lambda body: replace(body, pattern, replacement)) + self.assertNotEqual(result.returncode, 0, "timing regression escaped its oracle") + self.assertIn(oracle, result.stderr) + + +if __name__ == "__main__": + unittest.main()