Preserve KISS ownership and wait for initial radio2 readiness

This commit is contained in:
mikecarper committed 2026-10-26 17:23:38 -07:00
1 parent d836e661ba
commit 264a5a8b34
7 files changed
+306 -26

No files matched your search

+3
View File
@@ -32,6 +32,9 @@ runtime radio2 protocol lease without changing the saved radio2 tuple.
Temporary radio settings and Meshtext/Ethernet radio2 owners block admission.
The service never chooses a frequency, takes another service's radio2 lease,
crosses raw packets onto radio1, or feeds them into MeshCore parsing/bridges.
If the initial radio2 change is still pending, the connection waits and sends
READY when radio2 becomes available; no reconnect is needed. DATA received
while waiting is discarded, so hosts should wait for the initial READY.
USB remains the normal CLI until an initial KISS FEND (`C0`). During the raw
session, USB Companion framing and text/debug output are parked. Send the
+11 -1
View File
@@ -323,6 +323,17 @@ bool MyMesh::validateMeshtextChannelChange(int index, const ChannelDetails& cand
}
void MyMesh::refreshMeshtextProfile() {
const auto* current_profiles = _radio->profiles();
#if MESH_ENABLE_HOST_KISS
// Raw host traffic owns this running protocol until its session releases it.
// Meshtext refreshes must not clear that lease or publish another overlay.
if (current_profiles && current_profiles->secondary.params.protocol == mesh::RadioProfileProtocol::Kiss) {
_mt_active = false;
_mt_status = "radio2 reserved for KISS";
_mt_profile_dirty = true;
return;
}
#endif
#if MESH_ENABLE_ETHERNET_TUNNEL
// This owner must never revoke an Ethernet lease simply because it has
// no Meshtext channels. Explicit channel conflicts suspend Ethernet too.
@@ -333,7 +344,6 @@ void MyMesh::refreshMeshtextProfile() {
return;
}
#endif
const auto* current_profiles = _radio->profiles();
const uint32_t primary_generation = current_profiles ? current_profiles->generation[0] : 0;
const uint32_t secondary_generation = current_profiles ? current_profiles->generation[1] : 0;
const uint32_t intent_generation = _radio_profiles.secondaryIntentGeneration();
+113 -23
View File
@@ -25,6 +25,7 @@ RadioProfileCLI* profiles = nullptr;
Dispatcher* dispatcher = nullptr;
Codec* codec = nullptr; // All frame buffers are absent while the setting is off.
bool usb_session = false, suspended = false, leased = false, transmitting = false;
bool initial_waiting = false, initial_fresh = false, established = false, ready_started = false;
bool ready_pending = false, completion_pending = false, completion_success = false;
uint8_t quarantine_return = 0;
uint32_t profile_generation = 0, intent_generation = 0;
@@ -65,6 +66,7 @@ void resetFrames() {
if (codec) codec->reset();
ready_pending = completion_pending = false;
quarantine_return = 0;
initial_waiting = initial_fresh = established = ready_started = false;
}
void closeSession() {
@@ -102,17 +104,55 @@ bool leaseRadio() {
return true;
}
bool current() {
bool validLease() {
if (!leased || !active() || suspended || admission()) return false;
const auto* p = radio->profiles();
return p && p->generation[1] == profile_generation
&& profiles->secondaryIntentGeneration() == intent_generation;
}
bool current() { return established && validLease(); }
// Before the first READY, a pending settings handoff or another RF owner is a
// wait, not a failed connection. All pre-READY DATA is discarded; admission
// requires no buffered input and a complete KISS frame boundary.
bool startInitialLease() {
if (!leaseRadio()) return false;
resetFrames();
codec->feed(Codec::Fend);
ready_pending = true;
return true;
}
void admitInitial(bool input_empty) {
if (initial_waiting && codec && input_empty && codec->atFrameBoundary()
&& (!usb_session || isUsbBinaryStreamReady())) startInitialLease();
}
bool inputByte(uint8_t byte) {
codec->feed(byte);
if (codec->returned()) { closeSession(); return false; }
if (initial_waiting || suspended || !leased) {
const uint8_t* bytes;
size_t length;
while (codec->peekTransmit(bytes, length)) {
codec->consumeTransmit();
++discarded;
}
}
return true;
}
void outputProgress() {
// No RX or TX output is admitted before this initial READY completes.
if (leased && !initial_waiting && !established && !ready_pending
&& !codec->queuedOutput()) established = true;
}
const char* state(const Selection& selected, const char* reason) {
if (!selected.enabled) return "off";
if (suspended) return "suspended";
if (!codec) return "unavailable";
if (active() && initial_waiting) return "waiting-radio";
if (active() && !established) return "starting";
if (active()) return "connected";
if (reason) return "blocked";
#if defined(ESP32)
@@ -142,6 +182,17 @@ void invalidateSession() {
}
}
void checkLease() {
if (!leased || validLease()) return;
// A partial READY cannot be withdrawn: the peer may already have its bytes.
if (established || ready_started) invalidateSession();
else {
releaseRadio();
resetFrames();
initial_waiting = true;
}
}
void queueControls() {
if (!codec) return;
// Completion must survive a full RX queue, rather than being silently lost.
@@ -231,38 +282,61 @@ void loopTcp() {
if (tcp->listening) {
WiFiClient incoming = tcp->server.available();
if (incoming) {
if (tcp->connected || admission()) incoming.stop();
if (tcp->connected) incoming.stop();
else {
resetFrames();
if (!leaseRadio()) incoming.stop();
else {
tcp->client = incoming;
tcp->client.setNoDelay(true);
tcp->connected = true;
suspended = false;
ready_pending = true;
}
tcp->client = incoming;
tcp->client.setNoDelay(true);
tcp->connected = true;
suspended = false;
initial_waiting = true;
// A stable tuple accepts the first DATA even from a generic KISS
// client with flow control disabled. Only a genuinely blocked initial
// handoff needs to discard pre-READY input.
startInitialLease();
}
}
}
if (!tcp->connected) return;
const bool was_ready = established;
// Drain invalid/pre-READY host bytes before admitting the final RF tuple.
// recv's EAGAIN proves the socket input was empty on this bounded poll.
bool input_empty = false;
if (!established) {
for (size_t i = 0; i < ByteBudget && codec->canAcceptByte(); ++i) {
uint8_t byte;
const int got = ::recv(tcp->client.fd(), &byte, 1, MSG_DONTWAIT);
if (got == 1) { if (!inputByte(byte)) return; }
else {
if (!got || (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) {
closeSession(); return;
}
input_empty = errno == EAGAIN || errno == EWOULDBLOCK;
break;
}
}
admitInitial(input_empty);
}
queueControls();
const uint8_t* bytes;
size_t length;
if (codec->peekOutput(bytes, length)) {
if (length > ByteBudget) length = ByteBudget;
const int written = ::send(tcp->client.fd(), bytes, length, MSG_DONTWAIT);
if (written > 0) codec->consumeOutput(size_t(written));
if (written > 0) {
ready_started = true;
codec->consumeOutput(size_t(written)); outputProgress();
}
else if (written < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) {
closeSession(); return;
}
}
if (!was_ready) return;
for (size_t i = 0; i < ByteBudget && codec->canAcceptByte(); ++i) {
uint8_t byte;
const int got = ::recv(tcp->client.fd(), &byte, 1, MSG_DONTWAIT);
if (got == 1) {
codec->feed(byte);
if (codec->returned()) { closeSession(); break; }
if (!inputByte(byte)) break;
} else {
if (!got || (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) closeSession();
break;
@@ -318,8 +392,8 @@ bool handleCommand(const char* command, char* reply, size_t capacity, bool local
(unsigned long)received, (unsigned long)transmitted, (unsigned long)failed,
(unsigned long)(discarded + (codec ? codec->droppedFrames() : 0)),
(unsigned long)(codec ? codec->malformedFrames() : 0),
selected.enabled && !active() && reason ? " reason=" : "",
selected.enabled && !active() && reason ? reason : "");
selected.enabled && (!active() || initial_waiting) && reason ? " reason=" : "",
selected.enabled && (!active() || initial_waiting) && reason ? reason : "");
}
return true;
}
@@ -375,7 +449,7 @@ bool handleCommand(const char* command, char* reply, size_t capacity, bool local
void loop() {
if (!settings.selection().enabled || !codec) return;
if (active() && !suspended && !current()) invalidateSession();
if (active() && !suspended) checkLease();
#if defined(ESP32)
if (settings.selection().transport == Transport::Tcp) loopTcp();
#endif
@@ -389,9 +463,10 @@ bool serviceUsb() {
if (!port.available() || port.peek() != Codec::Fend) return false;
resetFrames();
usb_session = true;
suspended = !codec || !leaseRadio();
suspended = false;
initial_waiting = true;
initial_fresh = true;
setUsbBinaryStreamOwned(true);
ready_pending = !suspended;
}
// USB text writers may have been admitted just before the binary claim.
// Their nonblocking retirement must finish before READY or host bytes move.
@@ -410,22 +485,37 @@ bool serviceUsb() {
}
return true;
}
if (!suspended && !current()) invalidateSession();
if (initial_fresh) {
initial_fresh = false;
startInitialLease();
}
if (!suspended) checkLease();
const bool was_ready = established;
if (!established) {
for (size_t i = 0; i < ByteBudget && port.available() && codec->canAcceptByte(); ++i) {
const int value = port.read();
if (value < 0) break;
if (!inputByte(uint8_t(value))) return true;
}
if (!suspended) admitInitial(!port.available());
}
if (!suspended) {
queueControls();
const uint8_t* bytes;
size_t length;
if (codec->peekOutput(bytes, length)) {
if (length > ByteBudget) length = ByteBudget;
codec->consumeOutput(port.write(bytes, length));
const size_t written = port.write(bytes, length);
if (written) ready_started = true;
codec->consumeOutput(written);
outputProgress();
}
}
if (!was_ready) { pumpTransmit(); return true; }
for (size_t i = 0; i < ByteBudget && port.available() && codec->canAcceptByte(); ++i) {
const int value = port.read();
if (value < 0) break;
codec->feed(uint8_t(value));
if (codec->returned()) { closeSession(); return true; }
if (suspended) codec->consumeTransmit(); // Never queue TX while suspended.
if (!inputByte(uint8_t(value))) return true;
}
pumpTransmit();
return true;
+5
View File
@@ -100,6 +100,11 @@ class Codec {
return true;
}
bool returned() const { return return_; }
// A pristine decoder or a closing FEND is a safe place to announce READY.
// A host's partial pre-READY frame must finish (and be discarded) first.
bool atFrameBoundary() const {
return !has_command_ && !escaped_ && !discard_ && input_size_ == 0;
}
bool peekTransmit(const uint8_t*& bytes, size_t& length) const {
if (!tx_count_) { bytes = nullptr; length = 0; return false; }
bytes = tx_[tx_head_].bytes;
+7 -1
View File
@@ -50,7 +50,11 @@ bool Settings::save(const Selection& selection) {
if (!healthy_ || !fs_ || uint8_t(selection.transport) > 1) return false;
uint8_t image[ImageSize];
encode(image, selection);
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#if defined(NRF52_PLATFORM)
const auto outcome = storage::writeCheckedSettingsFile(fs_, Path, image, sizeof(image));
if (outcome == AtomicFileWriter::Outcome::Indeterminate) healthy_ = false;
if (outcome != AtomicFileWriter::Outcome::Committed) return false;
#elif defined(STM32_PLATFORM)
AtomicFileWriter writer(fs_, Path);
const bool wrote = writer && writer.write(image, sizeof(image)) == sizeof(image);
const bool saved = writer.commit(wrote);
@@ -65,10 +69,12 @@ bool Settings::save(const Selection& selection) {
writer.commit();
if (writer.outcome() == ContactFileTransaction::Outcome::NotCommitted) return false;
#endif
#if !defined(NRF52_PLATFORM)
uint8_t verified[ImageSize];
healthy_ = storage::readFixedSettingsFile(fs_, Path, verified, sizeof(verified))
&& memcmp(image, verified, sizeof(image)) == 0;
if (!healthy_) return false;
#endif
selection_ = selection;
return true;
}
+66 -1
View File
@@ -294,6 +294,35 @@ int main(){
const int third=newSocket();accepts.push_back(third);hostkiss::loop();assert(hostkiss::radioActive());
WiFi.mode=WIFI_AP;hostkiss::loop();assert(!hostkiss::radioActive()&&!sockets.at(third)->connected);
command("set kiss off");assert(strstr(reply,"OK"));
WiFi.mode=WIFI_STA;command("set kiss on");hostkiss::loop();
// A single connection survives the two-second settings handoff. No
// pre-READY DATA, including a partial escaped frame, can reach the radio.
profiles.busy=true;const int waiting=newSocket();accepts.push_back(waiting);
input(waiting,{0xc0,0,91,0xdb});hostkiss::loop();
assert(sockets.at(waiting)->connected&&!hostkiss::radioActive());
assert(sockets.at(waiting)->output.empty());command("get kiss");assert(strstr(reply,"waiting-radio"));
fake_millis=1999;hostkiss::loop();assert(sockets.at(waiting)->output.empty());
profiles.busy=false;fake_millis=2000;hostkiss::loop();assert(sockets.at(waiting)->output.empty());
input(waiting,{0xdc,0xc0});hostkiss::loop();
assert(sockets.at(waiting)->connected&&hostkiss::radioActive());
assert(sockets.at(waiting)->output==std::vector<uint8_t>({0xc0,0x0f,1,0xc0}));
assert(dispatcher.submitted==before);
input(waiting,{0,71,0xc0});hostkiss::loop();assert(dispatcher.submitted==before+1&&dispatcher.packet[0]==71);
command("set kiss off");assert(!sockets.at(waiting)->connected&&!dispatcher.pending);
// Initial conflicts wait automatically, but RETURN can cancel that wait.
command("set kiss on");profiles.manual=false;hostkiss::loop();
const int conflict=newSocket();accepts.push_back(conflict);hostkiss::loop();
assert(sockets.at(conflict)->connected&&!hostkiss::radioActive());
input(conflict,{0xc0,0xff,0xc0});hostkiss::loop();assert(!sockets.at(conflict)->connected);
const int resolves=newSocket();accepts.push_back(resolves);hostkiss::loop();
profiles.manual=true;hostkiss::loop();assert(sockets.at(resolves)->connected&&hostkiss::radioActive());
command("set kiss off");
// A stable, fresh connection preserves immediate first DATA from a generic
// KISS client which does not wait for the READY extension.
command("set kiss on");hostkiss::loop();const int immediate=newSocket();
input(immediate,{0xc0,0,72,0xc0});accepts.push_back(immediate);hostkiss::loop();
assert(dispatcher.submitted==before+2&&dispatcher.packet==std::vector<uint8_t>({72}));
command("set kiss off");
}
'''
@@ -321,6 +350,27 @@ int main() {
hostkiss::Settings saved;assert(saved.begin(&fs)&&saved.selection().enabled);
input({'g','e','t',' '});assert(!hostkiss::serviceUsb()&&serial.available()==4);
serial.input.clear();
// First FEND can arrive during the saved radio2 two-second handoff. The
// same connection becomes ready automatically once the tuple is stable.
profiles.busy=true;input({0xc0});poll();
assert(hostkiss::usbActive()&&!hostkiss::radioActive()&&serial.output.empty());
command("get kiss");assert(strstr(reply,"waiting-radio")&&strstr(reply,"pending radio settings"));
fake_millis=1999;poll();assert(serial.output.empty());
profiles.busy=false;fake_millis=2000;poll();
assert(hostkiss::radioActive()&&serial.output==std::vector<uint8_t>({0xc0,0x0f,1,0xc0}));
hostkiss::resetUsbSession();serial.output.clear();
// Drain a complete pre-READY frame and wait for the remainder of a partial
// escaped frame before announcing READY. Only fresh post-READY DATA sends.
profiles.busy=true;input({0xc0,0,90,0xc0,0,91,0xdb});poll();
profiles.busy=false;poll();assert(serial.output.empty()&&!hostkiss::radioActive());
input({0xdc,0xc0});poll();assert(hostkiss::radioActive());
assert(dispatcher.submitted==0&&serial.output==std::vector<uint8_t>({0xc0,0x0f,1,0xc0}));
hostkiss::resetUsbSession();serial.output.clear();
// Another initial owner can release radio2 without a reconnect.
radio.p.secondary.params.protocol=RadioProfileProtocol::Meshtext;input({0xc0});poll();
assert(!hostkiss::radioActive()&&serial.output.empty());
radio.p.secondary.params.protocol=RadioProfileProtocol::MeshCore;++radio.p.generation[1];poll();
assert(hostkiss::radioActive());hostkiss::resetUsbSession();serial.output.clear();
profiles.manual=false;input({0xc0});assert(hostkiss::serviceUsb()&&hostkiss::usbActive()&&binary_owned);
assert(!hostkiss::radioActive());input({0xff,0xc0});assert(hostkiss::serviceUsb()&&!hostkiss::usbActive());
profiles.manual=true;input({0xc0});binary_ready=false;
@@ -352,13 +402,28 @@ int main() {
poll();assert(hostkiss::usbActive()&&!hostkiss::radioActive());
assert(profiles.clear==cleared&&radio.p.secondary.params.protocol==RadioProfileProtocol::Meshtext);
const unsigned submitted=dispatcher.submitted;
radio.p.secondary.params.protocol=RadioProfileProtocol::MeshCore;++radio.p.generation[1];poll();
assert(!hostkiss::radioActive()); // An established invalidated session cannot reacquire.
input({0xc0,0,99,0xc0});poll();assert(dispatcher.submitted==submitted);
input({0xc0,0xff,0xc0});assert(hostkiss::serviceUsb());
assert(!hostkiss::usbActive()&&!binary_owned&&!dispatcher.pending);
hostkiss::sendComplete(true); // late completion cannot enter a new session
profiles.overlay=false;radio.p.secondary=profiles.saved;++radio.p.generation[1];
input({0xc0,0,17,0xc0});poll();assert(dispatcher.submitted==submitted+1);
input({0xc0});poll();input({0,17,0xc0});poll();assert(dispatcher.submitted==submitted+1);
hostkiss::resetUsbSession();assert(!hostkiss::usbActive()&&!binary_owned&&!dispatcher.pending);
// A partially written READY is already observable; a later profile change
// quarantines it rather than manufacturing READY on another generation.
serial.output.clear();serial.write_budget=1;input({0xc0});poll();
assert(serial.output.size()==1&&!hostkiss::radioActive());
++radio.p.generation[1];poll();serial.write_budget=128;poll();
assert(!hostkiss::radioActive()&&hostkiss::usbActive());
input({0xc0,0xff,0xc0});poll();assert(!hostkiss::usbActive());
// Stable radio2 also preserves a complete first DATA before the peer reads
// READY, as ordinary KISS hosts with flow control disabled require.
const unsigned before_immediate=dispatcher.submitted;
input({0xc0,0,33,0xc0});poll();
assert(dispatcher.submitted==before_immediate+1&&dispatcher.packet==std::vector<uint8_t>({33}));
hostkiss::resetUsbSession();
// Off must be durable, releases the overlay and cannot consume binary input.
command("set kiss off");assert(strstr(reply,"OK")&&!hostkiss::radioActive());
assert(saved.begin(&fs)&&!saved.selection().enabled);
+101
View File
@@ -227,6 +227,107 @@ int main() {
"-DMESH_ENABLE_ETHERNET_TUNNEL=" + str(foreign)],
["src/helpers/RadioProfileCLI.cpp"], ["test/fixtures/radio_profiles/mocks"])
def test_actual_meshtext_refresh_never_revokes_host_kiss_protocol(self):
source = (ROOT / "examples/companion_radio/MeshtextCompanion.cpp").read_text()
refresh = body(source, "void MyMesh::refreshMeshtextProfile()")
code = r'''
#define main existing_cli_main
#include "test/fixtures/radio_profiles/cli_test.cpp"
#undef main
#include <helpers/CompanionChannelAlias.h>
#define MAX_GROUP_CHANNELS 4
struct ChannelDetails {char name[32]={};};
class BaseChatMesh {
public:
ChannelDetails channels[MAX_GROUP_CHANNELS];unsigned channel_reads=0;
bool getChannel(int n,ChannelDetails& out){++channel_reads;out=channels[n];return out.name[0];}
};
namespace mesh {namespace mt {
enum class Region{Unset,US};enum class Preset{LongFast,ShortFast};struct Channel{};
bool parsePreset(const char* name,Preset& preset){
if(!strcmp(name,"longfast")){preset=Preset::LongFast;return true;}
if(!strcmp(name,"shortfast")){preset=Preset::ShortFast;return true;}
return false;
}
bool modemConfig(Region,Preset,RadioProfileParams& out,uint32_t){
out={910.5f,500,16,8,5};out.protocol=RadioProfileProtocol::Meshtext;return true;
}
bool frequencyInRegion(Region region,const RadioProfileParams&){return region==Region::US;}
bool sameModulation(const RadioProfileParams&a,const RadioProfileParams&b){return a.bw==b.bw&&a.sf==b.sf&&a.cr==b.cr;}
}}
bool describe(const ChannelDetails&,mesh::mt::Channel&){return true;}
class MyMesh:public BaseChatMesh {
public:
struct RadioView {
Radio& radio;
mesh::RadioProfiles* profiles(){return radio.profiles();}
bool supportsMeshtext()const{return true;}
bool validateProfile(const mesh::RadioProfileParams& p)const{return radio.validateProfile(p);}
} view;
RadioView* _radio;mesh::RadioProfileCLI& _radio_profiles;
bool _mt_active=true,_mt_profile_dirty=true,_mt_last_radio_ready=false,_mt_last_secondary_ready=false;
bool _radio_available=true,_mt_settings_valid=true;
uint32_t _mt_primary_generation=0,_mt_secondary_generation=0,_mt_intent_generation=0,_mt_slot=0;
float _mt_primary_frequency=0;const char* _mt_status="";
struct {float freq=909.5f;} _prefs;
struct {bool enabled=false;} _ethernet_settings;
mesh::mt::Region region=mesh::mt::Region::US;
MyMesh(Radio& radio,mesh::RadioProfileCLI& profiles):view{radio},_radio(&view),_radio_profiles(profiles){}
mesh::mt::Region meshtextRegion()const{return region;}
void refreshMeshtextProfile();
};
@REFRESH@
int main(){
using P=mesh::RadioProfileProtocol;
for(unsigned scenario=0;scenario<10;++scenario){
Fixture f;f.cmd("set radio2 910.5,500,8,5,rxtx,80");f.advance(2000);
const auto saved=f.fs.files;const auto intent=f.cli.secondaryIntentGeneration();
assert(f.cli.useSecondaryProtocol(P::Kiss));
const auto generation=f.radio.p.generation[1];const auto params=f.radio.p.secondary.params;
MyMesh mesh(f.radio,f.cli);
if(scenario!=0)strcpy(mesh.channels[0].name,"#mt-longfast");
if(scenario==2)strcpy(mesh.channels[0].name,"#mt-custom");
if(scenario==3)strcpy(mesh.channels[1].name,"#mt-shortfast");
if(scenario==4)strcpy(mesh.channels[1].name,"#r2-public");
if(scenario==5)mesh._mt_settings_valid=false;
if(scenario==6)mesh._radio_available=false;
if(scenario==7)mesh.region=mesh::mt::Region::Unset;
if(scenario==8)mesh._prefs.freq=915;
if(scenario==9)mesh._ethernet_settings.enabled=true;
mesh.refreshMeshtextProfile();mesh.refreshMeshtextProfile();
assert(f.radio.p.secondary.params.protocol==P::Kiss && f.radio.p.secondary.params==params);
assert(f.radio.p.generation[1]==generation && f.cli.secondaryIntentGeneration()==intent && f.fs.files==saved);
assert(!mesh._mt_active && mesh._mt_profile_dirty && !strcmp(mesh._mt_status,"radio2 reserved for KISS"));
assert(!mesh.channel_reads);
if(scenario==1){
f.cli.clearSecondaryProtocol();mesh.refreshMeshtextProfile();
assert(mesh._mt_active && f.radio.p.secondary.params.protocol==P::Meshtext);
assert(f.fs.files==saved);
}
}
}
'''
runner = ethernet_test.EthernetRadioTest()
runner.setUpClass()
guard_start = refresh.index("#if MESH_ENABLE_HOST_KISS")
guard_end = refresh.index("#endif", guard_start) + len("#endif")
negative = refresh[:guard_start] + refresh[guard_end:]
for ethernet, remove_guard in ((0, False), (1, False), (0, True)):
with self.subTest(ethernet=ethernet, remove_guard=remove_guard):
candidate = negative if remove_guard else refresh
if remove_guard:
with self.assertRaises(AssertionError) as failure:
runner.compile_run(code.replace("@REFRESH@", candidate),
["-DMESH_ENABLE_HOST_KISS=1", "-DMESH_ENABLE_MESHTEXT_CHAT=1",
"-DMESH_ENABLE_ETHERNET_TUNNEL=" + str(ethernet)],
["src/helpers/RadioProfileCLI.cpp"], ["test/fixtures/radio_profiles/mocks"])
self.assertIn("f.radio.p.secondary.params.protocol==P::Kiss", str(failure.exception))
else:
runner.compile_run(code.replace("@REFRESH@", candidate),
["-DMESH_ENABLE_HOST_KISS=1", "-DMESH_ENABLE_MESHTEXT_CHAT=1",
"-DMESH_ENABLE_ETHERNET_TUNNEL=" + str(ethernet)],
["src/helpers/RadioProfileCLI.cpp"], ["test/fixtures/radio_profiles/mocks"])
def test_feature_layout_is_consistent_with_platform_and_explicit_overrides(self):
checker = feature_config.MeshtextConfigTest()
checker.setUp()