"""Exercise real UART session parking, inbox delivery and serial sleep vetoes.""" from pathlib import Path import os import re import subprocess import tempfile import unittest from test_replay_reset_integration import extract_braced from test_esp32_usb_sleep import board_method ROOT = Path(__file__).resolve().parents[1] PRELUDE = r''' #include #include #include #include #include #include #include #include #include #include #include #define COMPANION_FEATURE_USB_MOTA_SOURCE 1 #define COMPANION_FEATURE_NETWORK_TERMINAL 1 #define COMPANION_FEATURE_READER 1 #define MESH_USB_LOGGING_AVAILABLE 0 #define MESH_DEBUG_PRINTLN(...) ((void)0) #define OFFLINE_QUEUE_SIZE 64 @CONSTANTS@ @BUFFER@ struct Uart : BufferStream { bool driver=true; unsigned begins=0,ends=0,flushes=0,fail_begins=0; explicit operator bool() const { return driver; } int availableForWrite() override { return driver ? BufferStream::availableForWrite() : 0; } void begin(unsigned baud) { assert(baud==115200);++begins; driver=fail_begins==0;if(fail_begins)--fail_begins; } void end() { assert(driver);++ends;driver=false;input.clear(); } void flush() override { assert(driver);++flushes; } } Serial; static uint32_t micros() { return millis()*1000; } static bool terminal_output_pending=false; namespace mesh { bool hasPendingUsbTerminalOutput() { return terminal_output_pending; } bool isUsbLoggingEnabled() { return false; } bool hasDedicatedUsbLoggingPort() { return false; } void discardUsbTerminalOutput() { terminal_output_pending=false; } Stream& usbTerminalPort() { return Serial; } static struct { const Stream* output=nullptr; } print_job; static const Stream*& reader_owner=print_job.output; @READER_CANCEL@ struct GroupChannel {}; struct Packet { uint8_t path_len=0; float getSNR() const { return 4; } bool isRouteFlood() const { return false; } }; namespace ota { struct Source { bool pending=false;bool hasPendingResponse() const { return pending; } }; struct OtaContext { static Source& serialFolderSource() { static Source source;return source; } }; } } ArduinoSerialInterface usb_serial_interface,other_interface; MultiSerialInterface interface_manager; BufferStream other_stream; struct MyMesh { @FRAME@ Frame offline_queue[OFFLINE_QUEUE_SIZE]{}; int offline_queue_len=0,offline_queue_head=0; uint8_t out_frame[MAX_FRAME_SIZE]{}; BaseSerialInterface* _serial=&interface_manager; bool terminal=false,network=false,stale_input=true; unsigned ascii_starts=0; bool last_banner=true; BaseSerialInterface* stream_route=nullptr; BaseSerialInterface* pending_route=nullptr; unsigned cancelled_usb=0,cancelled_other=0; bool isTerminalMode() const { return terminal; } void enterTerminalMode(bool banner) { assert(!network);terminal=true;++ascii_starts;last_banner=banner; } void exitTerminalMode() { terminal=false; } void resetUsbHostSessionInput() { stale_input=false; } void cancelSerialResponseStream(BaseSerialInterface* route) { if(stream_route==route)stream_route=nullptr; } void cancelSerialOperationsForRoute(BaseSerialInterface* route) { if(route==&usb_serial_interface)++cancelled_usb;else++cancelled_other; if(pending_route==route)pending_route=nullptr; } int findChannelIdxForPacket(const mesh::GroupChannel&,mesh::Packet*) const { return 0; } int getOfflineQueueCapacity() const; Frame& offlineQueueFrameAt(int); bool addToOfflineQueue(const uint8_t*,int); int peekOfflineQueue(uint8_t*); void popOfflineQueue(); void syncNextOfflineMessage(); void onChannelDataRecv(const mesh::GroupChannel&,mesh::Packet*,uint16_t,const uint8_t*,size_t); mesh::powerdiag::Mask serialVeto() const { using mesh::powerdiag::Reason;using mesh::powerdiag::mask; @SERIAL_GUARD@ return 0; } } the_mesh; @INBOX@ static bool usb_protocol_initialized=true,usb_mota_mode=false; static bool usb_logging_terminal_mode=false,usb_terminal_discard_line=false; static bool usb_terminal_host_reset_completion_pending=false; static char usb_terminal_line[64]{}; static unsigned usb_terminal_line_len=0; static mesh::UsbBinaryStartupProbe usb_binary_startup_probe; static mesh::UsbAsciiSessionDefault usb_ascii_session_default; static bool usb_uart_quiet_parked=false,usb_uart_quiet_restore_enabled=false; static bool isNetworkTerminalActive() { return the_mesh.network; } static void queueUsbTerminalControlReply(const char*) { assert(false); } static void drainUsbTerminalOutputBeforeProtocolSwitch() { assert(false); } static void clearUsbTerminalLine() { memset(usb_terminal_line,0,sizeof(usb_terminal_line));usb_terminal_line_len=0; } @SESSION@ struct Board { bool inhibit_sleep=false,ota=false,radio_test=false; uint32_t uart_quiet_start_at=0,uart_quiet_restore_at=0; uint16_t uart_quiet_seconds=0; bool isOTAUpdateRunning() const { return ota; } bool isRadioTestActive() const { return radio_test; } @BOARD@ }; using Bytes=std::vector; static void select(ArduinoSerialInterface& route,BufferStream& stream) { const uint8_t input[]={'<',1,0,10};uint8_t command[MAX_FRAME_SIZE]{}; stream.push(input,sizeof(input));assert(interface_manager.checkRecvFrame(command)==1); assert(interface_manager.captureReplyRoute()==&route); } static void receive(unsigned sequence) { mesh::GroupChannel channel;mesh::Packet packet; const uint8_t data[]={uint8_t(sequence),0xa5,0x5a,0xff}; the_mesh.onChannelDataRecv(channel,&packet,0xff00,data,sizeof(data)); } static void setup() { resetArduinoMock();g_mock_millis=1000; usb_serial_interface.begin(Serial,"START");usb_serial_interface.enableFlowControl(true); other_interface.begin(other_stream);other_interface.enableFlowControl(true); assert(interface_manager.addInterface(InterfaceType::USB,&usb_serial_interface)); assert(interface_manager.addInterface(InterfaceType::WiFi,&other_interface)); interface_manager.enable();other_interface.disable(); mesh::esp32::registerUartQuietSessionControl(nullptr,transitionCompanionUartQuietSession); } ''' SCENARIOS = r''' int main(int argc,char**) { setup();Board board;char reply[160]{}; using mesh::esp32::UartQuietAction; using mesh::esp32::UartQuietSessionAction; select(usb_serial_interface,Serial); const uint32_t original_count=usb_serial_interface.getCompletedFrameCount(); the_mesh.stream_route=the_mesh.pending_route=&usb_serial_interface; interface_manager.lockReplyRoute(); mesh::reader_owner=&Serial; // Refused readiness cannot partly retire the live host or its producers. usb_protocol_initialized=false; assert(!transitionCompanionUartQuietSession(nullptr,UartQuietSessionAction::Park)); usb_protocol_initialized=true;usb_mota_mode=true; assert(!transitionCompanionUartQuietSession(nullptr,UartQuietSessionAction::Park)); usb_mota_mode=false;mesh::ota::OtaContext::serialFolderSource().pending=true; assert(!transitionCompanionUartQuietSession(nullptr,UartQuietSessionAction::Park)); mesh::ota::OtaContext::serialFolderSource().pending=false; terminal_output_pending=true; assert(!transitionCompanionUartQuietSession(nullptr,UartQuietSessionAction::Park)); terminal_output_pending=false; assert(the_mesh.cancelled_usb==0&&mesh::reader_owner==&Serial&&usb_serial_interface.isEnabled()); // Cancelling an armed request is not a host-session disconnect. assert(board.handleUartQuiet(UartQuietAction::Schedule,5,reply,sizeof(reply))); assert(board.handleUartQuiet(UartQuietAction::Cancel,0,reply,sizeof(reply))); assert(the_mesh.cancelled_usb==0&&usb_serial_interface.isEnabled()&&Serial.ends==0); // A queued ACK must drain before either the session or UART is parked. Serial.write_capacity=0;const uint8_t ack=0; assert(interface_manager.writeFrameToRoute(&usb_serial_interface,&ack,1)==1); assert(board.handleUartQuiet(UartQuietAction::Schedule,5,reply,sizeof(reply))); g_mock_millis+=100;board.servicePowerTestControls(!interface_manager.hasPendingIO()); assert(Serial.ends==0&&the_mesh.cancelled_usb==0&&interface_manager.hasPendingIO()); Serial.write_capacity=4096;interface_manager.loop(); assert(!interface_manager.hasPendingIO()&&Serial.output==Bytes({'>',1,0,0})); board.servicePowerTestControls(!interface_manager.hasPendingIO()); assert(!Serial&&Serial.ends==1&&Serial.flushes==1); if(argc>1) { // Negative control: the old board ended the driver without parking its // still-assumed-connected interface. A real RX queues MSG_WAITING forever. assert(usb_serial_interface.isEnabled());receive(0); assert(the_mesh.offline_queue_len==1&&interface_manager.hasPendingIO()); assert(the_mesh.serialVeto()==mesh::powerdiag::mask(mesh::powerdiag::Reason::Serial)); puts("old UART quiet retained MSG_WAITING serial veto");return 42; } assert(!usb_serial_interface.isEnabled()&&!interface_manager.isConnected()); assert(the_mesh.cancelled_usb==1&&the_mesh.cancelled_other==0); assert(mesh::reader_owner==nullptr); assert(!the_mesh.stream_route&&!the_mesh.pending_route&&!interface_manager.captureReplyRoute()); assert(!the_mesh.stale_input&&!usb_binary_startup_probe.isActive()); assert(!interface_manager.isReplyRouteAvailable(&usb_serial_interface)); // The application's actual RX/inbox method retains every packet while no // transport is listening, with no unserviceable MSG_WAITING frame queued. for(unsigned i=0;i<40;++i) { receive(i);interface_manager.loop(); assert(the_mesh.offline_queue_len==int(i+1)); assert(!interface_manager.hasPendingIO()&&the_mesh.serialVeto()==0); } const Bytes quiet_output=Serial.output; the_mesh.syncNextOfflineMessage();assert(the_mesh.offline_queue_len==40); // Another transport's real queued work keeps the original serial veto. // Nothing about quiet mode grants it an exception or clears its session. other_interface.enable();other_stream.write_capacity=0; the_mesh.pending_route=the_mesh.stream_route=&other_interface; assert(interface_manager.writeFrameToRoute(&other_interface,&ack,1)==1); assert(interface_manager.hasPendingIO()); assert(the_mesh.serialVeto()==mesh::powerdiag::mask(mesh::powerdiag::Reason::Serial)); assert(!transitionCompanionUartQuietSession(nullptr,UartQuietSessionAction::Resume)); assert(!usb_serial_interface.isEnabled()&&the_mesh.pending_route==&other_interface); other_stream.write_capacity=4096;interface_manager.loop(); assert(!interface_manager.hasPendingIO()&&the_mesh.serialVeto()==0); other_interface.disable(); // A failed driver restart retains the parked session and all inbox data; // the timer retries even when manual CPU sleep has been turned off. mesh::esp32::powerTestControls().sleep_enabled=false;Serial.fail_begins=1; g_mock_millis+=5000;board.servicePowerTestControls(); assert(!Serial&&!usb_serial_interface.isEnabled()&&board.uart_quiet_restore_at); assert(the_mesh.offline_queue_len==40&&the_mesh.ascii_starts==0); g_mock_millis+=999;board.servicePowerTestControls();assert(!Serial); ++g_mock_millis;board.servicePowerTestControls(); assert(Serial&&usb_serial_interface.isEnabled()&&!board.uart_quiet_restore_at); assert(the_mesh.ascii_starts==1&&!the_mesh.last_banner&&the_mesh.terminal); assert(usb_serial_interface.getCompletedFrameCount()==original_count); assert(Serial.output==quiet_output&&the_mesh.offline_queue_len==40); assert(the_mesh.pending_route==&other_interface&&the_mesh.stream_route==&other_interface); assert(!other_interface.isEnabled()&&the_mesh.cancelled_other==0); // Fresh Binary ownership can download the original inbox once, in order. leaveUsbTerminalMode(false);select(usb_serial_interface,Serial); for(unsigned i=0;i<40;++i)the_mesh.syncNextOfflineMessage(); assert(the_mesh.offline_queue_len==0); size_t at=quiet_output.size(); for(unsigned i=0;i<40;++i) { assert(Serial.output.at(at)=='>');size_t length=Serial.output.at(at+1)|(size_t(Serial.output.at(at+2))<<8); at+=3;assert(length==13&&Serial.output.at(at)==27&&Serial.output.at(at+9)==i);at+=length; } assert(at==Serial.output.size()); // A pre-disabled interface must stay disabled after the timed recovery. usb_serial_interface.disable();const unsigned starts=the_mesh.ascii_starts; mesh::reader_owner=&other_stream; assert(board.handleUartQuiet(UartQuietAction::Schedule,5,reply,sizeof(reply))); g_mock_millis+=100;board.servicePowerTestControls();assert(!Serial); g_mock_millis+=5000;board.servicePowerTestControls(); assert(Serial&&!usb_serial_interface.isEnabled()&&the_mesh.ascii_starts==starts); assert(mesh::reader_owner==&other_stream); // A live network terminal/Reader retains its owner when USB comes back. usb_serial_interface.enable();the_mesh.network=true; assert(board.handleUartQuiet(UartQuietAction::Schedule,5,reply,sizeof(reply))); g_mock_millis+=100;board.servicePowerTestControls();assert(!Serial); g_mock_millis+=5000;board.servicePowerTestControls(); assert(Serial&&usb_serial_interface.isEnabled()&&the_mesh.ascii_starts==starts); assert(mesh::reader_owner==&other_stream&&the_mesh.network); // Active USB ASCII ownership is retired too. A subsequent global manager // shutdown must not be reversed merely because the UART recovery is due. the_mesh.network=false;serviceUsbAsciiSessionDefault(); assert(the_mesh.terminal);mesh::reader_owner=&Serial; assert(board.handleUartQuiet(UartQuietAction::Schedule,5,reply,sizeof(reply))); g_mock_millis+=100;board.servicePowerTestControls(); assert(!Serial&&!the_mesh.terminal&&!usb_serial_interface.isEnabled()&&mesh::reader_owner==nullptr); interface_manager.disable();g_mock_millis+=5000;board.servicePowerTestControls(); assert(Serial&&!interface_manager.isEnabled()&&!usb_serial_interface.isEnabled()); puts("parked UART retained forty real RX inbox frames without bypassing serial guards"); } ''' def harness(legacy=False): source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() main = (ROOT / "examples/companion_radio/main.cpp").read_text() header = (ROOT / "examples/companion_radio/MyMesh.h").read_text() native = (ROOT / "test/test_serial_mode_switch/test_serial_mode_switch.cpp").read_text() reader = (ROOT / "src/helpers/CompanionReader.cpp").read_text() constants = "\n".join(re.findall( r"^#define (?:RESP_CODE_(?:CHANNEL_MSG_RECV|CHANNEL_MSG_RECV_V3|CHANNEL_DATA_RECV|NO_MORE_MESSAGES)|PUSH_CODE_MSG_WAITING|MAX_CHANNEL_DATA_LENGTH)\s+.+$", source, re.MULTILINE)) inbox = "\n".join(extract_braced(source, signature) for signature in ( "bool MyMesh::Frame::isChannelMsg() const", "int MyMesh::getOfflineQueueCapacity() const", "MyMesh::Frame& MyMesh::offlineQueueFrameAt(", "bool MyMesh::addToOfflineQueue(", "int MyMesh::peekOfflineQueue(", "void MyMesh::popOfflineQueue(", "void MyMesh::syncNextOfflineMessage(", "void MyMesh::onChannelDataRecv(")) session = "\n".join(extract_braced(main, signature) for signature in ( "static void cancelUsbSerialOperations()", "static void enterUsbTerminalMode(", "static void leaveUsbTerminalMode(", "static void serviceUsbAsciiSessionDefault()", "static void beginUsbDefaultSession()", "static bool transitionCompanionUartQuietSession(")) board = "\n".join(board_method(signature) for signature in ( "bool handleUartQuiet(", "void servicePowerTestControls(")) if legacy: park = " if (!mesh::esp32::transitionUartQuietSession(\n mesh::esp32::UartQuietSessionAction::Park)) return;\n" assert board.count(park) == 1 board = board.replace(park, "", 1) guard = re.search(r" if \(_serial != NULL && _serial->hasPendingIO\(\)\) return mask\(Reason::Serial\);", source) assert guard is not None result = PRELUDE for marker, value in {"@CONSTANTS@": constants, "@BUFFER@": extract_braced(native, "class BufferStream") + ";", "@FRAME@": extract_braced(header, "struct Frame {") + ";", "@INBOX@": inbox, "@SERIAL_GUARD@": guard.group(), "@SESSION@": session, "@BOARD@": board, "@READER_CANCEL@": extract_braced(reader, "void cancelReaderCommand(")}.items(): result = result.replace(marker, value) return result + SCENARIOS class UartQuietSessionTests(unittest.TestCase): def test_actual_rx_queue_and_transport_lifecycle_with_old_negative_control(self): with tempfile.TemporaryDirectory(prefix="mesh-uart-quiet-session-") as directory: folder = Path(directory) for legacy in (True, False): with self.subTest(legacy=legacy): cpp, binary = folder / "test.cpp", folder / "test" cpp.write_text(harness(legacy), encoding="ascii") built = subprocess.run([os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-Wno-unused-function", "-Wno-sign-compare", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", "-DESP32_PLATFORM=1", "-DARDUINO_USB_CDC_ON_BOOT=0", "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), str(cpp), str(ROOT / "src/helpers/ArduinoSerialInterface.cpp"), "-o", str(binary)], text=True, capture_output=True, timeout=60) self.assertEqual(built.returncode, 0, built.stderr) ran = subprocess.run([str(binary)] + (["legacy"] if legacy else []), text=True, capture_output=True, timeout=20) self.assertEqual(ran.returncode, 42 if legacy else 0, ran.stdout + ran.stderr) self.assertIn("MSG_WAITING serial veto" if legacy else "forty real RX inbox frames", ran.stdout) def test_hook_is_registered_only_for_default_esp32_uart(self): source = (ROOT / "examples/companion_radio/main.cpp").read_text() pattern = r"#if defined\(ESP32_PLATFORM\) \\\n && \(!defined\(ARDUINO_USB_CDC_ON_BOOT\) \|\| !ARDUINO_USB_CDC_ON_BOOT\) \\\n && !defined\(SERIAL_RX\) && !defined\(SERIAL_TX\)\n" self.assertEqual(len(re.findall(pattern, source)), 2) start = source.index(" mesh::esp32::registerUartQuietSessionControl(") prefix = source[:start] block = prefix[prefix.rfind("#if defined(ESP32_PLATFORM)"):] + source[start:source.index("#endif", start) + 6] for defines, expected in ((["ESP32_PLATFORM=1"], True), (["NRF52_PLATFORM=1"], False), (["ESP32_PLATFORM=1", "ARDUINO_USB_CDC_ON_BOOT=1"], False), (["ESP32_PLATFORM=1", "SERIAL_RX=1"], False), (["ESP32_PLATFORM=1", "SERIAL_TX=1"], False)): with self.subTest(defines=defines): result = subprocess.run([os.environ.get("CXX", "c++"), "-E", "-P", "-x", "c++", *["-D" + value for value in defines], "-"], input=block, text=True, capture_output=True, timeout=20) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual("registerUartQuietSessionControl" in result.stdout, expected) if __name__ == "__main__": unittest.main(verbosity=2)