diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 285f39a5a..382e59f00 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -27,6 +27,13 @@ jobs: python3 -m pip install unicorn==2.1.4 python3 -B scripts/check_nrf52_companion_crypto.py .pio/build/RAK_4631_companion_radio_full/firmware.elf + - name: Qualify direct-OTA-only XIAO Full Companion + # This configuration has no Meshtext/Ethernet application callbacks. + run: >- + bash build.sh build-firmware Xiao_nrf52_companion_radio_full + --firmware-version v1.17.1.10-halo-keymind-cascade-dev + --radio-preset usa-cascade-fixed --profile cascade --require-ota + nrf52-sensor-smoke: runs-on: ubuntu-latest steps: @@ -247,6 +254,7 @@ jobs: python3 -B test/test_ota_direct_manager.py -v python3 -B test/test_mota_kiss_direct.py -v python3 -B test/test_ota_kiss_direct.py -v + python3 -B test/test_companion_foreign_hooks.py -v - name: Verify filter playground modes and built-in defaults run: node scripts/test_filter_tool.js diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 7dd57d0bc..7d5461460 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -405,9 +405,12 @@ protected: bool onEthernetFrame(const uint8_t* bytes, size_t length, uint8_t profile, uint32_t generation); #endif #if MESH_ENABLE_FOREIGN_RADIO +#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL bool onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, uint32_t generation, float snr, float rssi) override; void onForeignRadioSendComplete(bool success) override; +#endif + // Direct-only OTA inherits the Dispatcher's drop/no-op foreign callbacks. bool allowForeignRadioTransmit() const override { return _radio_available && _scheduled_reboot_at == 0 && ( #if MESH_ENABLE_MESHTEXT_CHAT diff --git a/test/fixtures/companion_foreign_hooks.cpp b/test/fixtures/companion_foreign_hooks.cpp new file mode 100644 index 000000000..079a79738 --- /dev/null +++ b/test/fixtures/companion_foreign_hooks.cpp @@ -0,0 +1,185 @@ +// Only application boundaries are stubbed. The Companion declarations, +// callbacks, inherited Mesh direct hooks and Dispatcher are production code. +@RADIO@ +#include +#include +#include + +struct ChannelDetails { struct { uint8_t secret[32] = {}; } channel; }; +namespace mesh { namespace mt { +constexpr unsigned DeliverySlots = 2; +struct PendingFrame { + uint32_t generation = 0, due = 0; + bool heard = false, cancelled = false, relayed = false, nodeinfo = false, sent = false; + int channel = 0; + unsigned retries = 0; + bool matchesRelayCopy(const uint8_t*, size_t) const { return false; } + bool matchesEcho(const uint8_t*, size_t) const { return false; } + void clear() { *this = {}; } +}; +struct Channel { const char* name = "test"; uint8_t key[32] = {}; size_t key_length = 16; }; +struct DecodedText { uint32_t from = 0, id = 0; uint8_t hops = 0; size_t text_len = 0; char text[32] = {}; }; +inline bool decodeText(const uint8_t*, size_t, const char*, const uint8_t*, size_t, + uint32_t, DecodedText&) { return false; } +} } +static bool describe(const ChannelDetails&, mesh::mt::Channel&) { return false; } +static unsigned meshtextRetryLimit(const mesh::mt::PendingFrame&) { return 0; } +static uint32_t meshtextRetryDelay(const mesh::mt::PendingFrame&) { return 100; } +namespace mesh { namespace ota { +struct BoundaryManager { + unsigned calls = 0; + bool enabled = false; + uint8_t hops = 255; + OtaReplyRoute route; + uint32_t directTransportEpoch() const { return 42; } + void set_direct_transport_enabled(bool value) { enabled = value; } + void note_rx_path_hops(uint8_t value) { hops = value; } + bool on_message(const uint8_t*, uint16_t, OtaReplyRoute value) { ++calls; route = value; return true; } + uint8_t fetchState() const { return 0; } +}; +struct BoundaryContext { + BoundaryManager manager; + void track_session(uint8_t, unsigned long) {} +} context; +inline BoundaryContext* ota_context_if_active() { return &context; } +} } +namespace mesh { +class Mesh : public Dispatcher { +public: + bool temp = false, primary = false; + mutable unsigned channel_queries = 0; + Mesh(Radio& r, MillisecondClock& c, PacketManager& m) : Dispatcher(r, c, m) {} + bool isAnyTempRadioActive() const { return temp; } + bool isTempRadioActive() const { return primary; } + bool getChannel(int, ChannelDetails&) const { ++channel_queries; return false; } + void syncOtaTiming(ota::BoundaryManager&) {} + struct RTC { uint32_t getCurrentTime() const { return 1000; } } rtc; + RTC* getRTCClock() { return &rtc; } +@DIRECT_DECLARATIONS@ +}; +@DIRECT_METHODS@ +} +using BaseChatMesh = mesh::Mesh; +#define MAX_GROUP_CHANNELS 1 +#define MAX_FRAME_SIZE 176 +class MyMesh : public BaseChatMesh { +public: + bool _radio_available = true; + uint32_t _scheduled_reboot_at = 0; + bool _mt_active = false, _ethernet_active = false; + unsigned _mt_received = 0, _mt_sent = 0, _mt_failed = 0, _mt_heard = 0, _mt_retried = 0; + unsigned _ethernet_received = 0, _ethernet_sent = 0, _ethernet_failed = 0; + uint32_t _mt_last_tx_completed_at = 0, _mt_node = 1, _mt_nodeinfo_generation = 0; + int _mt_tx_slot = -1; + bool _mt_nodeinfo_pending[1] = {}, _mt_nodeinfo_announced[1] = {}; + mesh::mt::PendingFrame _mt_delivery[mesh::mt::DeliverySlots]; + struct Seen { + bool seen(uint32_t, uint32_t) const { return false; } + void remember(uint32_t, uint32_t) {} + } _mt_seen; + struct Kiss { + unsigned calls = 0; + bool queueReceive(const uint8_t* bytes, size_t length) { assert(bytes && length); ++calls; return true; } + } _ethernet_kiss; + bool meshtextDeliveryCurrent(const mesh::mt::PendingFrame&) { return false; } + bool getChannel(int index, ChannelDetails& channel) { return BaseChatMesh::getChannel(index, channel); } + template bool queueChannelText(Args&&...) { return false; } + void maybeRebroadcastMeshtext(const ChannelDetails&, const uint8_t*, size_t) {} + MyMesh(TestRadio& r, Clock& c, StaticPoolPacketManager& p) : BaseChatMesh(r, c, p) {} + unsigned mesh_rx = 0, raw_logs = 0; + DispatcherAction onRecvPacket(Packet*) override { ++mesh_rx; return ACTION_RELEASE; } + void logRxRaw(float, float, const uint8_t*, int) override { ++raw_logs; } +@COMPANION_DECLARATIONS@ +#if MESH_ENABLE_ETHERNET_TUNNEL + bool onEthernetFrame(const uint8_t*, size_t, uint8_t, uint32_t); +#endif +#if MESH_ENABLE_FOREIGN_RADIO + using BaseChatMesh::queueForeignRadioFrame; + using BaseChatMesh::discardQueuedForeignRadioFrame; + bool foreignAllowed() const { return allowForeignRadioTransmit(); } + bool foreignReceive(const uint8_t* bytes, size_t n, uint8_t profile, uint32_t generation) { + // Explicit lookup also makes link failures identify the missing hook, + // instead of only the vtable. Dispatcher RX/TX below still dispatch virtually. + return MyMesh::onForeignRadioFrame(bytes, n, profile, generation, -4, -100); + } + void foreignComplete(bool okay) { MyMesh::onForeignRadioSendComplete(okay); } +#endif +#if MESH_ENABLE_OTA_KISS_DIRECT + using BaseChatMesh::queueDirectOtaRadioFrame; +#endif +}; +@CALLBACK_METHODS@ + +struct Fixture { + Clock clock; + TestRadio radio; + StaticPoolPacketManager pool{8}; + MyMesh node{radio, clock, pool}; + Fixture() { node.begin(); } + void tick(unsigned delta = 1) { clock.now += delta; node.loop(); } +}; +static void transmit(Fixture& f) { + f.tick(); assert(f.radio.sending); f.radio.complete = true; f.tick(10); + assert(!f.node.hasOutbound()); +} +int main() { + using Protocol = mesh::RadioProfileProtocol; + static_assert(bool(MESH_ENABLE_FOREIGN_RADIO) == bool( + MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL || MESH_ENABLE_OTA_KISS_DIRECT)); + const uint8_t bytes[] = {0x11, 0, 0x23}; + Fixture f; +#if MESH_ENABLE_FOREIGN_RADIO + assert(!f.node.foreignAllowed() && "Companion must retain inactive-app admission rejection"); +#if !MESH_ENABLE_MESHTEXT_CHAT && !MESH_ENABLE_ETHERNET_TUNNEL + assert(!f.node.foreignReceive(bytes, sizeof(bytes), 1, f.radio.p.generation[1])); + f.node.foreignComplete(true); f.node.foreignComplete(false); + assert(!f.node._mt_sent && !f.node._mt_failed && !f.node._ethernet_sent && !f.node._ethernet_failed); +#endif +#endif +#if MESH_ENABLE_OTA_KISS_DIRECT + // The actual Mesh inherited hooks and Dispatcher direct owner bypass + // inactive foreign apps, without falling through to foreign callbacks. + f.node.temp = true; f.radio.p.secondary_temporary = true; + f.radio.p.secondary.params.protocol = Protocol::MeshCore; + const auto gen = f.radio.p.generation[1]; + const uint8_t message[] = {mesh::ota::OTA_REQ, 0xc0, 0xdb}; + assert(f.node.queueDirectOtaRadioFrame(message, sizeof(message), 1, gen)); + transmit(f); + assert(f.node.getNumSentDirect() == 1 && !f.node._mt_sent && !f.node._ethernet_sent); + uint8_t raw[16]; const auto n = mesh::ota::ota_direct_wire_encode(raw, sizeof(raw), message, sizeof(message)); + f.radio.active = 1; f.radio.rx.assign(raw, raw + n); f.radio.incoming = true; f.tick(); + assert(mesh::ota::context.manager.calls == 1 && mesh::ota::context.manager.enabled); + assert(mesh::ota::context.manager.hops == 0 && mesh::ota::context.manager.route.direct); + assert(mesh::ota::context.manager.route.profile == 1 && mesh::ota::context.manager.route.generation == gen); + assert(!f.node.mesh_rx && !f.node.raw_logs && !f.node._mt_received && !f.node._ethernet_received); +#endif +#if MESH_ENABLE_MESHTEXT_CHAT + f.node._mt_active = true; + f.radio.p.secondary.params.protocol = Protocol::Meshtext; + assert(f.node.foreignAllowed()); + assert(!f.node.foreignReceive(bytes, sizeof(bytes), 1, f.radio.p.generation[1])); + assert(f.node.channel_queries == 1); // Real Meshtext callback reached its channel boundary. + assert(f.node.queueForeignRadioFrame(bytes, sizeof(bytes), 1, f.radio.p.generation[1], Protocol::Meshtext)); + transmit(f); assert(f.node._mt_sent == 1 && !f.node._ethernet_sent); + assert(f.node.queueForeignRadioFrame(bytes, sizeof(bytes), 1, f.radio.p.generation[1], Protocol::Meshtext)); + assert(f.node.discardQueuedForeignRadioFrame(Protocol::Meshtext)); assert(f.node._mt_failed == 1); +#endif +#if MESH_ENABLE_ETHERNET_TUNNEL + f.node._ethernet_active = true; f.node._mt_active = false; + f.radio.p.secondary.params.protocol = Protocol::Ethernet; + assert(f.node.foreignAllowed()); + assert(f.node.foreignReceive(bytes, sizeof(bytes), 1, f.radio.p.generation[1])); + assert(f.node._ethernet_received == 1 && f.node._ethernet_kiss.calls == 1 && !f.node._mt_received); + assert(!f.node.foreignReceive(bytes, sizeof(bytes), 1, f.radio.p.generation[1] + 1)); + const auto mt_sent = f.node._mt_sent; + assert(f.node.queueForeignRadioFrame(bytes, sizeof(bytes), 1, f.radio.p.generation[1], Protocol::Ethernet)); + transmit(f); assert(f.node._ethernet_sent == 1 && f.node._mt_sent == mt_sent); + assert(f.node.queueForeignRadioFrame(bytes, sizeof(bytes), 1, f.radio.p.generation[1], Protocol::Ethernet)); + assert(f.node.discardQueuedForeignRadioFrame(Protocol::Ethernet)); assert(f.node._ethernet_failed == 1); +#endif +#if MESH_ENABLE_FOREIGN_RADIO + f.node._scheduled_reboot_at = 1; assert(!f.node.foreignAllowed()); + f.node._scheduled_reboot_at = 0; f.node._radio_available = false; assert(!f.node.foreignAllowed()); +#endif + std::puts("PASS: Companion foreign-hook feature matrix"); +} diff --git a/test/test_companion_foreign_hooks.py b/test/test_companion_foreign_hooks.py new file mode 100644 index 000000000..511a33bba --- /dev/null +++ b/test/test_companion_foreign_hooks.py @@ -0,0 +1,126 @@ +"""Link real Companion hook declarations against their production providers.""" +from itertools import product +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_ota_kiss_direct import dispatcher_harness +from test_radio_receive_contract import method +from test_serial_wifi_sessions import SANITIZER_FLAGS + +ROOT = Path(__file__).resolve().parents[1] + + +def preprocessor_block(source, guard): + start = source.index(guard) + depth = 0 + for line in source[start:].splitlines(keepends=True): + stripped = line.strip() + if stripped.startswith(("#if ", "#ifdef ", "#ifndef ")): + depth += 1 + elif stripped == "#endif": + depth -= 1 + start += len(line) + if depth == 0: + return source[source.index(guard):start] + raise AssertionError("unterminated production preprocessor block") + + +def guarded_method(source, signature): + # Retain the source translation unit's surrounding eligibility conditions, + # including Ethernet's !CHAT fallback. Method bodies are never rewritten. + stack = [] + for line in source[:source.index(signature)].splitlines(): + text = line.strip() + if text.startswith("#if "): + stack.append(text[4:]) + elif text.startswith("#ifdef "): + stack.append("defined(" + text[7:] + ")") + elif text.startswith("#ifndef "): + stack.append("!defined(" + text[8:] + ")") + elif text == "#else": + stack[-1] = "!(" + stack[-1] + ")" + elif text == "#endif": + stack.pop() + elif text.startswith("#elif "): + raise AssertionError("extend guard extraction before testing an elif provider") + return "\n".join([*("#if " + item for item in stack), method(source, signature), + *("#endif" for _ in stack)]) + + +def fixture(declarations=None): + header = (ROOT / "examples/companion_radio/MyMesh.h").read_text() + declarations = declarations or preprocessor_block(header, "#if MESH_ENABLE_FOREIGN_RADIO\n") + mesh_h = (ROOT / "src/Mesh.h").read_text() + mesh_cpp = (ROOT / "src/Mesh.cpp").read_text() + direct = preprocessor_block(mesh_h, "#if MESH_ENABLE_OTA_KISS_DIRECT\n bool allowDirectOtaTransmit") + direct_methods = "\n".join(guarded_method(mesh_cpp, signature) for signature in ( + "uint32_t Mesh::directOtaEpoch(", "bool Mesh::allowDirectOtaTransmit(", + "bool Mesh::onDirectOtaRadioFrame(")) + mt = (ROOT / "examples/companion_radio/MeshtextCompanion.cpp").read_text() + eth = (ROOT / "examples/companion_radio/EthernetCompanion.cpp").read_text() + callbacks = "\n".join(guarded_method(source, signature) for source, signature in ( + (mt, "bool MyMesh::onForeignRadioFrame("), (mt, "void MyMesh::onForeignRadioSendComplete("), + (eth, "bool MyMesh::onEthernetFrame("), (eth, "bool MyMesh::onForeignRadioFrame("), + (eth, "void MyMesh::onForeignRadioSendComplete("))) + radio = dispatcher_harness().split("class TestDispatcher", 1)[0] + radio = radio.replace(" bool supportsMeshtext() const override { return supported; }", + " bool supportsMeshtext() const override { return supported; }\n" + " bool supportsEthernetTunnel() const override { return supported; }") + text = (ROOT / "test/fixtures/companion_foreign_hooks.cpp").read_text() + for token, value in (("@RADIO@", radio), ("@COMPANION_DECLARATIONS@", declarations), + ("@DIRECT_DECLARATIONS@", direct), ("@DIRECT_METHODS@", direct_methods), + ("@CALLBACK_METHODS@", callbacks)): + text = text.replace(token, value) + return text + + +class CompanionForeignHookTests(unittest.TestCase): + def compile_case(self, code, chat, tunnel, direct, expect_link=True): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("a host C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="meshcore-companion-foreign-hooks-") as folder: + binary = Path(folder) / "hooks" + result = subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", *SANITIZER_FLAGS, + "-DENABLE_OTA=1", f"-DMESH_ENABLE_MESHTEXT_CHAT={chat}", + f"-DMESH_ENABLE_ETHERNET_TUNNEL={tunnel}", f"-DMESH_ENABLE_OTA_KISS_DIRECT={direct}", + f"-I{ROOT / 'test/mocks'}", f"-I{ROOT / 'src'}", + *(str(ROOT / name) for name in ("src/Dispatcher.cpp", "src/Packet.cpp", + "src/helpers/StaticPoolPacketManager.cpp")), + "-x", "c++", "-", "-o", str(binary), + ], input=code, text=True, capture_output=True, timeout=60) + if not expect_link: + self.assertNotEqual(result.returncode, 0) + self.assertRegex(result.stderr.lower(), r"undefined reference|undefined symbols") + self.assertIn("MyMesh::onForeignRadioFrame", result.stderr) + self.assertIn("MyMesh::onForeignRadioSendComplete", result.stderr) + return + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + env = os.environ.copy() + env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=0" + result = subprocess.run([str(binary)], text=True, capture_output=True, timeout=10, env=env) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS: Companion foreign-hook feature matrix", result.stdout) + + def test_all_application_and_direct_feature_combinations_link_and_dispatch(self): + code = fixture() + for chat, tunnel, direct in product((0, 1), repeat=3): + with self.subTest(chat=chat, tunnel=tunnel, direct=direct): + self.compile_case(code, chat, tunnel, direct) + + def test_direct_only_broadened_callback_guard_fails_real_link(self): + header = (ROOT / "examples/companion_radio/MyMesh.h").read_text() + declarations = preprocessor_block(header, "#if MESH_ENABLE_FOREIGN_RADIO\n") + guard = "#if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_ETHERNET_TUNNEL\n" + self.assertEqual(declarations.count(guard), 1) + broken = declarations.replace(guard, "#if MESH_ENABLE_FOREIGN_RADIO\n", 1) + self.compile_case(fixture(broken), 0, 0, 1, expect_link=False) + + +if __name__ == "__main__": + unittest.main()