Add saved raw radio2 KISS host sessions to Full firmware

This commit is contained in:
mikecarper committed 2026-10-26 16:57:49 -07:00
1 parent 8a8c80ee6a
commit cb3c92a8da
50 files changed
+2750 -74

No files matched your search

+2 -1
View File
@@ -174,7 +174,8 @@ class CompanionPacketStreamTests(unittest.TestCase):
functions = usb[start:end] + '\n' + '\n'.join(method(usb, sig) for sig in (
'bool hasDedicatedUsbLoggingPort()', 'bool isDedicatedUsbLoggingPortConfigured()',
'bool usbLoggingInterfaceRestartRequired()', 'const char* usbLoggingPortDescription()',
'void beginUsbLoggingPort()', 'Stream& usbLoggingPort()'))
'void beginUsbLoggingPort()', 'static Stream& rawUsbLoggingPort()',
'Stream& usbLoggingPort()'))
start = mesh.index(' if (strcmp(command, "get usb.logging") == 0)')
end = mesh.index('\n#endif', start)
boot_start = mesh.index(' const bool usb_logging_enabled = _prefs.usb_logging_enabled != 0;')
+2
View File
@@ -21,6 +21,8 @@ def function(signature):
HARNESS = r'''
#define MESH_ENABLE_HOST_KISS 0
#include "helpers/HostKiss.h"
#include <cassert>
#include <cstdint>
#include <initializer_list>
+2
View File
@@ -16,6 +16,8 @@ CONTEXT = (ROOT / "src/helpers/ota/OtaContext.h").read_text()
HARNESS = r'''
#define MESH_ENABLE_HOST_KISS 0
#include "helpers/HostKiss.h"
#include <cassert>
#include <algorithm>
#include <cstdio>
+2
View File
@@ -9,6 +9,8 @@ from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#define MESH_ENABLE_HOST_KISS 0
#include "helpers/HostKiss.h"
#include <cassert>
#include <cstdio>
#include <cstring>
+8 -1
View File
@@ -5,6 +5,8 @@ from pathlib import Path
import re
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
ROLES = ("simple_repeater", "simple_room_server")
@@ -19,7 +21,7 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase):
text = source("src/helpers/UsbLogging.cpp")
self.assertRegex(
text,
r"Stream& usbConsolePort\(\)\s*\{\s*"
r"static Stream& rawUsbConsolePort\(\)\s*\{\s*"
r"#if MESH_ESP32_TINYUSB_NONBLOCKING\s+"
r"return buffered_esp32_tinyusb_terminal_port;\s+"
r"#elif MESH_ESP32_HWCDC_SESSION_GUARD\s+"
@@ -28,6 +30,11 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase):
r"return buffered_primary_usb_terminal_port;\s+"
r"#else\s+return Serial;\s+#endif",
)
# The shared binary-owner facade wraps the same transport selection.
# With host KISS omitted, every platform still gets its original port.
facade = body(text, "Stream& usbConsolePort()")
self.assertIn("guarded.use(rawUsbConsolePort());", facade)
self.assertRegex(facade, r"#else\s+return rawUsbConsolePort\(\);\s+#endif")
def test_role_output_never_bypasses_the_usb_facade(self):
# A capacity check outside Serial.write() cannot make Arduino's
+449
View File
@@ -0,0 +1,449 @@
#!/usr/bin/env python3
"""Run production KISS framing, persistence and session logic on the host."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
CODEC = r'''
#include <helpers/HostKissCodec.h>
#include <cassert>
#include <vector>
using mesh::hostkiss::Codec;
std::vector<uint8_t> output(Codec& codec) {
const uint8_t* bytes;size_t length;std::vector<uint8_t> result;
while(codec.peekOutput(bytes,length)) {
const size_t amount=length>7?7:length;
result.insert(result.end(),bytes,bytes+amount);codec.consumeOutput(amount);
}
return result;
}
void feed(Codec& codec,const std::vector<uint8_t>& bytes) {
for(auto byte:bytes) assert(codec.feed(byte));
}
int main() {
Codec encoder,decoder;
for(unsigned length=1;length<=255;++length) {
std::vector<uint8_t> bytes(length);
for(unsigned i=0;i<length;++i) bytes[i]=uint8_t(i+length);
assert(encoder.queueReceive(bytes.data(),bytes.size()));
feed(decoder,output(encoder));
const uint8_t* decoded;size_t size;
assert(decoder.peekTransmit(decoded,size)&&size==bytes.size());
assert(std::vector<uint8_t>(decoded,decoded+size)==bytes);
decoder.consumeTransmit();
}
const uint8_t escaped[]={0xc0,0xdb,0xc0};
assert(encoder.queueReceive(escaped,3));
assert(output(encoder)==std::vector<uint8_t>({0xc0,0,0xdb,0xdc,0xdb,0xdd,0xdb,0xdc,0xc0}));
// Oversized, malformed escape, empty DATA, unknown command/port and RETURN
// with payload cannot be transformed into a valid radio packet.
for(auto bytes:std::vector<std::vector<uint8_t>>{{0xc0,0,0xc0},
{0xc0,0,1,0xdb,0xc0},{0xc0,0,1,0xdb,0x11,0xc0},
{0xc0,0x10,1,2,0xc0},{0xc0,1,99,0xc0},{0xc0,0xff,1,0xc0}}) {
decoder.reset();feed(decoder,bytes);
const uint8_t* data;size_t size;assert(!decoder.peekTransmit(data,size)&&!decoder.returned());
}
std::vector<uint8_t> oversized(260,1);oversized[0]=0xc0;oversized[1]=0;oversized.back()=0xc0;
decoder.reset();feed(decoder,oversized);
const uint8_t* data;size_t size;assert(!decoder.peekTransmit(data,size));
assert(decoder.malformedFrames()>=5);
feed(decoder,{0xc0,0,17,0xc0,0,18,0xc0});
assert(decoder.queuedTransmit()==2&&!decoder.canAcceptByte()&&!decoder.feed(0xc0));
decoder.consumeTransmit();assert(decoder.canAcceptByte());
assert(decoder.peekTransmit(data,size)&&size==1&&data[0]==18);
decoder.reset();feed(decoder,{1,2,3,0xc0,0xff,0xc0});
assert(decoder.returned()&&!decoder.canAcceptByte());decoder.reset();
assert(!decoder.returned()&&decoder.canAcceptByte());
assert(encoder.queueReady()&&encoder.queueTxDone(false));
assert(!encoder.queueReceive(escaped,3)&&encoder.droppedFrames()==1);
assert(output(encoder)==std::vector<uint8_t>({0xc0,0x0f,1,0xc0,0xc0,0x06,0xf8,0,0xc0}));
assert(!encoder.queueReceive(nullptr,1)&&!encoder.queueReceive(escaped,0));
// Fuzz framing under sanitizers with periodic draining/backpressure.
uint32_t state=17;
for(unsigned i=0;i<200000;++i) {
state=state*1664525+1013904223;
if(!decoder.canAcceptByte()) decoder.reset();
decoder.feed(uint8_t(state>>24));
if(i%317==0) decoder.reset();
}
}
'''
SETTINGS = r'''
#include <helpers/HostKissSettings.h>
#include <helpers/PersistentStoreFormat.h>
#include <cassert>
using namespace mesh::hostkiss;
using Bytes=std::vector<uint8_t>;
constexpr char Path[]="/host_kiss";
Bytes image(Selection selection) {
Bytes bytes={'H','K','S','1',uint8_t(selection.enabled),uint8_t(selection.transport),0,0,0,0,0,0};
mesh::storage::writeLE32(bytes.data()+8,mesh::storage::updateCRC32(0xffffffff,bytes.data(),8));return bytes;
}
int main() {
Selection on{true,Transport::Usb},tcp{true,Transport::Tcp},off{false,Transport::Usb};
GuestMemoryFS fs;Settings store;
assert(store.begin(&fs)&&store.healthy()&&store.selection()==off&&fs.writes==0);
assert(store.save(on)&&store.selection()==on&&fs.files.at(Path)==image(on));
assert(store.save(tcp)&&store.selection()==tcp&&fs.files.at(Path)==image(tcp));
Settings reboot;assert(reboot.begin(&fs)&&reboot.selection()==tcp);
assert(reboot.save(off)&&reboot.selection()==off);
for(unsigned size=0;size<25;++size) {
if(size==12)continue;
GuestMemoryFS broken;broken.files[Path]=image(on);broken.files[Path].resize(size);
Settings read;assert(!read.begin(&broken)&&read.selection()==off);
assert(read.save(off));
}
for(unsigned byte=0;byte<12;++byte) for(unsigned bit=0;bit<8;++bit) {
GuestMemoryFS broken;broken.files[Path]=image(tcp);broken.files[Path][byte]^=1<<bit;
Settings read;assert(!read.begin(&broken)&&read.selection()==off);
}
for(unsigned fault=0;fault<7;++fault) {
GuestMemoryFS broken;broken.files[Path]=image(on);Settings read;assert(read.begin(&broken));
if(fault==0)broken.fail_open_write=true;
if(fault==1)broken.short_write=5;
if(fault==2)broken.corrupt_stage=true;
if(fault==3)broken.fail_stage_open=true;
if(fault==4)broken.fail_stage_read=true;
if(fault==5)broken.fail_publish=true;
if(fault==6)broken.false_publish=true;
assert(!read.save(off)&&read.selection()==on);
assert(broken.files.at(Path)==image(on));
}
for(auto old:{off,on,tcp}) {
GuestMemoryFS backing;backing.files[Path]=image(old);Settings read;assert(read.begin(&backing));
backing.capture=true;assert(read.save(old==off?tcp:off));
const auto snapshots=backing.snapshots;assert(snapshots.size()>10);
for(const auto& snapshot:snapshots) {
GuestMemoryFS resumed;resumed.files=snapshot;Settings recovered;assert(recovered.begin(&resumed));
if(snapshot.count(Path)) assert(image(recovered.selection())==snapshot.at(Path));
else if(snapshot.count("/host_kiss.bak")) assert(image(recovered.selection())==snapshot.at("/host_kiss.bak"));
else assert(recovered.selection()==off);
}
}
GuestMemoryFS scratch;scratch.files["/host_kiss.tmp"]=image(tcp);
Settings discarded;assert(discarded.begin(&scratch)&&discarded.selection()==off);
}
'''
RADIO_STUB = r'''
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <vector>
namespace mesh {
enum class RadioProfileProtocol:uint8_t {MeshCore=0,Meshtext=1,Ethernet=2,Kiss=3};
enum class RadioProfileMode:uint8_t {Off=0,Rx=1,RxTx=2};
struct RadioProfileParams {RadioProfileProtocol protocol=RadioProfileProtocol::MeshCore;};
struct RadioProfileConfig {RadioProfileParams params;RadioProfileMode mode=RadioProfileMode::RxTx;};
struct RadioProfiles {RadioProfileConfig secondary;uint32_t generation[2]={1,1};bool primary_temporary=false,secondary_temporary=false;};
class Radio {public:RadioProfiles p;bool supported=true;bool supportsHostKiss()const{return supported;}RadioProfiles* profiles(){return &p;}};
class Dispatcher {public:
bool pending=false,allow=true;unsigned submitted=0,cancelled=0;uint32_t gen=0;std::vector<uint8_t> packet;
bool submitHostKissFrame(const uint8_t* bytes,size_t size,uint32_t generation){
if(pending||!allow)return false;pending=true;++submitted;gen=generation;packet.assign(bytes,bytes+size);return true;}
bool cancelHostKissFrame(){++cancelled;if(!pending)return false;pending=false;return true;}
bool hostKissFramePending()const{return pending;}
};
}
'''
PROFILE_STUB = r'''
#pragma once
#include <Dispatcher.h>
namespace mesh {class RadioProfileCLI {
public:Radio* radio;RadioProfileConfig saved;bool manual=true,busy=false,overlay=false;uint32_t intent=0;unsigned clear=0;
explicit RadioProfileCLI(Radio& value):radio(&value){}
bool manualSecondaryConfigured()const{return manual;}
const RadioProfileConfig& savedSecondaryConfig()const{return saved;}
bool secondaryRuntimeAvailable()const{return !busy;}
uint32_t secondaryIntentGeneration()const{return intent;}
bool useSecondaryProtocol(RadioProfileProtocol protocol){
if(busy||!manual)return false;radio->p.secondary.params.protocol=protocol;++radio->p.generation[1];overlay=true;return true;}
void clearSecondaryProtocol(){++clear;if(overlay){radio->p.secondary=saved;++radio->p.generation[1];overlay=false;}}
};}
'''
USB_STUB = r'''
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <deque>
#include <vector>
struct Stream {
std::deque<uint8_t> input;std::vector<uint8_t> output;size_t write_budget=128;
int available(){return int(input.size());}int peek(){return input.empty()?-1:input.front();}
int read(){if(input.empty())return -1;int byte=input.front();input.pop_front();return byte;}
size_t write(const uint8_t* bytes,size_t length){if(length>write_budget)length=write_budget;output.insert(output.end(),bytes,bytes+length);return length;}
};
inline Stream serial;
inline uint32_t fake_millis=0;
inline uint32_t millis(){return fake_millis;}
namespace mesh {
inline bool binary_owned=false;
inline bool binary_ready=true;
inline Stream& usbCompanionPort(){return serial;}
inline void setUsbBinaryStreamOwned(bool value){binary_owned=value;}
inline bool isUsbBinaryStreamReady(){return binary_ready;}
}
'''
NETWORK_STUB = r'''
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <deque>
#include <vector>
#include <memory>
#include <map>
#include <cerrno>
#define WIFI_STA 1
#define WIFI_AP 2
#define WL_CONNECTED 3
#define MSG_DONTWAIT 0x40
struct Socket {bool connected=true;std::deque<uint8_t> input;std::vector<uint8_t> output;size_t write_budget=128;};
inline std::map<int,std::shared_ptr<Socket>> sockets;
inline std::deque<int> accepts;
inline int next_socket=1;
inline int newSocket(){int id=next_socket++;sockets[id]=std::make_shared<Socket>();return id;}
class IPAddress {uint32_t value_;public:IPAddress(uint32_t value=0):value_(value){}operator uint32_t()const{return value_;}};
struct Wifi {int mode=0,status_value=0;uint32_t address=0;
int getMode()const{return mode;}int status()const{return status_value;}IPAddress localIP()const{return IPAddress(address);}};
inline Wifi WiFi;
class WiFiClient {int id_=0;public:
WiFiClient()=default;explicit WiFiClient(int id):id_(id){}
explicit operator bool()const{return id_&&sockets.at(id_)->connected;}
bool connected()const{return bool(*this);}int fd()const{return id_;}
void stop(){if(id_)sockets.at(id_)->connected=false;id_=0;}void setNoDelay(bool){}
};
inline uint32_t bound_address=0;inline uint16_t bound_port=0;inline unsigned listens=0;
class WiFiServer {uint32_t address_=0;uint16_t port_=0;bool started_=false;
public:WiFiServer(uint16_t port,int):port_(port){}WiFiServer(IPAddress address,uint16_t port,int):address_(address),port_(port){}
void begin(){started_=true;bound_address=address_;bound_port=port_;++listens;}
void end(){started_=false;while(!accepts.empty()){sockets.at(accepts.front())->connected=false;accepts.pop_front();}}
explicit operator bool()const{return started_;}
WiFiClient available(){if(accepts.empty())return {};int id=accepts.front();accepts.pop_front();return WiFiClient(id);}
};
inline int send(int fd,const void* data,size_t length,int flags){
if(flags!=MSG_DONTWAIT)__builtin_trap();auto sock=sockets.at(fd);
if(!sock->connected){errno=EPIPE;return -1;}if(length>sock->write_budget)length=sock->write_budget;
const auto* bytes=static_cast<const uint8_t*>(data);sock->output.insert(sock->output.end(),bytes,bytes+length);return int(length);
}
inline int recv(int fd,void* data,size_t length,int flags){
if(flags!=MSG_DONTWAIT||length!=1)__builtin_trap();auto sock=sockets.at(fd);
if(!sock->connected)return 0;if(sock->input.empty()){errno=EAGAIN;return -1;}
*static_cast<uint8_t*>(data)=sock->input.front();sock->input.pop_front();return 1;
}
'''
TCP_RUNTIME = r'''
#include <helpers/HostKiss.h>
#include <Dispatcher.h>
#include <RadioProfileCLI.h>
#include <UsbLogging.h>
#include <WiFi.h>
#include <cassert>
#include <cstring>
using namespace mesh;
char reply[240];
void command(const char* text){assert(hostkiss::handleCommand(text,reply,sizeof(reply),true));}
void input(int socket,std::initializer_list<uint8_t> bytes){auto& q=sockets.at(socket)->input;q.insert(q.end(),bytes);}
int main(){
GuestMemoryFS fs;Radio radio;RadioProfileCLI profiles(radio);Dispatcher dispatcher;
hostkiss::begin(&fs,radio,profiles,dispatcher);
command("set kiss.transport tcp");assert(strstr(reply,"OK"));
command("set kiss on");assert(strstr(reply,"OK"));
WiFi.mode=WIFI_AP;WiFi.status_value=WL_CONNECTED;WiFi.address=123;
hostkiss::loop();assert(listens==0);
command("get kiss");assert(strstr(reply,"waiting-wifi"));
WiFi.mode=WIFI_STA|WIFI_AP;hostkiss::loop();assert(listens==1&&bound_address==123&&bound_port==5004);
command("get kiss");assert(strstr(reply,"listening"));
const int first=newSocket();accepts.push_back(first);hostkiss::loop();
assert(hostkiss::radioActive()&&!hostkiss::usbActive()&&!binary_owned);
assert(sockets.at(first)->output==std::vector<uint8_t>({0xc0,0x0f,1,0xc0}));
const int rejected=newSocket();accepts.push_back(rejected);hostkiss::loop();
assert(!sockets.at(rejected)->connected&&sockets.at(first)->connected);
input(first,{0xc0,0,1,2,3,0xc0});hostkiss::loop();
assert(dispatcher.submitted==1&&dispatcher.packet==std::vector<uint8_t>({1,2,3}));
dispatcher.pending=false;hostkiss::sendComplete(true);hostkiss::loop();hostkiss::loop();
assert(sockets.at(first)->output==std::vector<uint8_t>({0xc0,0x0f,1,0xc0,0xc0,6,0xf8,1,0xc0,0xc0,0x0f,1,0xc0}));
// Backpressure preserves every escaped output byte across partial sends.
sockets.at(first)->output.clear();sockets.at(first)->write_budget=3;
uint8_t packet[255];memset(packet,0xc0,sizeof(packet));
hostkiss::receive(packet,sizeof(packet),1,radio.p.generation[1],0,0);
for(unsigned i=0;i<200;++i)hostkiss::loop();
const auto& bytes=sockets.at(first)->output;
assert(bytes.size()==513&&bytes[0]==0xc0&&bytes[1]==0&&bytes.back()==0xc0);
for(unsigned i=2;i<512;i+=2)assert(bytes[i]==0xdb&&bytes[i+1]==0xdc);
// The profile change drops both the active stream and pending accepts.
const int stale=newSocket();accepts.push_back(stale);input(stale,{0xc0,0,99,0xc0});
++radio.p.generation[1];hostkiss::loop();
assert(!sockets.at(first)->connected&&!sockets.at(stale)->connected&&!dispatcher.pending);
assert(dispatcher.submitted==1&&!hostkiss::radioActive());
// A new explicit runtime profile can start a fresh stream; old RX cannot.
profiles.overlay=false;radio.p.secondary=profiles.saved;++radio.p.generation[1];
const int second=newSocket();accepts.push_back(second);hostkiss::loop();
assert(hostkiss::radioActive()&&sockets.at(second)->connected);
const unsigned before=dispatcher.submitted;
input(second,{0xc0,0xff,0xc0});hostkiss::loop();
assert(!sockets.at(second)->connected&&!hostkiss::radioActive()&&dispatcher.submitted==before);
// Losing station WiFi tears down the raw radio lease and listener.
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"));
}
'''
RUNTIME = r'''
#include <helpers/HostKiss.h>
#include <helpers/HostKissSettings.h>
#include <Dispatcher.h>
#include <RadioProfileCLI.h>
#include <UsbLogging.h>
#include <cassert>
#include <cstring>
using namespace mesh;
void input(std::initializer_list<uint8_t> bytes){serial.input.insert(serial.input.end(),bytes);}
void poll(){hostkiss::loop();hostkiss::serviceUsb();}
char reply[240];
void command(const char* text,bool local=true){assert(hostkiss::handleCommand(text,reply,sizeof(reply),local));}
int main() {
GuestMemoryFS fs;Radio radio;RadioProfileCLI profiles(radio);Dispatcher dispatcher;
hostkiss::begin(&fs,radio,profiles,dispatcher);
command("get kiss");assert(strstr(reply,"> off"));
input({0xc0,0,1,0xc0});assert(!hostkiss::serviceUsb()&&serial.available()==4);
serial.input.clear();
command("set kiss on",false);assert(strstr(reply,"local connection"));
command("set kiss on");assert(strstr(reply,"OK"));
hostkiss::Settings saved;assert(saved.begin(&fs)&&saved.selection().enabled);
input({'g','e','t',' '});assert(!hostkiss::serviceUsb()&&serial.available()==4);
serial.input.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;
const size_t before_ready=serial.output.size();
assert(hostkiss::serviceUsb()&&hostkiss::usbActive()&&binary_owned);
assert(serial.available()==1&&serial.output.size()==before_ready);
binary_ready=true;assert(hostkiss::serviceUsb()&&serial.available()==0);
assert(hostkiss::radioActive()&&radio.p.secondary.params.protocol==RadioProfileProtocol::Kiss);
assert(profiles.saved.params.protocol==RadioProfileProtocol::MeshCore);
input({0,7,0xdb,0xdc,0xdb,0xdd,0xc0});poll();
assert(dispatcher.submitted==1&&dispatcher.packet==std::vector<uint8_t>({7,0xc0,0xdb}));
input({0,8,0xc0});poll();assert(dispatcher.submitted==1);
dispatcher.pending=false;hostkiss::sendComplete(true);poll();assert(dispatcher.submitted==2);
// Cross-profile or old-generation RX can never leak into the host stream.
poll();poll();
serial.output.clear();uint8_t rx[]={9};
hostkiss::receive(rx,1,0,radio.p.generation[1],0,0);
hostkiss::receive(rx,1,1,radio.p.generation[1]-1,0,0);poll();
assert(serial.output.empty());
hostkiss::receive(rx,1,1,radio.p.generation[1],0,0);poll();
assert(serial.output==std::vector<uint8_t>({0xc0,0,9,0xc0}));
// A failed settings write preserves the active session and lease.
fs.fail_open_write=true;command("set kiss off");
assert(strstr(reply,"save failed")&&hostkiss::usbActive()&&hostkiss::radioActive());
fs.fail_open_write=false;
// Profile invalidation cancels stale TX but quarantines USB binary input.
radio.p.secondary.params.protocol=RadioProfileProtocol::Meshtext;
++radio.p.generation[1];const unsigned cleared=profiles.clear;
poll();assert(hostkiss::usbActive()&&!hostkiss::radioActive());
assert(profiles.clear==cleared&&radio.p.secondary.params.protocol==RadioProfileProtocol::Meshtext);
const unsigned submitted=dispatcher.submitted;
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);
hostkiss::resetUsbSession();assert(!hostkiss::usbActive()&&!binary_owned&&!dispatcher.pending);
// 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);
input({0xc0,0,8,0xc0});assert(!hostkiss::serviceUsb());serial.input.clear();
hostkiss::begin(&fs,radio,profiles,dispatcher);command("get kiss");assert(strstr(reply,"> off"));
command("set kiss.transport tcp");assert(strstr(reply,"requires ESP32"));
assert(!hostkiss::handleCommand("get kissing",reply,sizeof(reply),true));
}
'''
class HostKissTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.compiler = shutil.which("g++") or shutil.which("clang++")
if not cls.compiler:
raise unittest.SkipTest("host C++ compiler required")
cls.work = tempfile.TemporaryDirectory(prefix="mesh-host-kiss-")
cls.path = Path(cls.work.name)
cls.mocks = cls.path / "mocks"
shutil.copytree(ROOT / "test/fixtures/guest_login/mocks", cls.mocks)
for path in cls.mocks.rglob("*"):
if path.is_file():
path.write_text(path.read_text().replace("/guest_login", "/host_kiss"), encoding="ascii")
@classmethod
def tearDownClass(cls):
cls.work.cleanup()
def build_run(self, name, source, backend=None, runtime=False):
path = self.path / (name + ".cpp")
path.write_text(source, encoding="ascii")
binary = self.path / name
flags = ["-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror",
"-Wno-misleading-indentation", "-Wno-sign-compare"]
if os.name != "nt":
flags += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"]
sources = [str(path)]
includes = ["-I", str(self.mocks), "-I", str(ROOT / "src")]
if backend:
flags += ["-DMESH_ENABLE_HOST_KISS=1", "-D" + backend + "_PLATFORM"]
if backend == "ESP32":
flags += ["-DESP32"]
sources.append(str(ROOT / "src/helpers/HostKissSettings.cpp"))
if backend in ("NRF52", "STM32"):
sources.append(str(ROOT / "src/helpers/AtomicFileWriter.cpp"))
if runtime:
runtime_path = self.path / "HostKiss.cpp"
shutil.copyfile(ROOT / "src/helpers/HostKiss.cpp", runtime_path)
sources.append(str(runtime_path))
includes += ["-I", str(ROOT / "src/helpers")]
(self.mocks / "Dispatcher.h").write_text(RADIO_STUB, encoding="ascii")
(self.mocks / "RadioProfileCLI.h").write_text(PROFILE_STUB, encoding="ascii")
(self.mocks / "UsbLogging.h").write_text(USB_STUB, encoding="ascii")
if backend == "ESP32":
(self.mocks / "WiFi.h").write_text(NETWORK_STUB, encoding="ascii")
(self.mocks / "lwip").mkdir(exist_ok=True)
(self.mocks / "lwip/sockets.h").write_text('#include <WiFi.h>\n', encoding="ascii")
result = subprocess.run([self.compiler, *flags, *includes, *sources, "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_framing_limits_escaping_flow_and_malformed_input(self):
self.build_run("codec", CODEC)
def test_saved_settings_transaction_and_reboot_boundaries(self):
for backend in ("ESP32", "RP2040", "NRF52", "STM32"):
with self.subTest(backend=backend):
self.build_run("settings_" + backend, SETTINGS, backend)
def test_usb_session_ownership_stale_frames_and_toggle(self):
self.build_run("runtime", RUNTIME, "NRF52", runtime=True)
def test_tcp_station_binding_backpressure_sessions_and_toggle(self):
self.build_run("tcp_runtime", TCP_RUNTIME, "ESP32", runtime=True)
def test_disabled_header_needs_no_platform_types(self):
self.build_run("disabled", r'''
#include <helpers/HostKiss.h>
int main(){int a=0;mesh::hostkiss::begin(&a,a,a,a);
return mesh::hostkiss::serviceUsb()||mesh::hostkiss::radioActive();}
''')
if __name__ == "__main__":
unittest.main()
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Exercise Full infrastructure console ownership without stopping RF/network service."""
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import sys
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = r'''
#include <cassert>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <string>
static std::deque<unsigned char> input;
static bool kiss_active = false, reset_pending = false, network_pending = false;
static unsigned reset_reads = 0, kiss_resets = 0, ascii_commands = 0;
static unsigned board_loops = 0, ethernet_loops = 0, network_commands = 0;
static unsigned serial_cancels = 0, terminal_drains = 0;
struct Stream {
int available() const { return int(input.size()); }
int read() {
assert(!input.empty() && input.front() != 0xc0);
int value = input.front(); input.pop_front(); return value;
}
void print(char) { assert(!kiss_active); }
void println(const char*) { assert(!kiss_active); }
void printf(const char*, ...) { assert(!kiss_active); }
} console;
namespace mesh {
void serviceUsbLoggingPort() {}
void serviceUsbTerminalPort() { assert(!kiss_active); ++terminal_drains; }
bool takeUsbTerminalSessionReset() { ++reset_reads; bool value=reset_pending; reset_pending=false; return value; }
bool tryCompleteUsbTerminalSessionReset() { return true; }
bool canAcceptUsbConsoleCommand() { return true; }
Stream& usbConsolePort() { return console; }
void noteUsbLoggingStatsCommand(const char*) {}
namespace power { void serviceRegulatorSettings() {} }
#if MESH_ENABLE_HOST_KISS
namespace hostkiss {
void resetUsbSession() { ++kiss_resets; kiss_active=false; }
bool serviceUsb() {
if (!input.empty() && input.front()==0xc0) {
input.pop_front(); kiss_active=true;
}
bool owned=kiss_active;
if (kiss_active) {
while (!input.empty()) {
unsigned char value=input.front(); input.pop_front();
if (value==0xff) { kiss_active=false; break; }
}
}
return owned;
}
}
#endif
}
struct Mesh {
bool hasPendingSerialOutput() { return false; }
void cancelPendingSerialOutput() { ++serial_cancels; }
void handleUsbCommand(const char* text, char* reply) {
assert(!kiss_active && !strcmp(text,"ver")); ++ascii_commands; strcpy(reply,"version");
}
void cancelLocalOutput(Stream&) {}
bool hasPendingLocalOutput() { return false; }
void handleLocalCommand(const char* text, char* reply, Stream&) {
assert(!strcmp(text,"get kiss")); ++network_commands; strcpy(reply,"status");
}
} the_mesh;
struct Board { void loop() { ++board_loops; } } board;
struct Ethernet : Stream { bool connected() { return true; } } ethernet_client;
static char ethernet_command[160];
void ethernet_loop_maintain() { ++ethernet_loops; }
bool ethernet_take_session_reset() { return false; }
bool ethernet_read_line(char* dest, size_t) {
if (!network_pending) return false;
network_pending=false; strcpy(dest,"get kiss"); return true;
}
bool ethernet_handle_command(const char*, char*) { return false; }
void ethernet_send_reply(const char* text) { assert(!strcmp(text,"status")); }
static constexpr size_t LOCAL_SERIAL_COMMAND_MAX=159;
static char command[LOCAL_SERIAL_COMMAND_MAX+2] = {};
static bool command_overflow=false;
@PUMP@
static void ascii() { for (unsigned char c : std::string("ver\r")) input.push_back(c); }
int main() {
ascii(); serviceCommandInterfaces();
assert(ascii_commands==1 && board_loops==1 && ethernet_loops==1);
#if MESH_ENABLE_HOST_KISS
strcpy(command,"old prefix"); command_overflow=true;
input={0xc0,0x00,0x41,0x42}; network_pending=true;
unsigned old_drains=terminal_drains;
serviceCommandInterfaces();
assert(kiss_active && input.empty());
assert(command[0]==0 && !command_overflow && ascii_commands==1);
assert(board_loops==2 && ethernet_loops==2 && network_commands==1);
assert(terminal_drains==old_drains);
#if MESH_USB_CONSOLE_COOPERATIVE
assert(serial_cancels>0);
#endif
// A binary session with no new USB bytes must keep ownership and service
// unrelated network commands. The owner must not return out of the pump.
network_pending=true; serviceCommandInterfaces();
assert(kiss_active && board_loops==3 && ethernet_loops==3 && network_commands==2);
assert(terminal_drains==old_drains);
// EXIT returns ownership but its poll still bypasses the ASCII parser.
input={0xff}; serviceCommandInterfaces();
assert(!kiss_active && ascii_commands==1);
ascii(); serviceCommandInterfaces(); assert(ascii_commands==2);
#if MESH_USB_CONSOLE_COOPERATIVE
input={0xc0,0x00,0x43}; serviceCommandInterfaces(); assert(kiss_active);
reset_pending=true;
unsigned previous_resets=reset_reads;
strcpy(command,"stale"); command_overflow=true;
serviceCommandInterfaces();
assert(!kiss_active && kiss_resets==1 && reset_reads==previous_resets+1);
assert(command[0]==0 && !command_overflow);
ascii(); serviceCommandInterfaces(); assert(ascii_commands==3);
#endif
#endif
}
'''
class HostKissInfrastructureTest(unittest.TestCase):
def test_release_builder_selects_only_full_supported_roles(self):
builder = (ROOT / "build_legacy.sh").read_text()
promotion_start = builder.index(' if [ "$ESP32_FULL_BUILD" = "1" ] \\\n || is_companion_radio_full_target "$env_name"; then')
promotion_end = builder.index('\n fi', promotion_start) + len('\n fi')
kiss_start = builder.index(' # Full LoRa images retain their normal role')
kiss_end = builder.index(' declare_build_capability_contract ', kiss_start)
policy = builder[promotion_start:promotion_end] + '\n' + builder[kiss_start:kiss_end]
# Execute the release builder's actual promotion and feature-selection
# blocks, using its target classifiers. PlatformIO must never run here.
script = r'''
source "$1/build_legacy.sh"
pio() { printf 'Unexpected PlatformIO invocation\n' >&2; return 99; }
apply_policy() {
local env_name=$1 env_platform=$2 BUILD_PROFILE_FOR_TARGET=$3
local NRF52_OTA_SENSOR_PROFILE=$4 ESP32_FULL_BUILD=$5
local PLATFORMIO_BUILD_FLAGS='-DEXISTING_FLAG=1'
PIO_ENV_PLATFORM_BY_NAME[$env_name]=$env_platform
@POLICY@
case "$PLATFORMIO_BUILD_FLAGS" in
*-DMESH_ENABLE_HOST_KISS=1*) actual=1 ;;
*) actual=0 ;;
esac
if [ "$actual" != "$6" ]; then
printf 'Incorrect KISS build policy: %s/%s/%s/%s -> %s, expected %s\n' \
"$env_name" "$env_platform" "$BUILD_PROFILE_FOR_TARGET" \
"$NRF52_OTA_SENSOR_PROFILE" "$actual" "$6" >&2
return 1
fi
}
'''.replace("@POLICY@", policy)
cases = (
("heltec_v3_repeater", "ESP32_PLATFORM", "full", "", "1", "1"),
("heltec_v4_companion_radio_full", "ESP32_PLATFORM", "standard", "", "0", "1"),
("heltec_v3_repeater", "ESP32_PLATFORM", "standard", "", "0", "0"),
("heltec_v3_sensor", "ESP32_PLATFORM", "reduced", "", "0", "0"),
("RAK_4631_companion_radio_full", "NRF52_PLATFORM", "standard", "", "0", "1"),
("RAK_4631_sensor_lora_ota_no_external_sensors", "NRF52_PLATFORM", "auto", "full", "0", "1"),
("RAK_4631_sensor_lora_ota_no_external_sensors", "NRF52_PLATFORM", "auto", "reduced", "0", "0"),
("RAK_3401_repeater_lora_ota_no_external_sensors", "NRF52_PLATFORM", "auto", "full", "0", "1"),
("RAK_3401_room_server_lora_ota_no_external_sensors", "NRF52_PLATFORM", "auto", "reduced", "0", "0"),
("RAK_4631_repeater_rak15002_sd_seeder_only", "NRF52_PLATFORM", "auto", "full", "0", "0"),
("heltec_v3_kiss_modem", "ESP32_PLATFORM", "full", "", "1", "0"),
("wio_e5_repeater", "STM32_PLATFORM", "full", "", "0", "0"),
("xiao_rp2040_repeater", "RP2040_PLATFORM", "full", "", "0", "0"),
)
for case in cases:
script += "apply_policy " + shlex.join(case) + " || exit $?\n"
result = subprocess.run(["bash", "-c", script, "host-kiss-policy", str(ROOT)],
text=True, capture_output=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("Unexpected PlatformIO invocation", result.stdout + result.stderr)
def test_actual_repeater_console_preserves_network_and_housekeeping(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
pump = body((ROOT / "examples/simple_repeater/main.cpp").read_text(),
"static void __attribute__((noinline)) serviceCommandInterfaces()")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
for enabled, cooperative, lose_owner in ((0, 0, False), (0, 1, False),
(1, 0, False), (1, 1, False),
(1, 1, True)):
with self.subTest(enabled=enabled, cooperative=cooperative, lose_owner=lose_owner):
candidate = pump
if lose_owner:
candidate = candidate.replace("if (mesh::hostkiss::serviceUsb())",
"if (false && mesh::hostkiss::serviceUsb())")
self.assertNotEqual(candidate, pump)
with tempfile.TemporaryDirectory(prefix="host-kiss-infra-") as directory:
directory = Path(directory)
(directory / "test.cpp").write_text(FIXTURE.replace("@PUMP@", candidate), encoding="ascii")
result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", *sanitizers,
"-DMESH_ENABLE_HOST_KISS=" + str(enabled),
"-DMESH_USB_CONSOLE_COOPERATIVE=" + str(cooperative), "-DETHERNET_ENABLED=1",
str(directory / "test.cpp"), "-o", str(directory / "test")],
text=True, capture_output=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(directory / "test")], text=True,
capture_output=True, timeout=10)
if lose_owner:
self.assertNotEqual(result.returncode, 0,
"Negative control did not lose KISS ownership")
else:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_all_infrastructure_roles_claim_before_terminal_output_and_keep_rf_loop(self):
for role in ("simple_repeater", "simple_room_server", "simple_sensor"):
with self.subTest(role=role):
source = (ROOT / "examples" / role / "main.cpp").read_text()
signature = ("static void __attribute__((noinline)) serviceCommandInterfaces()"
if role == "simple_repeater" else "void loop()")
pump = body(source, signature)
self.assertEqual(pump.count("takeUsbTerminalSessionReset()"), 1)
self.assertLess(pump.index("mesh::hostkiss::resetUsbSession()"),
pump.index("mesh::hostkiss::serviceUsb()"))
self.assertLess(pump.index("mesh::hostkiss::serviceUsb()"),
pump.index("mesh::serviceUsbTerminalPort()"))
self.assertIn("usb_ready = false;", pump)
loop = body(source, "void loop()")
self.assertIn("the_mesh.loop();", loop)
self.assertIn("if (!mesh::hostkiss::usbActive())", loop)
self.assertIn("sleep_guard(!mesh::hostkiss::usbActive()", loop)
recovery = body(source, "static bool usbLoggingRecoverySafe(")
self.assertIn("if (mesh::hostkiss::usbActive()) return false;", recovery)
def test_common_binding_routing_and_loop_use_loaded_radio_profiles(self):
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
management = (ROOT / "src/helpers/CommonCLI_Management.cpp").read_text()
begin = body(management, "void CommonCLI::beginManagement(")
self.assertIn("mesh::hostkiss::begin(fs, *radio, _radio_profiles, mesh);", begin)
command = body(common, "void CommonCLI::handleCommand(")
self.assertIn("mesh::hostkiss::handleCommand(command, reply, 157, sender_timestamp == 0)", command)
loop = body(common, "void CommonCLI::loop()")
guarded = body(loop, "if (!mesh::morseTimingActive(")
self.assertIn("mesh::hostkiss::loop();", guarded)
self.assertLess(guarded.index("_radio_profiles.loop();"),
guarded.index("mesh::hostkiss::loop();"))
if __name__ == "__main__":
unittest.main()
+246
View File
@@ -0,0 +1,246 @@
"""Exercise raw radio2 host KISS isolation and concrete modem protocol selection."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from cpp_source import body
from test_meshtext_transport import HARNESS as FOREIGN_HARNESS
import test_meshtext_config as feature_config
import test_ethernet_radio as ethernet_test
ROOT = Path(__file__).resolve().parents[1]
class HostKissRadioTest(unittest.TestCase):
def run_cpp(self, source, *, dispatcher=False):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory(prefix="meshcore-host-kiss-") as folder:
cpp, exe = Path(folder) / "test.cpp", Path(folder) / "test"
cpp.write_text(source, encoding="ascii")
args = [compiler, "-std=c++17", "-Wall", "-Wextra",
"-DMESH_ENABLE_HOST_KISS=1", "-DMESH_ENABLE_MESHTEXT_CHAT=0",
"-DMESH_ENABLE_MESHTEXT_WIRE=0", "-DMESH_ENABLE_ETHERNET_WIRE=0",
"-DMESH_ENABLE_TX_BEACONS=0", "-I", str(ROOT / "test/mocks"),
"-I", str(ROOT / "src")]
if os.name != "nt":
args += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
if dispatcher:
args += [str(ROOT / "src" / name) for name in (
"Dispatcher.cpp", "Packet.cpp", "helpers/StaticPoolPacketManager.cpp")]
result = subprocess.run(args + [str(cpp), "-o", str(exe)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr[-16000:])
env = dict(os.environ)
env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=0"
result = subprocess.run([str(exe)], capture_output=True, text=True, env=env)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_actual_dispatcher_host_only_routing_and_scheduler_admission(self):
fixture = FOREIGN_HARNESS.split("int main()", 1)[0]
fixture = fixture.replace("RadioProfileProtocol::Meshtext", "RadioProfileProtocol::Kiss")
fixture = fixture.replace("bool supportsMeshtext() const override", "bool supportsHostKiss() const override")
fixture = fixture.replace("return queueForeignRadioFrame(b,n,1,_radio->profiles()->generation[1]);",
"return submitHostKissFrame(b,n,_radio->profiles()->generation[1]);")
hooks = r'''
namespace mesh { namespace hostkiss {
unsigned rx=0; std::vector<bool> completions;
void receive(const uint8_t* b,size_t n,uint8_t p,uint32_t g,float,float) {
assert(n==3 && b[0]==0x11 && p==1 && g); ++rx;
}
void sendComplete(bool success) { completions.push_back(success); }
} }
int main() {
using namespace mesh::hostkiss;
{ Fixture f;const auto done=completions.size();assert(f.d.queue(bytes));
assert(f.d.hostKissFramePending() && !f.d.queue(bytes));f.tick();
assert(f.radio.sends==1 && f.radio.active==1 && f.radio.sent==std::vector<uint8_t>(bytes,bytes+3));
assert(!f.d.cancelHostKissFrame());f.radio.complete=true;f.tick(20);
assert(completions.size()==done+1 && completions.back() && f.d.done.empty());
assert(!f.d.hostKissFramePending() && f.radio.p.tx_packets[1]==1);
assert(!f.d.getNumSentFlood() && !f.d.getNumSentDirect() && f.d.getTotalAirTime()==20);
}
{ Fixture f;auto before=rx;f.radio.active=1;f.radio.incoming=true;f.tick();
assert(rx==before+1 && !f.d.foreign_rx && !f.d.mc_rx && !f.d.raw_logs);
assert(!f.d.getNumRecvFlood() && !f.d.getNumRecvDirect() && !f.d.getLastMeshCoreRecvMillis());
assert(f.radio.processed==1 && f.radio.p.rx_packets[1]==1);
f.radio.p.secondary.mode=RadioProfileMode::Off;f.radio.incoming=true;f.tick();assert(rx==before+1);
f.radio.active=0;f.radio.p.primary.protocol=RadioProfileProtocol::Kiss;
f.radio.incoming=true;f.tick();assert(rx==before+1 && !f.d.foreign_rx && !f.d.raw_logs);
}
{ Fixture f;auto done=completions.size();assert(f.d.queue(bytes));assert(f.d.cancelHostKissFrame());
assert(completions.size()==done+1 && !completions.back() && !f.d.hostKissFramePending());
f.tick();assert(!f.radio.sends && f.d.done.empty());
}
{ Fixture f;auto done=completions.size();assert(f.d.queue(bytes));++f.radio.p.generation[1];f.tick();
assert(!f.radio.sends && !f.d.hostKissFramePending() && completions.size()==done+1 && !completions.back());
}
{ Fixture f;auto done=completions.size();assert(f.d.queue(bytes));f.tick();++f.radio.p.generation[1];
f.radio.complete=true;f.tick();assert(completions.size()==done+1 && !completions.back() && f.radio.finishes==1);
}
{ Fixture f;assert(!f.d.submitHostKissFrame(bytes,3,f.radio.p.generation[1]+1));
f.radio.supported=false;assert(!f.d.queue(bytes));f.radio.supported=true;
f.radio.p.secondary.mode=RadioProfileMode::Rx;assert(!f.d.queue(bytes));
f.radio.p.secondary.mode=RadioProfileMode::Off;assert(!f.d.queue(bytes));
f.radio.p.secondary.mode=RadioProfileMode::RxTx;f.radio.p.secondary.params.protocol=RadioProfileProtocol::MeshCore;
assert(!f.d.queue(bytes));f.radio.p.secondary.params.protocol=RadioProfileProtocol::Kiss;
assert(!f.d.queue(nullptr));assert(!f.d.queue(bytes,0));uint8_t full[256]={};assert(!f.d.queue(full,256));
f.radio.carrier=true;assert(!f.d.queue(bytes));f.radio.carrier=false;
f.d.allow_foreign=false;assert(!f.d.queue(bytes));
}
{ Fixture f;auto done=completions.size();f.radio.prepare=RadioParamApplyResult::BUSY;
assert(f.d.queue(bytes));f.tick();assert(!f.radio.sends && f.d.hostKissFramePending());
f.radio.prepare=RadioParamApplyResult::APPLIED;f.tick(11);assert(f.radio.sends==1 && f.radio.active==1);
f.d.setRadioAvailable(false);assert(completions.size()==done+1 && !completions.back());
}
{ Fixture f;auto done=completions.size();f.radio.start_ok=false;assert(f.d.queue(bytes));f.tick();f.tick(2000);
assert(f.radio.sends==1 && !f.d.hostKissFramePending() && completions.size()==done+1 && !completions.back());
}
{ Fixture f;auto done=completions.size();assert(f.d.queue(bytes));f.tick();f.tick(1151);
assert(f.radio.sends==1 && f.radio.finishes==1 && f.radio.recovery==1);
assert(completions.size()==done+1 && !completions.back());
}
{ Fixture f;f.radio.receiving=true;assert(f.d.queue(bytes));f.tick();f.tick(201);
assert(!f.radio.sends && f.d.hostKissFramePending());f.radio.receiving=false;f.tick(201);
assert(f.radio.sends==1 && f.radio.active==1);
}
{ Fixture f;f.d.duty_window=200;f.d.begin();assert(f.d.queue(bytes));f.tick();
f.clock.now+=100;f.radio.complete=true;f.d.loop();assert(f.d.getRemainingTxBudget()==0);
assert(f.d.queue(bytes));f.tick(100);assert(f.radio.sends==1);f.tick(101);assert(f.radio.sends==2);
}
{ Fixture f;assert(f.d.queue(bytes));f.tick();f.radio.complete=true;f.tick();assert(f.d.queue(bytes));
auto* p=newPacket(f.d,RADIO_TX_PRIMARY);assert(f.d.sendPacket(p,0));f.tick();
assert(f.radio.sends==2 && f.radio.active==0);f.radio.complete=true;f.tick();f.tick();
assert(f.radio.sends==3 && f.radio.active==1);
}
{ Fixture f;auto* p=newPacket(f.d);f.radio.p.cross=RadioCrossMode::On;
assert(!f.radio.p.canCross() && f.d.getTransmitProfileMask(p)==1);
assert(f.d.sendPacket(p,0));f.tick();assert(f.radio.sends==1 && f.radio.active==0);
auto* denied=newPacket(f.d,RADIO_TX_SECONDARY);assert(!f.d.sendPacket(denied,0));
}
}
'''
self.run_cpp(fixture + hooks, dispatcher=True)
def test_actual_protocol_selection_on_every_supported_wrapper(self):
for family in ("SX1262", "SX1268", "SX1276", "LLCC68", "STM32WLx", "LR1110", "LR1121", "LR2021"):
with self.subTest(family=family):
source = (ROOT / "src/helpers/radiolib" / ("Custom" + family + "Wrapper.h")).read_text()
select = body(source, "bool applyProtocol(mesh::RadioProfileProtocol protocol)")
select = select.replace(" override", "")
harness = r'''
#include <cassert>
#include <RadioProfiles.h>
#include <helpers/radiolib/LR2021ApplyStatus.h>
#define RADIOLIB_ERR_NONE 0
struct Driver {
unsigned calls=0; uint8_t sync=0; int status=0;
int setProfileProtocol(uint8_t s) { ++calls;sync=s;return status; }
};
using @CLASS@=Driver;
struct TestWrapper {
Driver driver; Driver* _radio=&driver;
bool supportsMeshtext() const { return false; }
bool supportsHostKiss() const { return true; }
int recordApply(mesh::lr2021::ApplyStatus::Stage,int code) { return code; }
@SELECT@
};
int main() {
using P=mesh::RadioProfileProtocol;
TestWrapper w;assert(w.applyProtocol(P::Kiss));assert(w.driver.sync==0x12 && w.driver.calls==1);
assert(w.applyProtocol(P::MeshCore));assert(w.driver.sync==0x12 && w.driver.calls==2);
assert(!w.applyProtocol(static_cast<P>(255)) && w.driver.calls==2);
w.driver.status=-17;assert(!w.applyProtocol(P::Kiss));
}
'''
self.run_cpp(harness.replace("@CLASS@", "Custom" + family).replace("@SELECT@", select))
def test_actual_cli_protocol_lease_preserves_saved_intent_and_other_owners(self):
code = r'''
#define main existing_cli_main
#include "test/fixtures/radio_profiles/cli_test.cpp"
#undef main
int main() {
using P=mesh::RadioProfileProtocol;
Fixture f;
f.cmd("set radio2 910.5,500,8,5,rxtx,80");f.advance(2000);
const auto saved=f.fs.files["/radio_profiles"];
const auto tuple=f.radio.p.secondary;
const auto intent=f.cli.secondaryIntentGeneration();
const auto generation=f.radio.p.generation[1];
assert(f.cli.manualSecondaryConfigured() && f.cli.secondaryRuntimeAvailable());
assert(f.cli.useSecondaryProtocol(P::Kiss));
assert(f.radio.p.secondary.params.protocol==P::Kiss && f.radio.p.generation[1]!=generation);
assert(f.radio.profilePreamble(1)==80 && f.fs.files["/radio_profiles"]==saved);
assert(f.cli.secondaryIntentGeneration()==intent && f.cli.savedSecondaryConfig().params==tuple.params);
const auto kiss_generation=f.radio.p.generation[1];
assert(f.cli.useSecondaryProtocol(P::Kiss) && f.radio.p.generation[1]==kiss_generation);
// A running host lease is not replaceable by another foreign service.
assert(!f.cli.useSecondaryProtocol(P::Meshtext));
assert(!f.cli.useSecondaryProtocol(P::Ethernet));
f.cli.clearSecondaryProtocol();
assert(f.radio.p.secondary.params==tuple.params && f.radio.p.secondary.mode==tuple.mode);
assert(f.fs.files["/radio_profiles"]==saved);
// Saved manual changes retire the host intent immediately, while preserving
// the two-second reply window before publishing the new running tuple.
assert(f.cli.useSecondaryProtocol(P::Kiss));
f.cmd("set radio2 912.5,500,9,5,rxtx,88");
assert(f.cli.secondaryIntentGeneration()!=intent && !f.cli.secondaryRuntimeAvailable());
assert(f.radio.p.secondary.params.protocol==P::Kiss && f.radio.p.secondary.params.freq==910.5f);
f.cli.clearSecondaryProtocol();f.advance(1999);
assert(f.radio.p.secondary.params.protocol==P::Kiss && f.radio.p.secondary.params.freq==910.5f);
f.advance(1);
assert(f.radio.p.secondary.params.protocol==P::MeshCore && f.radio.p.secondary.params.freq==912.5f);
assert(f.radio.profilePreamble(1)==88 && f.cli.secondaryRuntimeAvailable());
assert(f.cli.useSecondaryProtocol(P::Kiss));
// A direct outside owner wins even if the old host later releases its
// overlay. Old host cleanup must not restore the saved tuple over it.
auto foreign=f.radio.p.secondary;foreign.params.protocol=P::Meshtext;foreign.params.freq=913.5f;
f.radio.p.setSecondary(foreign,false);
f.cli.clearSecondaryProtocol();f.advance(1);
assert(f.radio.p.secondary.params==foreign.params && !f.cli.secondaryRuntimeAvailable());
f.cmd("set radio2 914.5,500,9,5,rxtx,88");f.advance(2000);
assert(f.cli.secondaryRuntimeAvailable() && f.cli.useSecondaryProtocol(P::Kiss));
#if MESH_ENABLE_MESHTEXT_CHAT
f.cli.clearSecondaryProtocol();assert(f.cli.useSecondaryProtocol(P::Meshtext));
const auto mt=f.radio.p.secondary;
assert(!f.cli.useSecondaryProtocol(P::Kiss) && f.radio.p.secondary.params==mt.params);
#endif
#if MESH_ENABLE_ETHERNET_TUNNEL
f.cli.clearSecondaryProtocol();assert(f.cli.useSecondaryProtocol(P::Ethernet));
const auto eth=f.radio.p.secondary;
assert(!f.cli.useSecondaryProtocol(P::Kiss) && f.radio.p.secondary.params==eth.params);
#endif
Fixture absent;assert(!absent.cli.useSecondaryProtocol(P::Kiss));
absent.cmd("set radio2 off");absent.advance(2000);
assert(!absent.cli.useSecondaryProtocol(P::Kiss));
}
'''
runner = ethernet_test.EthernetRadioTest()
runner.setUpClass()
for foreign in (0, 1):
with self.subTest(other_foreign_services=foreign):
runner.compile_run(code, ["-DMESH_ENABLE_HOST_KISS=1",
"-DMESH_ENABLE_MESHTEXT_CHAT=" + str(foreign),
"-DMESH_ENABLE_ETHERNET_TUNNEL=" + str(foreign)],
["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()
for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM"):
checker.assert_orders([platform, "COMPANION_RADIO_FULL=1"], 1, feature="MESH_ENABLE_HOST_KISS")
checker.assert_orders([platform, "COMPANION_RADIO_FULL=0"], 0, feature="MESH_ENABLE_HOST_KISS")
checker.assert_orders([platform, "COMPANION_RADIO_FULL=1", "MESH_ENABLE_HOST_KISS=0"], 0,
feature="MESH_ENABLE_HOST_KISS")
checker.assert_orders([platform, "MESH_ENABLE_HOST_KISS=1"], 1, feature="MESH_ENABLE_FOREIGN_RADIO")
checker.assert_orders([], 0, feature="MESH_ENABLE_HOST_KISS")
checker.assert_orders(["RP2040_PLATFORM", "COMPANION_RADIO_FULL=1"], 0, feature="MESH_ENABLE_HOST_KISS")
checker.assert_orders(["MESH_PRIMARY_ESPNOW=1", "MESH_ENABLE_HOST_KISS=1"], 0,
feature="MESH_ENABLE_HOST_KISS")
if __name__ == "__main__":
unittest.main()
+386
View File
@@ -0,0 +1,386 @@
#!/usr/bin/env python3
"""Execute cached USB text guards and Full Companion raw-KISS handoff code."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
ARDUINO = r'''
#pragma once
#include <cstddef>
#include <cstdint>
class Stream {
public:
virtual ~Stream() = default;
virtual int available() = 0;
virtual int read() = 0;
virtual int peek() = 0;
virtual void flush() = 0;
virtual int availableForWrite() { return 0; }
virtual size_t write(uint8_t) = 0;
virtual size_t write(const uint8_t*, size_t) = 0;
};
'''
TEXT_FIXTURE = r'''
#include <cassert>
#include <string>
#include <atomic>
#include <helpers/UsbBinaryTextGuard.h>
static std::atomic<bool> usb_binary_stream_owned{false};
static std::atomic<uint32_t> usb_text_writers{0};
static bool logging = true, debug = true;
struct Port : Stream {
unsigned reads=0, peeks=0, available_calls=0, flushes=0, capacity_calls=0;
std::string output;
int available() override { ++available_calls; return 3; }
int read() override { ++reads; return 65; }
int peek() override { ++peeks; return 65; }
void flush() override { ++flushes; }
int availableForWrite() override { ++capacity_calls; return 99; }
size_t write(uint8_t value) override { return write(&value,1); }
size_t write(const uint8_t* data,size_t length) override {
#if MESH_ENABLE_HOST_KISS
if (!usb_binary_stream_owned.load()) assert(usb_text_writers.load()==1);
#endif
output.append(reinterpret_cast<const char*>(data),length); return length;
}
} raw, null_port;
namespace mesh {
bool isUsbBinaryStreamOwned() { return usb_binary_stream_owned.load(); }
bool isUsbDebugLoggingEnabled() { return !isUsbBinaryStreamOwned() && logging && debug; }
@COUNTERS@
static Stream& null_usb_logging_stream = null_port;
static Stream& rawUsbLoggingPort() { return logging ? static_cast<Stream&>(raw) : null_port; }
static Stream& rawUsbTerminalPort(bool enabled) { return enabled ? static_cast<Stream&>(raw) : null_port; }
static Stream& rawUsbConsolePort() { return raw; }
@PORTS@
}
int main() {
using namespace mesh;
auto& cached_log=usbLoggingPort();
auto& cached_debug=usbDebugPort();
auto& cached_terminal=usbTerminalPort(true);
auto& cached_console=usbConsolePort();
const uint8_t text[]={'a','b','c'};
assert(cached_log.write(text,3)==3 && raw.output=="abc");
usb_binary_stream_owned.store(true);
#if MESH_ENABLE_HOST_KISS
for (auto* cached: {&cached_log,&cached_debug,&cached_terminal,&cached_console}) {
assert(cached->write(text,3)==3 && cached->write(uint8_t('!'))==1);
assert(!cached->available() && cached->read()==-1 && cached->peek()==-1);
assert(!cached->availableForWrite());cached->flush();
}
assert(raw.output=="abc" && !raw.reads && !raw.peeks && !raw.available_calls);
assert(!raw.flushes && !raw.capacity_calls && !canAcceptUsbConsoleCommand());
// New port lookups must be guarded too, without retargeting cached writers
// to a binary facade. Binary output uses its separate raw transport.
assert(usbLoggingPort().write(text,3)==3);
assert(usbTerminalPort(true).write(text,3)==3);
assert(usbConsolePort().write(text,3)==3 && raw.output=="abc");
assert(raw.write(reinterpret_cast<const uint8_t*>("KISS"),4)==4 && raw.output=="abcKISS");
usb_binary_stream_owned.store(false);
assert(cached_terminal.write(text,3)==3 && raw.output=="abcKISSabc");
assert(cached_log.available()==3 && cached_log.read()==65 && cached_log.peek()==65);
cached_console.flush();assert(cached_console.availableForWrite()==99 && canAcceptUsbConsoleCommand());
assert(raw.flushes==1 && raw.capacity_calls==1);
#else
// Feature-disabled images retain the prior raw facade and layouts.
assert(&cached_log==&raw && &cached_debug==&raw && &cached_terminal==&raw && &cached_console==&raw);
assert(cached_console.write(text,3)==3 && raw.output=="abcabc");
#endif
}
'''
COMPANION_FIXTURE = r'''
#include <cassert>
#include <cstddef>
#include <cstdint>
#include <deque>
static bool kiss_active=false, terminal_mode=false, usb_mota_mode=false, binary_owned=false;
static bool usb_logging_reply_pending=true;
static bool host_kiss_usb_parked=false,host_kiss_usb_restore_enabled=false;
static bool usb_terminal_discard_line=false,usb_logging_terminal_mode=false;
static unsigned serial_cancels=0,route_cancels=0,forgotten_routes=0;
static unsigned input_resets=0,text_discards=0,line_clears=0,reader_cancels=0,mota_resets=0;
static unsigned ascii_calls=0,usb_binary_calls=0,network_calls=0,kiss_resets=0;
static std::deque<uint8_t> input;
struct Port {} terminal_port;
struct Usb {
bool enabled=true,passthrough=false;
unsigned resets=0,frames=19,enables=0,disables=0;
bool isEnabled() const { return enabled; }
void enable(){enabled=true;++enables;}
void disable(){enabled=false;++disables;}
void resetSessionState(){++resets;}
unsigned getCompletedFrameCount()const{return frames;}
void setPassthroughMode(bool value){passthrough=value;}
} usb_serial_interface;
static bool ble_stream_pending=true,ble_operations_pending=true;
struct Mesh {
bool usb_stream_pending=true,usb_operations_pending=true;
void cancelSerialResponseStream(Usb* p){assert(p==&usb_serial_interface);++serial_cancels;usb_stream_pending=false;}
void cancelSerialOperationsForRoute(Usb* p){assert(p==&usb_serial_interface);++route_cancels;usb_operations_pending=false;}
void resetUsbHostSessionInput(){++input_resets;}
bool isTerminalMode()const{return terminal_mode;}
} the_mesh;
struct Manager {
bool enabled=true;
bool isEnabled()const{return enabled;}
void forgetReplyRouteForDisconnected(Usb* p){assert(p==&usb_serial_interface);++forgotten_routes;}
void loop(){++network_calls;if(usb_serial_interface.enabled && !input.empty())++usb_binary_calls;}
} interface_manager;
struct SessionProbe {
unsigned cancels=0,requests=0,last_frame=0;
void cancel(){++cancels;}
void request(unsigned frame){++requests;last_frame=frame;}
} usb_binary_startup_probe,usb_ascii_session_default;
void clearUsbTerminalLine(){++line_clears;}
void leaveUsbTerminalMode(bool banner){assert(!banner);terminal_mode=false;}
void leaveUsbMotaMode(bool resume){assert(!resume);usb_mota_mode=false;}
namespace mesh {
Port& usbTerminalPort(){return terminal_port;}
void cancelReaderCommand(Port* p){assert(p==&terminal_port);++reader_cancels;}
void discardUsbTerminalOutput(){++text_discards;}
void setUsbBinaryStreamOwned(bool value){binary_owned=value;}
namespace ota {
struct Source {void resetSessionState(){++mota_resets;}} source;
struct OtaContext {static Source& serialFolderSource(){return source;}};
}
namespace hostkiss {
bool usbActive(){return kiss_active;}
void resetUsbSession(){++kiss_resets;if(kiss_active){kiss_active=false;binary_owned=false;}}
bool serviceUsb(){
if(!kiss_active && !input.empty() && input.front()==0xc0){input.pop_front();kiss_active=true;binary_owned=true;}
bool claimed=kiss_active;
while(kiss_active && !input.empty()){
auto byte=input.front();input.pop_front();
if(byte==0xff){kiss_active=false;binary_owned=false;break;}
}
return claimed;
}
}
}
@CANCEL@
@RESET@
@SERVICE@
static bool poll(){
bool claimed=serviceCompanionHostKissUsb();
if(!claimed && !input.empty()){++ascii_calls;input.clear();}
interface_manager.loop();
return claimed;
}
int main(){
input={'v','e','r','\r'};assert(!poll() && ascii_calls==1 && !serial_cancels);
#if MESH_ENABLE_HOST_KISS
terminal_mode=true;usb_mota_mode=true;usb_terminal_discard_line=usb_logging_terminal_mode=true;
input={0xc0,0x00,0x41};assert(poll());
assert(kiss_active && host_kiss_usb_parked && host_kiss_usb_restore_enabled && !usb_serial_interface.enabled);
assert(serial_cancels==1 && route_cancels==1 && forgotten_routes==1);
assert(!the_mesh.usb_stream_pending && !the_mesh.usb_operations_pending);
assert(ble_stream_pending && ble_operations_pending && network_calls==2);
assert(!terminal_mode && !usb_terminal_discard_line && !usb_logging_terminal_mode);
assert(input_resets==1 && line_clears==1 && text_discards==1);
assert(!usb_logging_reply_pending && usb_binary_startup_probe.cancels==1 && usb_ascii_session_default.cancels==1);
#if COMPANION_FEATURE_READER
assert(reader_cancels==1);
#endif
#if COMPANION_FEATURE_USB_MOTA_SOURCE
assert(!usb_mota_mode && mota_resets==1);
#endif
assert(poll() && network_calls==3 && !usb_binary_calls && ascii_calls==1);
// RETURN ends binary ownership, but does not dispatch a trailing ASCII or
// Companion frame during this same poll. Restore is the next poll's work.
input={0xff,'v','e','r','\r'};assert(poll());
assert(!kiss_active && host_kiss_usb_parked && !usb_serial_interface.enabled && binary_owned);
assert(ascii_calls==1 && !usb_binary_calls && !input.empty());
assert(!poll());
assert(!host_kiss_usb_parked && !host_kiss_usb_restore_enabled && usb_serial_interface.enabled && !binary_owned);
assert(ascii_calls==2 && !usb_binary_calls && usb_serial_interface.resets==1);
assert(usb_ascii_session_default.requests==1 && usb_ascii_session_default.last_frame==19);
// A transport already disabled by its manager must not be resurrected.
usb_serial_interface.enabled=false;input={0xc0};assert(poll());
assert(!host_kiss_usb_restore_enabled);input={0xff};assert(poll());assert(!poll());
assert(!usb_serial_interface.enabled && !host_kiss_usb_parked);
// Disabling the manager during the KISS lease also vetoes restoration.
usb_serial_interface.enabled=true;input={0xc0};assert(poll());interface_manager.enabled=false;
input={0xff};assert(poll());assert(!poll());assert(!usb_serial_interface.enabled);
interface_manager.enabled=true;
// A disconnect between RETURN and the next poll also releases the saved
// one-poll quarantine even though the runtime KISS session already ended.
usb_serial_interface.enabled=true;input={0xc0};assert(poll());
input={0xff};assert(poll());assert(binary_owned && !kiss_active);
resetUsbTerminalHostSession();
assert(!binary_owned && !host_kiss_usb_parked && usb_serial_interface.enabled && kiss_resets==1);
// An actual host-session reset cancels only USB work and retires KISS.
usb_serial_interface.enabled=true;input={0xc0};assert(poll());
resetUsbTerminalHostSession();
assert(!kiss_active && kiss_resets==2 && !host_kiss_usb_parked && !host_kiss_usb_restore_enabled);
assert(usb_serial_interface.enabled && !usb_serial_interface.passthrough);
assert(ble_stream_pending && ble_operations_pending && !usb_terminal_discard_line && !usb_logging_terminal_mode);
#else
assert(!serviceCompanionHostKissUsb() && usb_serial_interface.enabled);
assert(!serial_cancels && !route_cancels && !forgotten_routes && !input_resets);
#endif
}
'''
class HostKissUsbTest(unittest.TestCase):
def run_cpp(self, source, *, enabled, reader=0, mota=0, fail=False, extra_flags=()):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory(prefix="host-kiss-usb-") as directory:
directory = Path(directory)
(directory / "Arduino.h").write_text(ARDUINO, encoding="ascii")
(directory / "test.cpp").write_text(source, encoding="ascii")
flags = ["-std=c++17", "-Wall", "-Wextra", "-DMESH_ENABLE_HOST_KISS=" + str(enabled),
"-DMESH_USB_LOGGING_AVAILABLE=1", "-DMESH_ESP32_TINYUSB_NONBLOCKING=0",
"-DMESH_ESP32_HWCDC_SESSION_GUARD=0", "-DMESH_NRF52_USB_CONSOLE_COOPERATIVE=0",
"-DCOMPANION_FEATURE_READER=" + str(reader),
"-DCOMPANION_FEATURE_USB_MOTA_SOURCE=" + str(mota),
"-I", str(directory), "-I", str(ROOT / "src")]
if sys.platform.startswith("linux"):
flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
result = subprocess.run([compiler, *flags, *extra_flags, str(directory / "test.cpp"), "-o", str(directory / "test")],
capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(directory / "test")], capture_output=True, text=True, timeout=10)
if fail:
self.assertNotEqual(result.returncode, 0, "Negative control failed to lose USB ownership")
else:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_actual_cached_text_facades_suppress_binary_session_writes_and_reads(self):
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
ports = "\n".join(body(source, signature) for signature in (
"Stream& usbLoggingPort()", "Stream& usbDebugPort()", "Stream& usbTerminalPort(bool enabled)",
"Stream& usbConsolePort()", "bool canAcceptUsbConsoleCommand()"))
for enabled in (0, 1):
with self.subTest(enabled=enabled):
counters = "\n".join(body(source, signature) for signature in (
"static bool beginUsbTextWrite()", "static void endUsbTextWrite()"))
self.run_cpp(TEXT_FIXTURE.replace("@PORTS@", ports).replace("@COUNTERS@", counters), enabled=enabled)
def test_actual_companion_claim_return_reset_and_usb_only_cancellation(self):
source = (ROOT / "examples/companion_radio/main.cpp").read_text()
cancel = body(source, "static void cancelUsbSerialOperations()")
reset = body(source, "static void resetUsbTerminalHostSession()")
service = body(source, "static bool serviceCompanionHostKissUsb()")
for enabled, reader, mota, negative in ((0, 0, 0, False), (1, 0, 0, False),
(1, 1, 1, False), (1, 1, 1, True)):
with self.subTest(enabled=enabled, reader=reader, mota=mota, negative=negative):
candidate = service
if negative:
candidate = candidate.replace("host_kiss_usb_parked && !claimed && !mesh::hostkiss::usbActive()",
"host_kiss_usb_parked && !mesh::hostkiss::usbActive()")
self.assertNotEqual(candidate, service)
fixture = COMPANION_FIXTURE.replace("@CANCEL@", cancel).replace("@RESET@", reset).replace("@SERVICE@", candidate)
self.run_cpp(fixture, enabled=enabled, reader=reader, mota=mota, fail=negative)
def test_actual_nrf52_text_queue_discards_backlog_without_draining_binary_stream(self):
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
fixture = r'''
#include <cassert>
#include <atomic>
#include <cstdint>
static std::atomic<bool> usb_binary_stream_owned{false};
static std::atomic<uint32_t> usb_text_writers{0};
static std::atomic<bool> primary_usb_terminal_discard_pending{false};
static uint32_t primary_usb_terminal_seen_reset_generation=0, reset_generation=0;
static bool platform_debug=false, saved_logging=true, saved_debug=true;
uint32_t primaryUsbSessionGeneration(){return reset_generation;}
bool isUsbDebugLoggingEnabled(){return !usb_binary_stream_owned.load() && saved_logging && saved_debug;}
void setPlatformDebugOutputEnabled(bool enabled){platform_debug=enabled;}
struct Queue {
unsigned pending=3,discards=0,services=0; bool busy=false;
bool tryDiscardPending(){++discards;if(busy)return false;pending=0;return true;}
void service(){++services;pending=0;}
} buffered_primary_usb_terminal_port;
void serviceUsbTerminalPort();
void discardUsbTerminalOutput();
bool hasPendingUsbTerminalOutput(){return buffered_primary_usb_terminal_port.pending || primary_usb_terminal_discard_pending.load();}
@BEGIN@
@END@
@OWNED@
@SET@
@READY@
@SERVICE@
@DISCARD@
int main(){
// An admitted writer can outlive the ownership store. Even with no queued
// bytes or busy queue lock, READY must wait for that producer to leave.
assert(beginUsbTextWrite() && usb_text_writers.load()==1);
setUsbBinaryStreamOwned(true);
assert(!isUsbBinaryStreamReady() && !beginUsbTextWrite() && usb_text_writers.load()==1);
endUsbTextWrite();
assert(usb_text_writers.load()==0 && isUsbBinaryStreamReady());
setUsbBinaryStreamOwned(false);
buffered_primary_usb_terminal_port.pending=3;
buffered_primary_usb_terminal_port.busy=true;
setUsbBinaryStreamOwned(true);
assert(!isUsbBinaryStreamReady());
assert(isUsbBinaryStreamOwned() && !platform_debug && saved_logging && saved_debug);
assert(primary_usb_terminal_discard_pending.load() && !buffered_primary_usb_terminal_port.services);
assert(buffered_primary_usb_terminal_port.pending==3);
serviceUsbTerminalPort();assert(!buffered_primary_usb_terminal_port.services);
buffered_primary_usb_terminal_port.busy=false;
serviceUsbTerminalPort();
assert(!buffered_primary_usb_terminal_port.pending && !buffered_primary_usb_terminal_port.services);
assert(!primary_usb_terminal_discard_pending.load() && isUsbBinaryStreamReady());
// New host/reset generations discard rather than expose a queued old line.
buffered_primary_usb_terminal_port.pending=4;++reset_generation;
serviceUsbTerminalPort();
assert(primary_usb_terminal_seen_reset_generation==reset_generation);
assert(!buffered_primary_usb_terminal_port.pending && !buffered_primary_usb_terminal_port.services);
setUsbBinaryStreamOwned(false);assert(!isUsbBinaryStreamOwned() && platform_debug);
serviceUsbTerminalPort();assert(buffered_primary_usb_terminal_port.services==1);
}
'''
for token, signature in (("@BEGIN@", "static bool beginUsbTextWrite()"),
("@END@", "static void endUsbTextWrite()"),
("@READY@", "bool isUsbBinaryStreamReady()"),
("@OWNED@", "bool isUsbBinaryStreamOwned()"),
("@SET@", "void setUsbBinaryStreamOwned(bool owned)"),
("@SERVICE@", "void serviceUsbTerminalPort()"),
("@DISCARD@", "void discardUsbTerminalOutput()")):
fixture = fixture.replace(token, body(source, signature))
self.run_cpp(fixture, enabled=1, extra_flags=("-DNRF52_PLATFORM=1", "-DUSE_TINYUSB=1"))
negative = fixture.replace(" || usb_text_writers.load() != 0", "")
self.assertNotEqual(negative, fixture)
self.run_cpp(negative, enabled=1, fail=True,
extra_flags=("-DNRF52_PLATFORM=1", "-DUSE_TINYUSB=1"))
def test_actual_loop_holds_ascii_and_binary_parser_gates_through_return_poll(self):
source = (ROOT / "examples/companion_radio/main.cpp").read_text()
loop = body(source, "\nvoid loop()")
self.assertLess(loop.index("serviceUsbTerminalHostSessionReset();"),
loop.index("serviceCompanionHostKissUsb();"))
self.assertLess(loop.index("serviceCompanionHostKissUsb();"), loop.index("mesh::serviceUsbLoggingPort();"))
for call in ("serviceUsbAsciiSessionDefault();", "serviceUsbLoggingReplyBarrier();",
"expireUsbBinaryStartupProbeBeforeDispatch();", "serviceUsbMotaResponseBoundary();",
"serviceUsbTerminal();"):
prefix = loop[:loop.index(call)]
self.assertIn("if (!host_kiss_usb_claimed)", prefix[prefix.rfind("#if defined(ENABLE_USB_INTERFACE)"):])
self.assertIn("the_mesh.loop();", loop)
self.assertIn("interface_manager.loop();", loop)
self.assertIn("if (mesh::hostkiss::usbActive()) return false;",
body(source, "static bool usbLoggingRecoverySafe("))
rescue = body((ROOT / "examples/companion_radio/MyMesh.cpp").read_text(), "void MyMesh::checkCLIRescueCmd()")
self.assertLess(rescue.index("if (mesh::isUsbBinaryStreamOwned()) return;"),
rescue.index("mesh::usbCompanionPort()"))
mymesh = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
admission = body(mymesh, "bool allowForeignRadioTransmit() const")
self.assertIn("_radio_available", admission)
self.assertIn("_scheduled_reboot_at == 0", admission)
self.assertIn("mesh::hostkiss::radioActive()", admission)
if __name__ == "__main__":
unittest.main()
+5 -2
View File
@@ -64,6 +64,7 @@ class Stream {
"""
RUNTIME = r'''
#define MESH_ENABLE_HOST_KISS 0
#include <atomic>
#include <cassert>
#include <cstring>
@@ -202,8 +203,10 @@ class Nrf52FrameworkLoggingTest(unittest.TestCase):
source = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
header = (ROOT / "src/helpers/UsbLogging.h").read_text()
parts = {
"DEBUG_GATE": extract_braced(header, "inline bool isUsbDebugLoggingEnabled("),
"LOGGING_PORT": extract_braced(source, "Stream& usbLoggingPort("),
"DEBUG_GATE": extract_braced(header, "inline bool isUsbBinaryStreamOwned(") + "\n"
+ extract_braced(header, "inline bool isUsbDebugLoggingEnabled("),
"LOGGING_PORT": "\n".join(extract_braced(source, signature) for signature in (
"static Stream& rawUsbLoggingPort(", "Stream& usbLoggingPort(")),
"DEBUG_PORT": extract_braced(source, "Stream& usbDebugPort("),
}
hook = extract_braced(source, 'extern "C" size_t meshNrf52FrameworkDebugWrite(')
+2 -1
View File
@@ -330,7 +330,8 @@ int main() {
}
'''
source = source.replace('@NULL@', function(USB, 'class NullUsbLoggingStream') + ';')
source = source.replace('@PORT@', function(USB, 'Stream& usbTerminalPort('))
source = source.replace('@PORT@', '\n'.join(function(USB, signature) for signature in (
'static Stream& rawUsbTerminalPort(', 'Stream& usbTerminalPort(')))
source = source.replace('@WAITING@', function(header, 'bool isTerminalWaitingForInput('))
source = source.replace('@METHODS@', '\n'.join(function(mesh, signature) for signature in (
'Stream& MyMesh::terminalOutput(', 'void MyMesh::printTerminalBanner(',