Keep OTA-only Companions on inherited foreign callbacks

This commit is contained in:
mikecarper committed 2026-10-16 03:01:34 -07:00
1 parent a731fbdb50
commit f8e8a9c3a7
4 files changed
+322

No files matched your search

+8
View File
@@ -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
+3
View File
@@ -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
+185
View File
@@ -0,0 +1,185 @@
// Only application boundaries are stubbed. The Companion declarations,
// callbacks, inherited Mesh direct hooks and Dispatcher are production code.
@RADIO@
#include <helpers/ota/OtaManager.h>
#include <Arduino.h>
#include <cstdio>
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 <typename... Args> 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");
}
+126
View File
@@ -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()