mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-11-04 07:27:22 +00:00
Keep OTA-only Companions on inherited foreign callbacks
This commit is contained in:
1 parent
a731fbdb50
commit
f8e8a9c3a7
4 files changed
+322
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user