#!/usr/bin/env python3 """Run actual Companion ASCII/probe and mOTA input handlers on host streams.""" from pathlib import Path import re import subprocess import tempfile import unittest from test_replay_reset_integration import extract_braced ROOT = Path(__file__).resolve().parents[1] MAIN = (ROOT / "examples/companion_radio/main.cpp").read_text() CONTEXT = (ROOT / "src/helpers/ota/OtaContext.h").read_text() HARNESS = r''' #include #include #include #include #include #include #include #include "helpers/ArduinoSerialInterface.h" #include "helpers/ota/MotaSourceSerial.h" #include "helpers/UsbAsciiBinarySwitch.h" #include "helpers/CLICommandUtils.h" #define MAX_TRANS_UNIT @MAX_TRANS_UNIT@ #define COMPANION_FEATURE_USB_MOTA_SOURCE 1 #define COMPANION_FEATURE_NETWORK_TERMINAL 0 #define MESH_USB_LOGGING_AVAILABLE 1 struct Input : Stream { std::deque bytes; std::string output; std::deque> responses; unsigned source_requests=0; bool replenish=false; uint32_t advance_per_read=0; unsigned reads=0; unsigned rx_dropped=0; int available() override { return static_cast(bytes.size()); } int read() override { if(bytes.empty()) return -1; uint8_t result=bytes.front(); bytes.pop_front(); ++reads; g_mock_millis+=advance_per_read; if(replenish) bytes.push_back('x'); return result; } int peek() override { return bytes.empty()?-1:bytes.front(); } int availableForWrite() override { return 4096; } size_t write(uint8_t value) override { output+=char(value); return 1; } size_t write(const uint8_t* p,size_t n) override { if(n>=4 && p[0]=='M' && p[1]=='S') { ++source_requests; if(!responses.empty()) { for(uint8_t value:responses.front()) bytes.push_back(value); responses.pop_front(); } } output.append(reinterpret_cast(p),n); return n; } void push(const char* p) { while(*p) bytes.push_back(uint8_t(*p++)); } // The real HWCDC ISR reads a 64-byte FIFO packet, then abandons its // remaining bytes when xQueueSendFromISR finds the RX queue full. void hwcdcBurst(const std::string& wire,size_t capacity) { for(size_t start=0;start stats_commands; namespace mesh { bool isUsbLoggingEnabled() { return logging; } bool hasDedicatedUsbLoggingPort() { return dedicated; } void noteUsbLoggingStatsCommand(const char* command) { stats_commands.emplace_back(command); } Stream& usbCompanionPort() { return input; } void discardUsbTerminalOutput() {} } struct Mesh { bool terminal=false, waiting=false, attach_succeeds=true; bool attach_with_source=false; unsigned banners=0, attaches=0, detaches=0; std::vector commands; bool isTerminalMode() const { return terminal; } bool isTerminalWaitingForInput() const { return waiting; } void enterTerminalMode(bool banner) { terminal=true; waiting=!banner; if(banner) { ++banners; input.output+="BANNER"; } } void exitTerminalMode() { terminal=false; waiting=false; } void resetUsbHostSessionInput() {} void handleTerminalCommand(char* command) { commands.emplace_back(command); } bool handleLocalControlCommand(const char* command,char* reply,size_t size) { if(!strcmp(command,"ota folder on")) { ++attaches; if(attach_with_source) { auto& source=mesh::ota::OtaContext::serialFolderSource(); mesh::ota::MotaDesc desc; attach_succeeds=source.count()!=0 && source.describe(0,desc); } snprintf(reply,size,"%s",attach_succeeds?"OK attached":"ERR attach failed"); } else { assert(!strcmp(command,"ota folder off")); ++detaches; snprintf(reply,size,"OK detached"); } return true; } } the_mesh; @TERMINAL_LINE_DECL@ static constexpr size_t hwcdc_rx_capacity=@HWCDC_RX_CAPACITY@; static size_t usb_terminal_line_len=0; static bool usb_terminal_discard_line=false; static bool usb_logging_terminal_mode=false,usb_logging_network_parked=false; static mesh::UsbBinaryStartupProbe usb_binary_startup_probe; static mesh::UsbAsciiSessionDefault usb_ascii_session_default; static bool usb_mota_mode=false,usb_mota_disconnect_armed=false; static mesh::UsbMotaEntryOrigin usb_mota_entry_origin=mesh::UsbMotaEntryOrigin::BINARY; static char usb_mota_line[32]={}; static size_t usb_mota_line_len=0; static bool usb_mota_discard_line=false; static const char USB_TERMINAL_START_TOKEN[]="+++MESHCORE-TERM-START"; static const char USB_TERMINAL_STOP_TOKEN[]="+++MESHCORE-TERM-STOP"; static const char USB_MOTA_START_TOKEN[]="ota folder on"; static bool isUsbTerminalDataConnected() { return connected; } static void redrawUsbTerminalInput() {} static void serviceUsbLoggingOwnership(bool) {} static void cancelUsbSerialOperations() {} static void queueUsbTerminalControlReply(const char* reply) { input.output+=reply; } static void drainUsbTerminalOutputBeforeProtocolSwitch() {} @FUNCTIONS@ static void reset() { input=Input(); the_mesh=Mesh(); connected=true; logging=dedicated=false; stats_commands.clear(); mesh::ota::OtaContext::serialFolderSource().resetSessionState(); usb_serial_interface=ArduinoSerialInterface(); usb_serial_interface.begin(input,USB_TERMINAL_START_TOKEN,USB_MOTA_START_TOKEN); usb_serial_interface.enable(); usb_serial_interface.setReceiveFrameCheck(acceptUsbBinaryStartupFrame); usb_logging_terminal_mode=usb_logging_network_parked=false; usb_mota_mode=usb_mota_disconnect_armed=usb_mota_discard_line=false; usb_mota_line_len=0; usb_mota_line[0]=0; clearUsbTerminalLine(); usb_terminal_discard_line=false; g_mock_millis=100; enterUsbTerminalMode(false); } static void finishProbe() { uint8_t frame[MAX_FRAME_SIZE]={}; assert(usb_serial_interface.checkRecvFrame(frame)==2); assert(frame[0]==0x16 && frame[1]==3); serviceUsbTerminal(); assert(!the_mesh.terminal && !usb_serial_interface.isPassthroughMode()); assert(!usb_binary_startup_probe.isActive()); } static std::vector response(uint8_t op,const std::vector& payload, uint8_t status=0) { std::vector result={'m','s',op,status}; result.insert(result.end(),payload.begin(),payload.end()); uint8_t checksum=0; for(uint8_t value:result) checksum^=value; result.push_back(checksum); return result; } int main() { for(const char* leading : {"","\r","\n","\r\n","\n\r\n\r"}) { reset(); input.push(leading); input.frame(); serviceUsbTerminal(); assert(!the_mesh.terminal && usb_binary_startup_probe.isActive()); assert(input.available()==5 && input.peek()=='<'); assert(input.output.empty() && the_mesh.banners==0); finishProbe(); } // Empty Enter reveals the silent terminal without fabricating a command. for(const char* leading : {"\r","\n","\r\n"}) { reset(); input.push(leading); serviceUsbTerminal(); assert(the_mesh.terminal && !the_mesh.waiting && the_mesh.banners==1); assert(!usb_binary_startup_probe.isActive() && input.available()==0); assert(the_mesh.commands.empty()); serviceUsbTerminal(); assert(the_mesh.banners==1); } reset(); input.push("\r\nget name\r\n"); serviceUsbTerminal(); assert(the_mesh.banners==1 && the_mesh.commands.size()==1); assert(the_mesh.commands[0]=="get name" && the_mesh.terminal); // Reaching an empty prompt through an edit also leaves the frame untouched. reset(); input.push("x\b"); input.frame(); serviceUsbTerminal(); assert(usb_binary_startup_probe.isActive() && input.available()==5); finishProbe(); // A typed prefix and an overlong-line discard cannot become binary probes. reset(); input.push("x"); input.frame(); serviceUsbTerminal(); assert(the_mesh.terminal && !usb_binary_startup_probe.isActive()); reset(); the_mesh.waiting=false; usb_terminal_discard_line=true; input.frame(); serviceUsbTerminal(); assert(the_mesh.terminal && !usb_binary_startup_probe.isActive()); assert(usb_terminal_discard_line && usb_terminal_line_len==0); // Single-CDC logging forbids frames; a dedicated logger does not block CDC0. reset(); logging=true; usb_logging_terminal_mode=true; input.push("\r\n"); input.frame(); serviceUsbTerminal(); assert(the_mesh.terminal && !usb_binary_startup_probe.isActive()); reset(); logging=dedicated=true; input.push("\r\n"); input.frame(); serviceUsbTerminal(); assert(usb_binary_startup_probe.isActive()); finishProbe(); reset(); usb_logging_network_parked=true; input.frame(); serviceUsbTerminal(); assert(input.available()==5 && !usb_binary_startup_probe.isActive()); // NUL is legal inside a Binary frame, not a plaintext-line terminator. reset(); input.push("\r\n"); input.frame(0); serviceUsbTerminal(); uint8_t nul_frame[MAX_FRAME_SIZE]={}; assert(usb_serial_interface.checkRecvFrame(nul_frame)==2); assert(nul_frame[0]==0x16 && nul_frame[1]==0); serviceUsbTerminal(); assert(!the_mesh.terminal && !usb_binary_startup_probe.isActive()); // The gate uses the actual completion timestamp, before returning a command // or granting client proof, including delayed mesh work and millis rollover. for(uint32_t start : {100u,UINT32_MAX-20}) { for(uint32_t elapsed : {999u,1000u,1001u}) { reset(); g_mock_millis=start; input.frame(); serviceUsbTerminal(); assert(usb_binary_startup_probe.isActive()); g_mock_millis=start+elapsed; uint8_t command[MAX_FRAME_SIZE]={}; const bool accepted=elapsed<1000; assert(usb_serial_interface.checkRecvFrame(command)==(accepted?2u:0u)); assert(usb_serial_interface.hasReceivedFrame()==accepted); assert(usb_serial_interface.getCompletedFrameCount()==(accepted?1u:0u)); assert(usb_serial_interface.getLastFrameMillis()==(accepted?millis():0)); assert(!usb_serial_interface.isReadBusy()); serviceUsbTerminal(); assert(the_mesh.terminal==!accepted && !usb_binary_startup_probe.isActive()); } } reset(); input.frame(); serviceUsbTerminal(); g_mock_millis=1099; expireUsbBinaryStartupProbeBeforeDispatch(); assert(usb_binary_startup_probe.isActive()); // Time spent inside mesh.loop after its earlier deadline precheck. g_mock_millis=1100; uint8_t delayed_command[MAX_FRAME_SIZE]={}; assert(usb_serial_interface.checkRecvFrame(delayed_command)==0); assert(!usb_serial_interface.hasReceivedFrame()); serviceUsbTerminal(); assert(the_mesh.terminal); reset(); input.frame(); serviceUsbTerminal(); g_mock_millis=1098; input.advance_per_read=1; assert(usb_serial_interface.checkRecvFrame(delayed_command)==0); assert(millis()==1103 && !usb_serial_interface.hasReceivedFrame()); input.advance_per_read=0; serviceUsbTerminal(); assert(the_mesh.terminal); reset(); input.frame(); serviceUsbTerminal(); g_mock_millis=1099; assert(usb_serial_interface.checkRecvFrame(delayed_command)==2); assert(!usb_binary_startup_probe.isActive()); // The first timely frame establishes ownership before the service poll. g_mock_millis=1100; input.frame(); assert(usb_serial_interface.checkRecvFrame(delayed_command)==2); assert(usb_serial_interface.getCompletedFrameCount()==2); serviceUsbTerminal(); assert(!the_mesh.terminal); // Once explicitly selected, newline-free STOP retains its legacy behavior. reset(); input.push(USB_TERMINAL_STOP_TOKEN); serviceUsbTerminal(); assert(!the_mesh.terminal && !usb_binary_startup_probe.isActive()); g_mock_millis+=1001; input.frame(); assert(usb_serial_interface.checkRecvFrame(delayed_command)==2); // Text-first attach, clean exit, and the remaining CRLF cannot corrupt a // immediately following framed client command. reset(); input.push("ota folder on\r\n"); serviceUsbTerminal(); assert(usb_mota_mode && usb_mota_entry_origin==mesh::UsbMotaEntryOrigin::ASCII); assert(!the_mesh.terminal && the_mesh.attaches==1); input.push("ota folder off\r\n"); input.frame(); serviceUsbTerminal(); assert(!usb_mota_mode && the_mesh.terminal && the_mesh.waiting); assert(the_mesh.detaches==1); input.output.clear(); serviceUsbTerminal(); assert(usb_binary_startup_probe.isActive() && input.available()==5); assert(input.output.empty()); finishProbe(); // Failed ASCII entry stays ASCII; failed Binary entry stays Binary. reset(); the_mesh.attach_succeeds=false; input.push("ota folder on\r\n"); serviceUsbTerminal(); assert(!usb_mota_mode && the_mesh.terminal && the_mesh.banners==2); assert(usb_serial_interface.isPassthroughMode() && !usb_mota_discard_line); reset(); leaveUsbTerminalMode(false); the_mesh.attach_succeeds=false; input.push("ota folder on\r\n"); uint8_t frame[MAX_FRAME_SIZE]={}; assert(usb_serial_interface.checkRecvFrame(frame)==0); serviceUsbTerminal(); assert(!usb_mota_mode && !the_mesh.terminal && !usb_serial_interface.isPassthroughMode()); assert(!usb_mota_discard_line); // Every NUL position rejects the whole ASCII owner-transition/mutating // line, then the next real command is handled normally. for(const char* command : {"ota folder on","ota folder off","set usb.logging on","get stats"}) { for(size_t position=0;position<=strlen(command);++position) { reset(); for(size_t i=0;i{oversized_setter.substr(0,256)}); // The configured pre-begin RX queue preserves the complete unpaced burst. // Production parsing rejects it and its delimiter produces the real prompt. static_assert(hwcdc_rx_capacity>sizeof(usb_terminal_line)); reset(); input.hwcdcBurst(oversized_setter,hwcdc_rx_capacity); serviceUsbTerminal(); assert(!input.rx_dropped && input.available()==0 && the_mesh.commands.empty()); assert(!usb_terminal_discard_line && !usb_terminal_line_len); assert(input.output.find("ERROR: command too long")!=std::string::npos); assert(input.output.substr(input.output.size()-2)=="> "); input.push("get name\r"); serviceUsbTerminal(); assert(the_mesh.commands==std::vector{"get name"}); // Even a single line exceeding the RX queue reaches discard mode before // any CR is lost. Recovery Enter cannot execute its retained setter prefix. // Hosts must still use request/reply rather than overflow a multi-line batch. for(size_t size : {hwcdc_rx_capacity+64,hwcdc_rx_capacity*4}) { reset(); input.hwcdcBurst("set name "+std::string(size,'X')+"\r",hwcdc_rx_capacity); serviceUsbTerminal(); assert(input.rx_dropped && input.available()==0 && the_mesh.commands.empty()); assert(usb_terminal_discard_line && !usb_terminal_line_len); assert(input.output.find("ERROR: command too long")!=std::string::npos); input.push("\rget name\r"); serviceUsbTerminal(); assert(!usb_terminal_discard_line && the_mesh.commands==std::vector{"get name"}); } // Embedded NUL rejection remains whole-line with the real HWCDC burst path. reset(); std::string nul_setter="set name MUST-NOT-CHANGE"; nul_setter.push_back(0); nul_setter+="ignored\r"; input.hwcdcBurst(nul_setter,hwcdc_rx_capacity); serviceUsbTerminal(); assert(!input.rx_dropped && the_mesh.commands.empty() && !usb_terminal_discard_line); assert(input.output.find("ERROR: invalid command")!=std::string::npos); assert(input.output.substr(input.output.size()-2)=="> "); // Every accepted byte, UTF-8 erase, and invalid-line discard preserves the // terminated-length invariant; only complete valid lines prove a reader. reset(); const uint8_t edited[]={'g','e','t',' ','s','t','a','t','s',0xc3,0xa9,0x7f,'\r','\n'}; for(uint8_t byte : edited) { input.bytes.push_back(byte); serviceUsbTerminal(); assert(strlen(usb_terminal_line)==usb_terminal_line_len); } assert(the_mesh.commands==std::vector{"get stats"}); assert(stats_commands==the_mesh.commands); reset(); dedicated=true; input.push("get stats\r\n"); serviceUsbTerminal(); assert(the_mesh.commands==std::vector{"get stats"}); assert(stats_commands.empty()); // Overlong lines never accept their suffix as an independent shutdown. for(unsigned length : {32u,40u,4096u}) { reset(); leaveUsbTerminalMode(false); assert(enterUsbMotaMode(mesh::UsbMotaEntryOrigin::ASCII)); for(unsigned i=0;i(dangerous,dangerous+sizeof(dangerous)-1)); for(size_t prefix=0;prefix desc(38,'x'); const char mutation[]="\nset usb.logging on\n"; std::copy(mutation,mutation+sizeof(mutation)-1,desc.begin()+2); const uint8_t embedded_frame[]={'<',2,0,0x16,3}; std::copy(embedded_frame,embedded_frame+sizeof(embedded_frame),desc.begin()+25); for(bool ascii : {false,true}) { for(bool describe : {false,true}) { const auto delayed=describe?response(2,desc):response(1,{1}); for(size_t prefix=0;prefix