From 9ba79848cadb11ff1834b17991a0c4e8dd468e91 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 25 Sep 2026 17:48:05 -0700 Subject: [PATCH] Restore Companion text terminal after USB mOTA sender exits --- examples/companion_radio/main.cpp | 12 ++- test/test_companion_usb_mota_exit.py | 109 +++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 test/test_companion_usb_mota_exit.py diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 8d9d513a..7bd947f4 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -620,6 +620,8 @@ static mesh::UsbBinaryStartupProbe usb_binary_startup_probe; static mesh::UsbAsciiSessionDefault usb_ascii_session_default; #if COMPANION_FEATURE_USB_MOTA_SOURCE static bool usb_mota_mode = 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_disconnect_armed = false; @@ -779,16 +781,23 @@ static void resetUsbMotaMode() { } static void leaveUsbMotaMode(bool acknowledge) { + const bool restore_ascii = acknowledge + && usb_mota_entry_origin == mesh::UsbMotaEntryOrigin::ASCII; char reply[160] = {0}; the_mesh.handleLocalControlCommand("ota folder off", reply, sizeof(reply)); if (acknowledge) { char transition_reply[224]; snprintf(transition_reply, sizeof(transition_reply), - "\r\n%s\r\nOK - Binary mode\r\n", reply); + "\r\n%s\r\nOK - %s mode\r\n", reply, + restore_ascii ? "Terminal" : "Binary"); queueUsbTerminalControlReply(transition_reply); drainUsbTerminalOutputBeforeProtocolSwitch(); } resetUsbMotaMode(); + // A text-first motatool session must hand CDC0 back to the text terminal. + // Otherwise closing the sender leaves this still-open USB host in Binary + // mode, and its next `ver` or `ota status` appears to get no reply. + if (restore_ascii) enterUsbTerminalMode(false); } static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin) { @@ -796,6 +805,7 @@ static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin) { mesh::discardUsbTerminalOutput(); usb_serial_interface.setPassthroughMode(true); usb_mota_mode = true; + usb_mota_entry_origin = origin; usb_mota_line_len = 0; usb_mota_line[0] = 0; usb_mota_disconnect_armed = isUsbTerminalDataConnected(); diff --git a/test/test_companion_usb_mota_exit.py b/test/test_companion_usb_mota_exit.py new file mode 100644 index 00000000..25a523c6 --- /dev/null +++ b/test/test_companion_usb_mota_exit.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Execute the Companion's real USB mOTA exit functions with both entry modes.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + + +MAIN = (Path(__file__).resolve().parents[1] / "examples/companion_radio/main.cpp").read_text() + + +def function(signature): + start = MAIN.index(signature) + end = MAIN.index("{", start) + 1 + depth = 1 + while depth: + depth += (MAIN[end] == "{") - (MAIN[end] == "}") + end += 1 + return MAIN[start:end] + + +HARNESS = r''' +#include +#include +#include +namespace mesh { +enum class UsbMotaEntryOrigin { BINARY, ASCII }; +static void discardUsbTerminalOutput() {} +} +static bool usb_mota_mode = true, usb_mota_disconnect_armed = true; +static mesh::UsbMotaEntryOrigin usb_mota_entry_origin = mesh::UsbMotaEntryOrigin::BINARY; +static char usb_mota_line[32] = "old"; +static size_t usb_mota_line_len = 3; +static bool ascii = false; +static int drained = 0, folder_off = 0; +static char control_reply[224]; +struct SerialInterface { + bool passthrough = true; + void setPassthroughMode(bool enabled) { passthrough = enabled; } +} usb_serial_interface; +struct Mesh { + bool handleLocalControlCommand(const char* command, char* reply, size_t size) { + assert(strcmp(command, "ota folder off") == 0); + ++folder_off; + snprintf(reply, size, "OK folder off"); + return true; + } +} the_mesh; +static void queueUsbTerminalControlReply(const char* reply) { + snprintf(control_reply, sizeof(control_reply), "%s", reply); +} +static void drainUsbTerminalOutputBeforeProtocolSwitch() { ++drained; } +static void enterUsbTerminalMode(bool show_banner) { + assert(!show_banner); + ascii = true; + usb_serial_interface.setPassthroughMode(true); +} +@FUNCTIONS@ +static void prepare(mesh::UsbMotaEntryOrigin origin) { + usb_mota_mode = usb_mota_disconnect_armed = true; + usb_mota_entry_origin = origin; + usb_mota_line_len = 3; + strcpy(usb_mota_line, "old"); + usb_serial_interface.passthrough = true; + ascii = false; + drained = folder_off = 0; + control_reply[0] = 0; +} +int main() { + prepare(mesh::UsbMotaEntryOrigin::ASCII); + leaveUsbMotaMode(true); + assert(ascii && usb_serial_interface.passthrough); + assert(!usb_mota_mode && !usb_mota_disconnect_armed && usb_mota_line_len == 0); + assert(folder_off == 1 && drained == 1); + assert(strstr(control_reply, "OK - Terminal mode") != nullptr); + + prepare(mesh::UsbMotaEntryOrigin::BINARY); + leaveUsbMotaMode(true); + assert(!ascii && !usb_serial_interface.passthrough); + assert(folder_off == 1 && drained == 1); + assert(strstr(control_reply, "OK - Binary mode") != nullptr); + + prepare(mesh::UsbMotaEntryOrigin::ASCII); + leaveUsbMotaMode(false); // Host reset owns the next protocol decision. + assert(!ascii && !usb_serial_interface.passthrough); + assert(folder_off == 1 && drained == 0 && control_reply[0] == 0); +} +''' + + +class UsbMotaExitTests(unittest.TestCase): + def test_clean_text_sender_restores_text_terminal(self): + source = HARNESS.replace( + "@FUNCTIONS@", + function("static void resetUsbMotaMode()") + "\n" + + function("static void leaveUsbMotaMode(bool acknowledge)"), + ) + with tempfile.TemporaryDirectory() as directory: + cpp = Path(directory) / "test.cpp" + binary = Path(directory) / "test" + cpp.write_text(source) + subprocess.run(["c++", "-std=c++11", str(cpp), "-o", str(binary)], + check=True, capture_output=True) + subprocess.run([str(binary)], check=True, capture_output=True) + + +if __name__ == "__main__": + unittest.main()