diff --git a/docs/kiss_modem_protocol.md b/docs/kiss_modem_protocol.md index 7480985ad..9088c6dca 100644 --- a/docs/kiss_modem_protocol.md +++ b/docs/kiss_modem_protocol.md @@ -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 diff --git a/examples/companion_radio/MeshtextCompanion.cpp b/examples/companion_radio/MeshtextCompanion.cpp index 7393577f8..3e6145267 100644 --- a/examples/companion_radio/MeshtextCompanion.cpp +++ b/examples/companion_radio/MeshtextCompanion.cpp @@ -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(); diff --git a/src/helpers/HostKiss.cpp b/src/helpers/HostKiss.cpp index 5172b72fa..60bf7cbe0 100644 --- a/src/helpers/HostKiss.cpp +++ b/src/helpers/HostKiss.cpp @@ -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; diff --git a/src/helpers/HostKissCodec.h b/src/helpers/HostKissCodec.h index 1cb6076c1..732394cc5 100644 --- a/src/helpers/HostKissCodec.h +++ b/src/helpers/HostKissCodec.h @@ -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; diff --git a/src/helpers/HostKissSettings.cpp b/src/helpers/HostKissSettings.cpp index ab72ef903..603f2e83f 100644 --- a/src/helpers/HostKissSettings.cpp +++ b/src/helpers/HostKissSettings.cpp @@ -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; } diff --git a/test/test_host_kiss.py b/test/test_host_kiss.py index fcd8ef8d8..ef6feee9e 100644 --- a/test/test_host_kiss.py +++ b/test/test_host_kiss.py @@ -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({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({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({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({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({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); diff --git a/test/test_host_kiss_radio.py b/test/test_host_kiss_radio.py index 6a9bf0dea..0c313dcc3 100644 --- a/test/test_host_kiss_radio.py +++ b/test/test_host_kiss_radio.py @@ -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 +#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()