Preserve USB mOTA ownership across deadlines and logging handoffs

This commit is contained in:
mikecarper committed 2026-10-03 19:01:22 -07:00
1 parent ecdaacdd58
commit 4837265648
15 files changed
+1737 -57

No files matched your search

+15 -4
View File
@@ -82,8 +82,9 @@ switched; they remain binary.
1. Companion initializes the normal USB Binary Companion interface, then
gives its primary stream to the ASCII terminal before normal loop service
begins.
2. While the prompt has no buffered input, the terminal peeks at the next byte.
It does not remove that byte.
2. While the prompt has no buffered input, the terminal skips blank CR/LF
delimiters and peeks at the next byte without removing it. A queued binary
frame after a blank line is detected before any ASCII banner is emitted.
3. If the byte is `<`, the terminal temporarily releases the stream and enables
the existing `ArduinoSerialInterface` frame parser.
4. The parser consumes the original `<`, the two-byte length, and the payload.
@@ -94,11 +95,21 @@ switched; they remain binary.
6. If no complete frame arrives within one second, the parser state is reset
and the ASCII terminal prints a new banner and prompt.
The switcher checks framing, not client identity. Any syntactically complete
Binary Companion frame confirms binary mode; it does not require the first
The switcher checks framing, not client identity. A complete Binary Companion
frame with 1-176 payload bytes confirms binary mode; it does not require the first
command to be `CMD_APP_START` or `CMD_DEVICE_QUERY`. Normal command validation
still occurs after the frame parser returns the payload.
Oversized frames are consumed and discarded, never truncated into executable
commands or counted as proof of a Binary client. ASCII, framed Binary, and mOTA
input handlers process at most the initial queued-byte count, so a continuously
sending host cannot keep the radio loop trapped in one input call.
ASCII and mOTA reject an entire line containing an embedded NUL or exceeding its
line buffer, discarding through the next CR/LF. A valid-looking suffix is not
executed. Binary payloads may still contain NUL bytes. The immediate manual
terminal-stop token remains an exception to ordinary line-oriented handling.
The empty-prompt requirement prevents a literal `<` in the middle of a command
from silently changing modes. A literal `<` typed as the first character does
start a probe, but the terminal returns after the one-second timeout if no
+29
View File
@@ -831,6 +831,35 @@ CDC/UART receive rings even though the host successfully wrote the complete
reply. Chunking is internal to the transport and does not change `OP_READ` or
the `MotaSource` random-access contract.
Each response uses one deadline, starting after request transmission, for its
magic, header, payload and checksum together. The deadline does not restart
for each received byte. USB folder sessions use 600 ms; BLE/TCP folder sessions
use 3000 ms. Each chunk of a larger logical read has its own deadline. A slow
trickle can therefore fail sooner than before; a maximum 197-byte response
takes about 17.1 ms on a 115200-baud UART, excluding host processing latency.
A shared USB text/control session explicitly opts into retaining a timed-out
response's sync/header/payload/checksum position. No subsequent USB request is
sent until that old response reaches its known checksum boundary, or an actual
host-session reset clears it. The request supplies the
payload length: the response itself carries neither a length nor a request ID.
Late binary bytes therefore cannot become USB `ota folder off` control text.
A complete reply followed by that ordinary shutdown line still works. If a
software handoff or failed attach leaves a response outstanding, USB input is
quarantined until its boundary; commands typed during that wait are discarded.
Leaving a log-only USB/network handoff preserves that response for the tracked
consumer rather than discarding its tail as raw input. A deliberate USB mOTA
entry cancels startup probe and deferred ASCII timers so they cannot reclaim
the stream or emit terminal text into an active transfer.
If the sender abandons a partial reply, reconnect the USB host session to
recover. Bare shutdown text inside a partial payload is inherently ambiguous
and is not interpreted as control. This assumes one ordered reply per request;
unsolicited duplicate or reordered binary replies cannot be unambiguously
separated by this legacy wire format.
Dedicated UART, BLE and TCP sources retain their legacy retry/stale-drain
behavior; their absolute response deadline still applies to every request.
`OP_DEFLATE_BLOCK` lets a host-folder source perform the optional transport compression without linking an
encoder into the embedded seeder. `len=0, off=0` queries the exact encoded length; subsequent chunks are at
most 190 bytes, keeping the total response payload at 192 bytes. The host independently encodes each manifest
+130 -33
View File
@@ -9,6 +9,10 @@
#include "CompanionBluetooth.h"
#include "CompanionWireless.h"
#include "CompanionWiFi.h"
#if COMPANION_FEATURE_USB_MOTA_SOURCE || COMPANION_FEATURE_BLE_MOTA_SOURCE
#include <helpers/ota/MotaSourceSerial.h>
#include <helpers/ota/OtaContext.h>
#endif
#if defined(COMPANION_PAIRING_UI_HIL)
void companionPairingUiHilProbe();
#endif
@@ -321,9 +325,6 @@ void serviceWioE5HeadlessControls() {
#endif
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
#include <helpers/ota/MotaSourceSerial.h>
#include <helpers/ota/OtaContext.h>
class Nrf52BleMotaSourceControl : public mesh::companion::MotaSourceControl {
public:
Nrf52BleMotaSourceControl()
@@ -352,6 +353,7 @@ public:
_packets_sent_at_start = context.manager.packetsSent();
_last_packets_sent = 0;
bluetooth_interface.setMotaStreamActive(true);
_source.resetSessionState(); // setActive(true) cleared the old ring generation.
if (!context.attach_folder_source(
&_source, mesh::ota::OtaContext::FOLDER_LINK_BLE, "ble",
reply, reply_size)) {
@@ -636,12 +638,22 @@ static bool usb_logging_reply_state = false;
#endif
static mesh::UsbBinaryStartupProbe usb_binary_startup_probe;
static mesh::UsbAsciiSessionDefault usb_ascii_session_default;
static bool acceptUsbBinaryStartupFrame(uint32_t completed_at) {
// Radio/mesh work and the parser itself can cross the deadline after the
// main-loop precheck. Reject before command dispatch or Binary-client proof.
if (usb_binary_startup_probe.hasTimedOut(completed_at)) return false;
// This first valid frame establishes Binary ownership immediately, not at
// the later terminal-service poll. Later frames have no startup deadline.
usb_binary_startup_probe.cancel();
return true;
}
#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_discard_line = false;
static bool usb_mota_disconnect_armed = false;
#endif
@@ -801,8 +813,12 @@ static void resetUsbMotaMode() {
usb_mota_mode = false;
usb_mota_line_len = 0;
usb_mota_line[0] = 0;
usb_mota_discard_line = false;
usb_mota_disconnect_armed = false;
usb_serial_interface.setPassthroughMode(false);
// A software owner change is not a transport reset. Retain any late binary
// reply and quarantine input until its known boundary is consumed.
usb_serial_interface.setPassthroughMode(
mesh::ota::OtaContext::serialFolderSource().hasPendingResponse());
}
static void leaveUsbMotaMode(bool acknowledge) {
@@ -826,13 +842,27 @@ static void leaveUsbMotaMode(bool acknowledge) {
}
static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin) {
// An explicit seeder command selects its own protocol owner, even if it
// arrived after a rejected startup frame. Neither a stale Binary deadline
// nor deferred default-ASCII restoration may reclaim a live mOTA stream.
usb_binary_startup_probe.cancel();
usb_ascii_session_default.cancel();
auto& source = mesh::ota::OtaContext::serialFolderSource();
if (source.hasPendingResponse()) {
queueUsbTerminalControlReply("\r\nERR waiting for previous mOTA response\r\n");
if (mesh::shouldRestoreAsciiAfterMotaFailure(origin)) enterUsbTerminalMode();
return false;
}
cancelUsbSerialOperations();
mesh::discardUsbTerminalOutput();
source.enableSharedTextControl();
source.resetSessionState();
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_discard_line = false;
usb_mota_disconnect_armed = isUsbTerminalDataConnected();
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
@@ -876,14 +906,20 @@ static void serviceUsbMota() {
}
#endif
// SerialMotaSource consumes framed responses synchronously while serving a
// block. Bytes left here are host control text, notably motatool's automatic
// `ota folder off` on a clean shutdown.
Stream& usb_input = mesh::usbCompanionPort();
while (usb_input.available()) {
int value = usb_input.read();
// A failed transaction can leave a late binary response, not just host text.
// Retain its known body boundary before recognizing clean shutdown controls.
// The static source accessor never allocates/acquires an OTA context here.
auto& source = mesh::ota::OtaContext::serialFolderSource();
int pending = source.availableControlBytes();
while (pending-- > 0) {
int value = source.readControlByte();
if (value == mesh::ota::SerialMotaSource::RESPONSE_BYTE) continue;
if (value < 0) break;
char c = (char)value;
if (usb_mota_discard_line) {
if (c == '\r' || c == '\n') usb_mota_discard_line = false;
continue;
}
if (c == '\r' || c == '\n') {
if (usb_mota_line_len == 0) continue;
usb_mota_line[usb_mota_line_len] = 0;
@@ -896,15 +932,41 @@ static void serviceUsbMota() {
}
continue;
}
if (usb_mota_line_len < sizeof(usb_mota_line) - 1) {
if (c != 0 && usb_mota_line_len < sizeof(usb_mota_line) - 1) {
usb_mota_line[usb_mota_line_len++] = c;
usb_mota_line[usb_mota_line_len] = 0;
} else {
usb_mota_line_len = 0;
usb_mota_line[0] = 0;
usb_mota_discard_line = true;
}
}
}
static bool serviceUsbMotaResponseBoundary() {
if (usb_mota_mode) return false;
auto& source = mesh::ota::OtaContext::serialFolderSource();
if (!source.hasPendingResponse()) return false;
// Failed attach or software handoff must not turn late binary data into
// terminal commands or Companion frames. Input typed while waiting is
// discarded; only an actual response boundary or host reset ends the wait.
if (!usb_serial_interface.isPassthroughMode()) {
usb_serial_interface.setPassthroughMode(true);
}
int pending = source.availableControlBytes();
while (pending-- > 0) {
if (source.readControlByte() == -1) break;
if (!source.hasPendingResponse()) {
source.resetSessionState();
usb_serial_interface.setPassthroughMode(
the_mesh.isTerminalMode() || usb_logging_network_parked
|| (!mesh::hasDedicatedUsbLoggingPort()
&& mesh::isUsbLoggingEnabled()));
break;
}
}
return true;
}
#endif
// Consume an exact TinyUSB owner-task edge, or HWCDC's framework-debounced SOF
@@ -923,6 +985,7 @@ static void resetUsbTerminalHostSession() {
if (usb_mota_mode) {
leaveUsbMotaMode(false);
}
mesh::ota::OtaContext::serialFolderSource().resetSessionState();
#endif
if (the_mesh.isTerminalMode()) leaveUsbTerminalMode(false);
mesh::discardUsbTerminalOutput();
@@ -1034,6 +1097,7 @@ static void serviceUsbTerminal() {
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());
if (usb_logging_network_parked) return;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (serviceUsbMotaResponseBoundary()) return;
if (usb_mota_mode) {
serviceUsbMota();
return;
@@ -1059,24 +1123,31 @@ static void serviceUsbTerminal() {
return;
}
// Companion boots as a useful ASCII terminal. MeshCLI's first framed
// command begins with '<'; hand that byte over untouched at an empty prompt.
// A malformed or accidental probe times out and restores the terminal.
if (!usb_logging_terminal_mode
&& usb_binary_startup_probe.shouldStart(
usb_terminal_line_len == 0, usb_terminal_discard_line,
mesh::usbCompanionPort().peek())) {
const uint32_t frame_count = usb_serial_interface.getCompletedFrameCount();
leaveUsbTerminalMode(false);
usb_binary_startup_probe.start(millis(), frame_count);
return;
}
Stream& usb_input = mesh::usbCompanionPort();
if (the_mesh.isTerminalWaitingForInput() && usb_input.available() > 0) {
enterUsbTerminalMode();
}
while (usb_input.available()) {
bool blank_input = false;
int pending = usb_input.available();
while (pending-- > 0) {
// A leftover CR/LF (for example after mOTA shutdown) must not hide a
// following frame or cause a banner to poison its first binary reply.
if (usb_terminal_line_len == 0 && !usb_terminal_discard_line) {
const int next_byte = usb_input.peek();
if (next_byte == '\r' || next_byte == '\n') {
usb_input.read();
blank_input = true;
continue;
}
// Re-check at each empty prompt, before consuming the '<' byte. A
// malformed or accidental probe still returns to ASCII after one second.
if (!usb_logging_terminal_mode
&& usb_binary_startup_probe.shouldStart(true, false, next_byte)) {
const uint32_t frame_count = usb_serial_interface.getCompletedFrameCount();
leaveUsbTerminalMode(false);
usb_binary_startup_probe.start(millis(), frame_count);
return;
}
}
if (the_mesh.isTerminalWaitingForInput()) enterUsbTerminalMode();
int value = usb_input.read();
if (value < 0) break;
char c = (char)value;
@@ -1089,6 +1160,15 @@ static void serviceUsbTerminal() {
continue;
}
if (c == 0) {
// The handlers consume C strings. Reject the whole malformed line, not
// an apparently valid mutating command before an embedded NUL.
clearUsbTerminalLine();
usb_terminal_discard_line = true;
usbTerminalOutput().print("\r\n ERROR: invalid command\r\n");
continue;
}
if (c == '\b' || c == 0x7F) {
if (usb_terminal_line_len > 0) {
usb_terminal_line_len = mesh::cli::eraseLastTerminalInput(
@@ -1122,9 +1202,9 @@ static void serviceUsbTerminal() {
return;
}
#endif
// On dual CDC hardware only CDC1 is the logging-reader endpoint.
if (!mesh::hasDedicatedUsbLoggingPort()
&& strlen(usb_terminal_line) == usb_terminal_line_len)
// NUL-containing lines were discarded before reaching this point. On
// dual CDC hardware only CDC1 is the logging-reader endpoint.
if (!mesh::hasDedicatedUsbLoggingPort())
mesh::noteUsbLoggingStatsCommand(usb_terminal_line);
the_mesh.handleTerminalCommand(usb_terminal_line);
clearUsbTerminalLine();
@@ -1166,6 +1246,9 @@ static void serviceUsbTerminal() {
return;
}
}
// Enter alone should still reveal the otherwise silent ASCII session.
if (blank_input && usb_input.available() == 0
&& the_mesh.isTerminalWaitingForInput()) enterUsbTerminalMode();
}
static void serviceUsbLoggingOwnership(bool logging_enabled) {
@@ -1197,11 +1280,21 @@ static void serviceUsbLoggingOwnership(bool logging_enabled) {
&& logging_action != mesh::UsbLoggingTerminalAction::PARK_USB_FOR_NETWORK) {
// Bytes typed while USB was log-only belong to neither the new ASCII CLI
// nor a new Binary client. Bound the discard to the existing RX snapshot.
#if COMPANION_FEATURE_USB_MOTA_SOURCE
const bool response_pending =
mesh::ota::OtaContext::serialFolderSource().hasPendingResponse();
#else
const bool response_pending = false;
#endif
// A timed-out mOTA transaction still owns its exact response boundary.
// Raw reads would consume its tail without advancing that tracker, leaving
// the stream quarantined forever. Let its bounded consumer drain instead.
Stream& input = mesh::usbCompanionPort();
int pending = input.available();
int pending = response_pending ? 0 : input.available();
while (pending-- > 0) input.read();
usb_logging_network_parked = false;
usb_serial_interface.setPassthroughMode(the_mesh.isTerminalMode());
usb_serial_interface.setPassthroughMode(
the_mesh.isTerminalMode() || response_pending);
}
switch (logging_action) {
case mesh::UsbLoggingTerminalAction::PARK_USB_FOR_NETWORK:
@@ -3347,6 +3440,7 @@ void setup() {
#endif
// keep frames intact and pace the contact stream when the host is slow
usb_serial_interface.enableFlowControl(true);
usb_serial_interface.setReceiveFrameCheck(acceptUsbBinaryStartupFrame);
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
// The ESP32 USB-Serial-JTAG peripheral (HWCDC) has no DTR concept at all.
@@ -3485,6 +3579,9 @@ void loop() {
#endif
#if defined(ENABLE_USB_INTERFACE)
expireUsbBinaryStartupProbeBeforeDispatch();
#if COMPANION_FEATURE_USB_MOTA_SOURCE
serviceUsbMotaResponseBoundary();
#endif
#endif
the_mesh.loop();
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
+1
View File
@@ -115,6 +115,7 @@ void WiFiOtaSeeder::loop() {
}
seeder_client = incoming;
seeder_source.resetSessionState(); // The old socket cannot supply this session's reply.
if (!context.attach_folder_source(&seeder_source, OtaContext::FOLDER_LINK_TCP,
"tcp", attach_reply, sizeof(attach_reply))) {
seeder_client.stop();
+124 -13
View File
@@ -1,23 +1,122 @@
#include "MotaSourceSerial.h"
#include "MotaSeederProto.h"
#include "OtaByteIO.h"
#include <limits.h>
#include <string.h>
namespace mesh {
namespace ota {
bool SerialMotaSource::readByteT(uint8_t& b) {
uint32_t t0 = millis();
while ((millis() - t0) < _to) {
int c = _io.read();
if (c >= 0) { b = (uint8_t)c; return true; }
void SerialMotaSource::resetSessionState() {
_response_pending = false;
_response_stage = SYNC_FIRST;
_response_payload_len = _response_remaining = 0;
_control_offset = _control_len = 0;
_control_discard_line = false;
}
void SerialMotaSource::queueControlByte(uint8_t b) {
const bool newline = b == '\r' || b == '\n';
if (_control_discard_line) {
if (!newline) return;
_control_discard_line = false;
}
if (_control_len == sizeof(_control_queue)) {
// Invalidate any prefix already handed to the ASCII owner, then discard
// through the real line ending. Never expose an overlong line's suffix.
_control_offset = 0;
_control_len = 1;
_control_queue[0] = 0;
_control_discard_line = !newline;
if (!newline) return;
}
_control_queue[(_control_offset + _control_len) % sizeof(_control_queue)] = b;
++_control_len;
}
int SerialMotaSource::takeControlByte() {
if (_control_len == 0) return -1;
const uint8_t value = _control_queue[_control_offset];
_control_offset = (_control_offset + 1) % sizeof(_control_queue);
--_control_len;
return value;
}
int SerialMotaSource::readTrackedByte() {
const int value = _io.read();
if (value < 0) return value;
if (!_shared_text_control) return value;
const uint8_t b = (uint8_t)value;
if (!_response_pending) {
queueControlByte(b);
return value;
}
switch (_response_stage) {
case SYNC_FIRST:
if (b == MOTA_SEEDER_RSP_MAGIC0) _response_stage = SYNC_SECOND;
else queueControlByte(b);
break;
case SYNC_SECOND:
if (b == MOTA_SEEDER_RSP_MAGIC1) {
_response_stage = RESPONSE_OP;
} else {
queueControlByte(MOTA_SEEDER_RSP_MAGIC0);
if (b != MOTA_SEEDER_RSP_MAGIC0) {
queueControlByte(b);
_response_stage = SYNC_FIRST;
}
}
break;
case RESPONSE_OP:
_response_stage = RESPONSE_STATUS;
break;
case RESPONSE_STATUS:
_response_remaining = b == MS_STATUS_OK ? _response_payload_len : 0;
_response_stage = _response_remaining ? RESPONSE_PAYLOAD : RESPONSE_CHECKSUM;
break;
case RESPONSE_PAYLOAD:
if (--_response_remaining == 0) _response_stage = RESPONSE_CHECKSUM;
break;
case RESPONSE_CHECKSUM:
// The request determines payload length, even when checksum/op fails.
// Only this full wire boundary, or an explicit owner reset, permits a
// new request; a late body tail can never become ASCII control text.
_response_pending = false;
_response_stage = SYNC_FIRST;
break;
}
return value;
}
int SerialMotaSource::availableControlBytes() {
const int available = _io.available();
if (available <= 0) return _control_len;
return available > INT_MAX - _control_len ? INT_MAX : available + _control_len;
}
int SerialMotaSource::readControlByte() {
if (!_shared_text_control) return _io.read();
if (_control_len != 0) return takeControlByte();
if (readTrackedByte() < 0) return -1;
return _control_len ? takeControlByte() : RESPONSE_BYTE;
}
bool SerialMotaSource::readByteT(uint8_t& b, uint32_t started) {
while ((uint32_t)(millis() - started) < _to) {
int c = readTrackedByte();
if (c >= 0) {
// A read or scheduler tick can cross the deadline after the precheck.
if ((uint32_t)(millis() - started) >= _to) return false;
b = (uint8_t)c;
return true;
}
delay(1); // let BLE/WiFi callbacks deliver the response
}
return false;
}
bool SerialMotaSource::readExact(uint8_t* b, uint32_t n) {
for (uint32_t i = 0; i < n; i++) if (!readByteT(b[i])) return false;
bool SerialMotaSource::readExact(uint8_t* b, uint32_t n, uint32_t started) {
for (uint32_t i = 0; i < n; i++) if (!readByteT(b[i], started)) return false;
return true;
}
@@ -25,7 +124,13 @@ bool SerialMotaSource::readExact(uint8_t* b, uint32_t n) {
// checksum, then scans for the response magic and validates op+status+checksum before delivering payload.
bool SerialMotaSource::txn(uint8_t op, const uint8_t* args, uint8_t arglen,
uint8_t* payload, uint32_t payload_len) {
while (_io.read() >= 0) {} // drop any stale/partial bytes before a fresh request
// Drop the currently queued stale/partial input only. A continuously writing
// host cannot keep the primary mesh loop trapped before the request.
int pending = _io.available();
while (pending-- > 0) if (readTrackedByte() < 0) break;
// Shared text ownership must never overwrite an incomplete old response's
// body length. The wire has no request ID or response payload length.
if (_response_pending) return false;
if (arglen > 7) return false; // largest source request is READ(idx,off,len)
uint8_t xs = op;
for (uint8_t i = 0; i < arglen; i++) xs ^= args[i];
@@ -37,13 +142,19 @@ bool SerialMotaSource::txn(uint8_t op, const uint8_t* args, uint8_t arglen,
if (arglen) { memcpy(frame + frame_len, args, arglen); frame_len += arglen; }
frame[frame_len++] = xs;
if (_io.write(frame, frame_len) != frame_len) return false;
if (_shared_text_control) {
_response_pending = true;
_response_stage = SYNC_FIRST;
_response_payload_len = payload_len;
_response_remaining = 0;
}
if (_write_policy == MotaStreamWritePolicy::FlushTransmit) _io.flush();
// scan for response magic 'm' 's' (tolerate leading noise)
uint32_t t0 = millis(); bool got = false;
const uint32_t t0 = millis(); bool got = false;
uint8_t prev = 0;
while ((millis() - t0) < _to) {
int c = _io.read();
int c = readTrackedByte();
if (c < 0) { delay(1); continue; }
if (prev == MOTA_SEEDER_RSP_MAGIC0 && (uint8_t)c == MOTA_SEEDER_RSP_MAGIC1) { got = true; break; }
prev = (uint8_t)c;
@@ -51,16 +162,16 @@ bool SerialMotaSource::txn(uint8_t op, const uint8_t* args, uint8_t arglen,
if (!got) return false;
uint8_t hdr[2];
if (!readExact(hdr, 2)) return false; // op, status
if (!readExact(hdr, 2, t0)) return false; // op, status
if (hdr[0] != op) return false;
uint8_t rxs = (uint8_t)(MOTA_SEEDER_RSP_MAGIC0 ^ MOTA_SEEDER_RSP_MAGIC1) ^ hdr[0] ^ hdr[1];
bool ok = (hdr[1] == MS_STATUS_OK);
if (ok && payload_len) {
if (!readExact(payload, payload_len)) return false;
if (!readExact(payload, payload_len, t0)) return false;
for (uint32_t i = 0; i < payload_len; i++) rxs ^= payload[i];
}
uint8_t xsum;
if (!readByteT(xsum)) return false;
if (!readByteT(xsum, t0)) return false;
if (xsum != rxs) return false; // corrupt frame -> caller retries
return ok;
}
+34 -4
View File
@@ -7,8 +7,10 @@
// A MotaSource backed by a host "mota-seeder" daemon over a dedicated framed Stream, such as a spare
// UART, USB CDC, or TCP client. The device pulls catalog + bytes on demand (MotaSeederProto.h); the folder
// image is never held on the device - it streams through. Do not share the Stream with a text CLI because
// command/log text would collide with the binary framing. Reads block up to `timeout_ms`; keep the host
// endpoint responsive so a round-trip does not stall the primary transfer.
// command/log text would collide with the binary framing. Each request's entire
// response (sync, header, payload, checksum) has one `timeout_ms` deadline after
// request transmission. Larger reads use multiple bounded requests. Keep the
// host responsive so a round-trip does not stall the primary transfer.
namespace mesh {
namespace ota {
@@ -25,12 +27,40 @@ public:
bool read_deflated_block(uint8_t idx, uint16_t block, uint8_t* buf,
uint16_t cap, uint16_t* len) override;
// Only shared USB text/control ownership opts into retained boundaries.
// Dedicated UART/BLE/TCP keep their legacy retry and stale-drain behavior.
void enableSharedTextControl() { _shared_text_control = true; }
// A shared USB owner consumes only out-of-response control bytes. One call
// reads at most one underlying byte; RESPONSE_BYTE means binary was consumed.
static constexpr int RESPONSE_BYTE = -2;
int availableControlBytes();
int readControlByte();
bool hasPendingResponse() const { return _response_pending; }
void resetSessionState();
private:
// Send a request (op+args) and read its response header; on OK, `payload` (if non-null) receives
// `payload_len` bytes. Returns true iff a well-formed OK response for `op` arrived in time.
bool txn(uint8_t op, const uint8_t* args, uint8_t arglen, uint8_t* payload, uint32_t payload_len);
bool readByteT(uint8_t& b); // one byte within the timeout
bool readExact(uint8_t* b, uint32_t n);
bool readByteT(uint8_t& b, uint32_t started); // within the shared response deadline
bool readExact(uint8_t* b, uint32_t n, uint32_t started);
int readTrackedByte();
void queueControlByte(uint8_t b);
int takeControlByte();
enum ResponseStage : uint8_t {
SYNC_FIRST, SYNC_SECOND, RESPONSE_OP, RESPONSE_STATUS,
RESPONSE_PAYLOAD, RESPONSE_CHECKSUM,
};
bool _shared_text_control = false;
bool _response_pending = false;
ResponseStage _response_stage = SYNC_FIRST;
uint32_t _response_payload_len = 0;
uint32_t _response_remaining = 0;
uint8_t _control_queue[64] = {};
uint8_t _control_offset = 0;
uint8_t _control_len = 0;
bool _control_discard_line = false;
Stream& _io;
uint32_t _to;
+5 -2
View File
@@ -502,13 +502,16 @@ struct OtaContext {
// Attach/detach an external folder of `.mota` served by a host daemon over the seeder UART (the node
// then advertises + relays them alongside its own fw). Only built when OTA_FOLDER_SERIAL is configured.
#if defined(OTA_FOLDER_SERIAL)
bool attach_folder(char* msg, size_t cap) {
static SerialMotaSource& serialFolderSource() {
static SerialMotaSource src(OTA_FOLDER_SERIAL_STREAM,
OTA_FOLDER_SERIAL_WRITE_POLICY, 600);
return src;
}
bool attach_folder(char* msg, size_t cap) {
#ifdef OTA_FOLDER_SERIAL_BEGIN
OTA_FOLDER_SERIAL_STREAM.begin(OTA_FOLDER_SERIAL_BAUD); // dedicated UART; console is already up
#endif
return attach_folder_source(&src, FOLDER_LINK_SERIAL, "serial", msg, cap);
return attach_folder_source(&serialFolderSource(), FOLDER_LINK_SERIAL, "serial", msg, cap);
}
#endif
void detach_folder(bool preserve_capture = false) {
@@ -85,7 +85,12 @@ Log& usbLoggingPort() { static Log log; return log; }
namespace ota {
class SerialMotaSource : public ::Folder {
public:
inline static unsigned session_resets = 0;
SerialMotaSource(int, MotaStreamWritePolicy, uint32_t) {}
void resetSessionState() {
assert(::bluetooth_interface.active);
++session_resets;
}
};
} }
#include "ble_control_under_test.h"
@@ -225,10 +230,12 @@ int main() {
#else
Nrf52BleMotaSourceControl ble;
assert(!ble.start(reply, sizeof reply)); // no subscription, no storage loan
assert(SerialMotaSource::session_resets == 0);
assert(!ota_context_if_active() && q.buffer.capacity() == 256);
for (int disconnect = 0; disconnect < 2; ++disconnect) {
bluetooth_interface.ready = true;
assert(ble.start(reply, sizeof reply));
assert(SerialMotaSource::session_resets == static_cast<unsigned>(disconnect + 1));
assert(ble.status().attached && q.buffer.capacity() == 128);
assert(ble.status().offered == 1 && ble.status().advertised == 1);
if (disconnect) {
@@ -255,6 +262,7 @@ int main() {
"USB", reply, sizeof reply));
bluetooth_interface.ready = true;
assert(!ble.start(reply, sizeof reply));
assert(SerialMotaSource::session_resets == 2);
assert(ble.status().another_link_active);
assert(ble.stop(reply, sizeof reply));
ota_release_context_if_idle(false);
@@ -115,6 +115,36 @@ static void test_wifi_shared_queue(Queue& q, CheckMessages check_messages) {
packets.clear();
}
// TCP keeps legacy retries on the same connection. A genuinely new socket
// starts its next catalog handshake without old transport state.
WiFi.connected = true;
auto stalled = connect_host();
WiFiOtaSeeder::loop();
assert(WiFiOtaSeeder::isAttached() && ota_ctx().manager.servedCount() == 1);
stalled->respond = [](const uint8_t* request, size_t) {
assert(request[2] == MS_OP_COUNT);
return std::vector<uint8_t>{'m', 's', MS_OP_COUNT, MS_STATUS_OK};
};
const unsigned before_timeout = stalled->requests;
ota_ctx().manager.refresh_sources();
assert(stalled->requests == before_timeout + 1);
assert(ota_ctx().manager.servedCount() == 0);
WiFiOtaSeeder::loop();
ota_ctx().manager.refresh_sources();
assert(stalled->requests == before_timeout + 2);
stalled->connected = false;
WiFiOtaSeeder::loop();
ota_release_context_if_idle(true);
auto replacement = connect_host();
WiFiOtaSeeder::loop();
assert(replacement->requests >= 2 && WiFiOtaSeeder::isAttached());
assert(ota_ctx().manager.servedCount() == 1);
WiFiOtaSeeder::stop();
ota_release_context_if_idle(true);
assert(!ota_context_if_active() && q.buffer.capacity() == 256);
check_messages();
packets.clear();
// An incoming TCP client and listener stop must not detach a USB source.
char reply[160];
assert(ota_acquire_context(reply, sizeof reply));
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Compile the production Companion include block without transitive OTA headers."""
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
MAIN = ROOT / "examples/companion_radio/main.cpp"
class CompanionOtaIncludeTest(unittest.TestCase):
def compile_include_block(self, flags, usb, ble):
# Preserve the actual production includes and their feature guards.
# Unlike handler-extraction tests, this harness does not inject either
# OTA header. MyMesh intentionally lacks its optional queue-dependent
# OtaContext include, just like the Heltec V4 Full configuration.
prefix = MAIN.read_text().split(
"// Believe it or not, this std C function is busted on some platforms!",
1,
)[0]
self.assertIn('#include "MyMesh.h"', prefix)
with tempfile.TemporaryDirectory(prefix="meshcore-companion-includes-") as temp:
directory = Path(temp)
for header in (
"helpers/ui/StartupScreen.h", "helpers/ui/DisplayPowerSettings.h",
"helpers/UsbLoggingWatchdog.h", "helpers/UsbLoggingClientActivity.h",
"Mesh.h", "helpers/BluetoothMac.h", "CompanionBluetooth.h",
"CompanionWireless.h", "CompanionWiFi.h", "esp_bt.h", "esp_pm.h",
"esp_sleep.h", "esp_system.h", "esp_heap_caps.h",
):
path = directory / header
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#pragma once\n")
(directory / "MyMesh.h").write_text(
'#pragma once\n#include "CompanionFeatures.h"\n'
)
if not (usb or ble):
# Prove that small/non-OTA images do not gain these dependencies.
for header in ("MotaSourceSerial.h", "OtaContext.h"):
path = directory / "helpers/ota" / header
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('#error "OTA headers must remain feature guarded"\n')
checks = (
"static_assert(COMPANION_FEATURE_USB_MOTA_SOURCE == %d, \"USB profile\");\n"
"static_assert(COMPANION_FEATURE_BLE_MOTA_SOURCE == %d, \"BLE profile\");\n"
) % (usb, ble)
if usb or ble:
checks += (
"static_assert(sizeof(mesh::ota::OtaContext) != 0, \"context declared\");\n"
"static_assert(mesh::ota::SerialMotaSource::RESPONSE_BYTE == -2, "
"\"source declared\");\n"
)
if usb:
checks += (
"mesh::ota::SerialMotaSource& source_under_test() {\n"
" return mesh::ota::OtaContext::serialFolderSource();\n}\n"
)
source = directory / "includes.cpp"
source.write_text(
"class Stream;\nStream& include_test_serial_port();\n"
"#define OTA_FOLDER_SERIAL_STREAM include_test_serial_port()\n"
+ prefix + checks
)
result = subprocess.run([
"c++", "-std=c++17", "-fsyntax-only", *flags,
"-I", temp, "-I", str(ROOT / "src"),
"-I", str(ROOT / "test/mocks"),
"-I", str(ROOT / "examples/companion_radio"), str(source),
], text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_usb_only_full_esp32_declares_actual_ota_types(self):
self.compile_include_block([
"-DESP32_PLATFORM=1", "-DCOMPANION_RADIO_FULL=1", "-DBLE_PIN_CODE=123456",
"-DWIFI_SSID=\"\"", "-DENABLE_USB_INTERFACE=1", "-DENABLE_OTA=1",
"-DOTA_FOLDER_SERIAL=1", "-DOTA_SEEDER_ONLY=1",
], usb=1, ble=0)
def test_ble_only_full_nrf52_declares_actual_ota_types(self):
self.compile_include_block([
"-DNRF52_PLATFORM=1", "-DCOMPANION_RADIO_FULL=1", "-DBLE_PIN_CODE=123456",
"-DENABLE_OTA=1", "-DOTA_SEEDER_ONLY=1",
"-DCOMPANION_FEATURE_BLE_MOTA_SOURCE=1",
], usb=0, ble=1)
def test_non_ota_image_keeps_ota_headers_out(self):
self.compile_include_block(["-DSTM32_PLATFORM=1"], usb=0, ble=0)
if __name__ == "__main__":
unittest.main()
+559
View File
@@ -0,0 +1,559 @@
#!/usr/bin/env python3
"""Run actual Companion ASCII/probe and mOTA input handlers on host streams."""
from pathlib import Path
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 <cassert>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <deque>
#include <string>
#include <vector>
#include "helpers/ArduinoSerialInterface.h"
#include "helpers/ota/MotaSourceSerial.h"
#include "helpers/UsbAsciiBinarySwitch.h"
#include "helpers/CLICommandUtils.h"
#define COMPANION_FEATURE_USB_MOTA_SOURCE 1
#define COMPANION_FEATURE_NETWORK_TERMINAL 0
#define MESH_USB_LOGGING_AVAILABLE 1
struct Input : Stream {
std::deque<uint8_t> bytes;
std::string output;
std::deque<std::vector<uint8_t>> responses;
unsigned source_requests=0;
bool replenish=false;
uint32_t advance_per_read=0;
unsigned reads=0;
int available() override { return static_cast<int>(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<const char*>(p),n); return n;
}
void push(const char* p) { while(*p) bytes.push_back(uint8_t(*p++)); }
void frame(uint8_t tail=3) {
const uint8_t frame[]={'<',2,0,0x16,tail};
for(uint8_t c : frame) bytes.push_back(c);
}
} input;
#define OTA_FOLDER_SERIAL_STREAM input
#define OTA_FOLDER_SERIAL_WRITE_POLICY mesh::ota::MotaStreamWritePolicy::NoFlush
namespace mesh { namespace ota {
struct OtaContext {
@SOURCE_ACCESSOR@
};
} }
ArduinoSerialInterface usb_serial_interface;
static Stream& usbTerminalOutput() { return input; }
static bool connected=true, logging=false, dedicated=false;
static std::vector<std::string> 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<std::string> 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;
static char usb_terminal_line[400]={};
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<uint8_t> response(uint8_t op,const std::vector<uint8_t>& payload,
uint8_t status=0) {
std::vector<uint8_t> 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<position;++i) input.bytes.push_back(command[i]);
input.bytes.push_back(0);
input.push(command+position); input.push("\r\nget name\r\n");
serviceUsbTerminal();
assert(!usb_mota_mode && the_mesh.attaches==0 && the_mesh.detaches==0);
assert(the_mesh.commands.size()==1 && the_mesh.commands[0]=="get name");
assert(stats_commands==the_mesh.commands);
const auto error=input.output.find("ERROR: invalid command");
assert(error!=std::string::npos);
assert(input.output.find("ERROR: invalid command",error+1)==std::string::npos);
}
}
reset(); input.push("ota fol"); input.bytes.push_back(0); serviceUsbTerminal();
assert(usb_terminal_discard_line && usb_terminal_line_len==0 && the_mesh.attaches==0);
input.push("der on\r\nget name\r\n"); serviceUsbTerminal();
assert(the_mesh.commands.size()==1 && the_mesh.commands[0]=="get name");
assert(!usb_terminal_discard_line && !usb_mota_mode && the_mesh.attaches==0);
// 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<std::string>{"get stats"});
assert(stats_commands==the_mesh.commands);
reset(); dedicated=true; input.push("get stats\r\n"); serviceUsbTerminal();
assert(the_mesh.commands==std::vector<std::string>{"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<length;++i) input.bytes.push_back('x');
input.push("ota folder off\r\n"); serviceUsbMota();
assert(usb_mota_mode && the_mesh.detaches==0);
assert(!usb_mota_discard_line && usb_mota_line_len==0);
input.push("ota folder off\r\n"); serviceUsbMota();
assert(!usb_mota_mode && the_mesh.detaches==1 && the_mesh.terminal);
}
// Discard survives arbitrary read boundaries, but reset/entry clear it.
reset(); leaveUsbTerminalMode(false);
assert(enterUsbMotaMode(mesh::UsbMotaEntryOrigin::BINARY));
input.push("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); serviceUsbMota();
assert(usb_mota_line_len==31 && !usb_mota_discard_line);
input.push("xota folder off"); serviceUsbMota();
assert(usb_mota_discard_line && usb_mota_mode && the_mesh.detaches==0);
input.push("\n"); serviceUsbMota(); assert(!usb_mota_discard_line);
input.push("ota folder off\r\n"); serviceUsbMota();
assert(!usb_mota_mode && !the_mesh.terminal && !usb_serial_interface.isPassthroughMode());
usb_mota_discard_line=true; resetUsbMotaMode(); assert(!usb_mota_discard_line);
usb_mota_discard_line=true;
assert(enterUsbMotaMode(mesh::UsbMotaEntryOrigin::BINARY));
assert(!usb_mota_discard_line);
// mOTA NUL-containing shutdown strings must not truncate into an exact
// owner command. Rejecting them is silent and preserves the source.
for(const char* command : {"ota folder on","ota folder off"}) {
for(size_t position=0;position<=strlen(command);++position) {
reset(); leaveUsbTerminalMode(false);
assert(enterUsbMotaMode(mesh::UsbMotaEntryOrigin::ASCII));
input.output.clear();
for(size_t i=0;i<position;++i) input.bytes.push_back(command[i]);
input.bytes.push_back(0);
input.push(command+position); input.push("\r\n"); serviceUsbMota();
assert(usb_mota_mode && the_mesh.detaches==0 && input.output.empty());
input.push("ota folder off\r\n"); serviceUsbMota();
assert(!usb_mota_mode && the_mesh.detaches==1 && the_mesh.terminal);
}
}
reset(); leaveUsbTerminalMode(false);
assert(enterUsbMotaMode(mesh::UsbMotaEntryOrigin::BINARY));
input.push("ota folder off"); input.bytes.push_back(0); serviceUsbMota();
assert(usb_mota_discard_line && usb_mota_line_len==0 && the_mesh.detaches==0);
input.push("suffix\n"); serviceUsbMota();
assert(!usb_mota_discard_line && usb_mota_mode && the_mesh.detaches==0);
input.push("ota folder off\r\n"); serviceUsbMota();
assert(!usb_mota_mode && the_mesh.detaches==1 && !the_mesh.terminal);
// Continually arriving data cannot make a single mOTA control poll unbounded.
reset(); leaveUsbTerminalMode(false);
assert(enterUsbMotaMode(mesh::UsbMotaEntryOrigin::BINARY));
input.bytes={'x','x','x','x'}; input.replenish=true;
const unsigned before=input.reads; serviceUsbMota();
assert(input.reads==before+4 && input.available()==4);
// The ordinary ASCII handler yields too, including while discarding an
// overlong command from a host that never stops writing.
reset(); input.bytes={'x','x','x','x'}; input.replenish=true;
for(unsigned pass=0;pass<128;++pass) {
const unsigned before=input.reads; serviceUsbTerminal();
assert(input.reads==before+4 && input.available()==4);
}
assert(usb_terminal_discard_line && the_mesh.commands.empty());
input.replenish=false; input.push("\nget name\r\n"); serviceUsbTerminal();
assert(!usb_terminal_discard_line && the_mesh.commands.size()==1);
assert(the_mesh.commands[0]=="get name");
// A real timed-out READ reply can contain shutdown text as arbitrary image
// bytes. No prefix, header, payload or checksum boundary may expose it.
const char dangerous[]="x\nota folder off\n";
const auto late_read=response(3,std::vector<uint8_t>(dangerous,dangerous+sizeof(dangerous)-1));
for(size_t prefix=0;prefix<late_read.size();++prefix) {
reset(); leaveUsbTerminalMode(false);
assert(enterUsbMotaMode(mesh::UsbMotaEntryOrigin::ASCII));
auto& source=mesh::ota::OtaContext::serialFolderSource();
input.responses.emplace_back(late_read.begin(),late_read.begin()+prefix);
uint8_t data[sizeof(dangerous)-1]={};
assert(!source.read(0,0,data,sizeof(data)) && source.hasPendingResponse());
const unsigned requests=input.source_requests;
for(unsigned retry=0;retry<3;++retry) {
assert(!source.read(0,0,data,1));
assert(input.source_requests==requests && source.hasPendingResponse());
}
// Drain fragments through both txn's stale pass and the control consumer.
for(size_t i=prefix;i<late_read.size();++i) {
input.bytes.push_back(late_read[i]);
if(i+1<late_read.size() && i%2==0) {
assert(!source.read(0,0,data,1));
assert(input.source_requests==requests);
} else serviceUsbMota();
assert(usb_mota_mode && the_mesh.detaches==0);
}
assert(!source.hasPendingResponse());
input.push("ota folder off\r\n"); serviceUsbMota();
assert(!usb_mota_mode && the_mesh.detaches==1 && the_mesh.terminal);
}
// Successful response followed immediately by an ordinary shutdown retains
// both its payload and the exact control bytes at the proven wire boundary.
reset(); leaveUsbTerminalMode(false);
assert(enterUsbMotaMode(mesh::UsbMotaEntryOrigin::ASCII));
auto good_read=response(3,{42});
const char off[]="ota folder off\r\n";
good_read.insert(good_read.end(),off,off+sizeof(off)-1);
input.responses.push_back(good_read);
uint8_t good_byte=0;
assert(mesh::ota::OtaContext::serialFolderSource().read(0,0,&good_byte,1));
assert(good_byte==42); serviceUsbMota();
assert(!usb_mota_mode && the_mesh.detaches==1 && the_mesh.terminal);
// Failed ASCII and Binary attach must quarantine a late COUNT/DESCRIBE
// frame through every partial boundary, not execute its text/frame payload.
std::vector<uint8_t> 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<delayed.size();++prefix) {
reset(); the_mesh.attach_with_source=true;
if(describe) input.responses.push_back(response(1,{1}));
input.responses.emplace_back(delayed.begin(),delayed.begin()+prefix);
if(!ascii) {
leaveUsbTerminalMode(false);
assert(!enterUsbMotaMode(mesh::UsbMotaEntryOrigin::BINARY));
} else {
input.push("ota folder on\r\n"); serviceUsbTerminal();
}
auto& source=mesh::ota::OtaContext::serialFolderSource();
assert(!usb_mota_mode && source.hasPendingResponse());
assert(the_mesh.terminal==ascii && usb_serial_interface.isPassthroughMode());
const unsigned requests=input.source_requests;
assert(!enterUsbMotaMode(mesh::UsbMotaEntryOrigin::BINARY));
assert(source.hasPendingResponse() && input.source_requests==requests);
uint8_t command[MAX_FRAME_SIZE]={};
for(size_t i=prefix;i<delayed.size();++i) {
input.bytes.push_back(delayed[i]);
assert(usb_serial_interface.checkRecvFrame(command)==0);
serviceUsbTerminal();
assert(the_mesh.commands.empty() && !usb_serial_interface.hasReceivedFrame());
}
assert(!source.hasPendingResponse());
assert(usb_serial_interface.isPassthroughMode()==ascii);
if(ascii) {
input.push("get name\r\n"); serviceUsbTerminal();
assert(the_mesh.commands.size()==1 && the_mesh.commands[0]=="get name");
} else {
input.frame(); assert(usb_serial_interface.checkRecvFrame(command)==2);
}
}
}
}
// A partial payload is deliberately ambiguous: ordinary input cannot end
// quarantine. A physical session reset does clear the retained transaction.
reset(); the_mesh.attach_with_source=true;
input.responses.push_back(response(1,{1}));
const auto delayed_desc=response(2,desc);
input.responses.emplace_back(delayed_desc.begin(),delayed_desc.begin()+5);
input.push("ota folder on\r\n"); serviceUsbTerminal();
auto& waiting_source=mesh::ota::OtaContext::serialFolderSource();
assert(waiting_source.hasPendingResponse());
input.push("get name\r\n"); serviceUsbTerminal();
assert(the_mesh.commands.empty() && waiting_source.hasPendingResponse());
input.bytes.clear(); // The transport purges the old session's bytes.
resetUsbTerminalHostSession();
assert(!waiting_source.hasPendingResponse() && !usb_serial_interface.hasReceivedFrame());
// A real software handoff preserves the outstanding frame and restores the
// chosen terminal/logging/network ownership only after its final checksum.
for(bool terminal : {false,true}) {
for(bool shared_logging : {false,true}) {
for(bool parked : {false,true}) {
reset(); leaveUsbTerminalMode(false);
assert(enterUsbMotaMode(mesh::UsbMotaEntryOrigin::BINARY));
auto& source=mesh::ota::OtaContext::serialFolderSource();
input.responses.emplace_back(late_read.begin(),late_read.begin()+5);
uint8_t data[sizeof(dangerous)-1]={};
assert(!source.read(0,0,data,sizeof(data)));
leaveUsbMotaMode(false);
assert(source.hasPendingResponse() && usb_serial_interface.isPassthroughMode());
if(terminal) enterUsbTerminalMode(false);
logging=true; dedicated=!shared_logging;
usb_logging_network_parked=parked;
for(size_t i=5;i<late_read.size();++i) input.bytes.push_back(late_read[i]);
assert(serviceUsbMotaResponseBoundary());
assert(!source.hasPendingResponse() && the_mesh.commands.empty());
assert(the_mesh.terminal==terminal);
assert(usb_serial_interface.isPassthroughMode()==(terminal || shared_logging || parked));
}
}
}
}
'''
class CompanionUsbInputTests(unittest.TestCase):
def test_actual_usb_input_state_machines(self):
functions = "\n".join(extract_braced(MAIN, signature)
for signature in (
"static void clearUsbTerminalLine()",
"static bool acceptUsbBinaryStartupFrame(uint32_t completed_at)",
"static void enterUsbTerminalMode(bool show_banner",
"static void leaveUsbTerminalMode(bool acknowledge)",
"static void resetUsbMotaMode()",
"static void leaveUsbMotaMode(bool acknowledge)",
"static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin)",
"static void serviceUsbMota()",
"static bool serviceUsbMotaResponseBoundary()",
"static void resetUsbTerminalHostSession()",
"static void serviceUsbTerminal()",
"static void expireUsbBinaryStartupProbeBeforeDispatch()",
))
# The default is in the forward declaration in production.
functions = functions.replace("static void enterUsbTerminalMode(bool show_banner)",
"static void enterUsbTerminalMode(bool show_banner = true)")
accessor = extract_braced(CONTEXT, "static SerialMotaSource& serialFolderSource()")
with tempfile.TemporaryDirectory() as directory:
cpp = Path(directory) / "usb-input.cpp"
cpp.write_text(HARNESS.replace("@FUNCTIONS@", functions)
.replace("@SOURCE_ACCESSOR@", accessor))
for sanitizer in (False, True):
with self.subTest(sanitizer=sanitizer):
binary = Path(directory) / ("usb-input-" + str(sanitizer))
flags = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sanitizer else [])
build = subprocess.run([
"g++", "-std=c++17", *flags,
"-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
str(cpp), str(ROOT / "src/helpers/ArduinoSerialInterface.cpp"),
str(ROOT / "src/helpers/ota/MotaSourceSerial.cpp"),
"-o", str(binary)], capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
run = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
if __name__ == "__main__":
unittest.main()
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Execute real USB logging/mOTA handoffs across retained response boundaries."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_companion_usb_input import CONTEXT, HARNESS, MAIN, ROOT
from test_replay_reset_integration import extract_braced
CASES = r'''
static void selectAscii() {
if (!the_mesh.terminal) {
input.push(USB_TERMINAL_START_TOKEN); input.push("\n");
uint8_t frame[MAX_FRAME_SIZE]={};
assert(usb_serial_interface.checkRecvFrame(frame)==0);
serviceUsbTerminal();
assert(the_mesh.terminal);
}
}
static void verifyNextClients() {
selectAscii();
input.push("get name\r\n"); serviceUsbTerminal();
assert(the_mesh.commands.size()==1 && the_mesh.commands[0]=="get name");
logging=false; serviceUsbLoggingOwnership(false);
input.push(USB_TERMINAL_STOP_TOKEN); serviceUsbTerminal();
assert(!the_mesh.terminal && !usb_serial_interface.isPassthroughMode());
input.frame();
uint8_t frame[MAX_FRAME_SIZE]={};
assert(usb_serial_interface.checkRecvFrame(frame)==2);
assert(frame[0]==0x16 && frame[1]==3);
assert(usb_serial_interface.getCompletedFrameCount()==1);
}
static void responseHandoffs() {
// Every partial stage is exercised, with image bytes that resemble both
// ASCII ownership commands and Companion framing (including embedded NUL).
const char controls[]="\nget name\r\nota folder off\n+++MESHCORE-TERM-STOP\n";
std::vector<uint8_t> payload(controls,controls+sizeof(controls)-1);
payload.insert(payload.end(),{'<',2,0,0x16,3});
for (uint8_t status : {uint8_t(0),uint8_t(1)}) {
const auto late=response(3,status==0?payload:std::vector<uint8_t>{},status);
for (size_t prefix=0;prefix<late.size();++prefix) {
for (bool logging_after_close : {false,true}) {
for (bool logging_off_before_close : {false,true}) {
for (auto origin : {mesh::UsbMotaEntryOrigin::ASCII,
mesh::UsbMotaEntryOrigin::BINARY}) {
reset(); network=false; leaveUsbTerminalMode(false);
assert(enterUsbMotaMode(origin));
auto& source=mesh::ota::OtaContext::serialFolderSource();
input.responses.emplace_back(late.begin(),late.begin()+prefix);
std::vector<uint8_t> data(payload.size());
assert(!source.read(0,0,data.data(),data.size()));
assert(source.hasPendingResponse());
logging=network=true; serviceUsbLoggingOwnership(true);
assert(!usb_mota_mode && usb_logging_network_parked);
assert(usb_serial_interface.isPassthroughMode());
assert(the_mesh.attaches==1 && the_mesh.detaches==1);
if (logging_off_before_close) {
logging=false; serviceUsbLoggingOwnership(false);
assert(!usb_logging_network_parked && source.hasPendingResponse());
assert(usb_serial_interface.isPassthroughMode());
}
network=false; logging=logging_after_close;
uint8_t command[MAX_FRAME_SIZE]={};
for (size_t i=prefix;i<late.size();++i) {
input.bytes.push_back(late[i]);
serviceUsbLoggingOwnership(logging);
// Unparking cannot raw-drain the retained response, even if
// diagnostics are being disabled at the same time.
assert(input.available()==1);
assert(usb_serial_interface.isPassthroughMode());
assert(usb_serial_interface.checkRecvFrame(command)==0);
serviceUsbTerminal();
assert(source.hasPendingResponse()==(i+1<late.size()));
assert(the_mesh.commands.empty() && the_mesh.detaches==1);
assert(!usb_serial_interface.hasReceivedFrame());
}
assert(!usb_logging_network_parked && !source.hasPendingResponse());
verifyNextClients();
}
}
}
}
}
// The boundary consumer stops at the checksum, leaving a subsequent real
// ASCII command intact rather than discarding the entire queued snapshot.
reset(); network=false; leaveUsbTerminalMode(false);
assert(enterUsbMotaMode(mesh::UsbMotaEntryOrigin::BINARY));
auto& source=mesh::ota::OtaContext::serialFolderSource();
const auto late=response(3,{42,43});
input.responses.emplace_back(late.begin(),late.begin()+5);
uint8_t data[2]={}; assert(!source.read(0,0,data,2));
logging=network=true; serviceUsbLoggingOwnership(true);
for (size_t i=5;i<late.size();++i) input.bytes.push_back(late[i]);
input.push("get name\n"); network=false;
serviceUsbTerminal();
assert(!source.hasPendingResponse() && input.available()==9);
assert(the_mesh.commands.empty());
serviceUsbTerminal();
assert(the_mesh.commands.size()==1 && the_mesh.commands[0]=="get name");
// Without a retained binary response, the original bounded discard still
// removes log-only input. A refilling source cannot trap this handoff.
reset(); network=true; logging=true; leaveUsbTerminalMode(false);
serviceUsbLoggingOwnership(true); assert(usb_logging_network_parked);
input.push("xxxx"); input.replenish=true;
const unsigned reads=input.reads; network=false;
serviceUsbLoggingOwnership(true);
assert(input.reads==reads+4 && input.available()==4);
assert(!usb_logging_network_parked && the_mesh.terminal);
input.replenish=false;
}
static void probeHandoffs() {
// Empty and oversize startup frames grant no Binary proof; a deliberate
// mOTA token after either must supersede the abandoned probe's deadline.
for (uint16_t length : {uint16_t(0),uint16_t(177),uint16_t(65535)}) {
for (bool attach_succeeds : {false,true}) {
reset(); network=false; the_mesh.attach_succeeds=attach_succeeds;
input.bytes={'<',uint8_t(length),uint8_t(length>>8)};
for (uint32_t i=0;i<length;++i) input.bytes.push_back('x');
input.push("ota folder on\n"); serviceUsbTerminal();
assert(usb_binary_startup_probe.isActive() && !the_mesh.terminal);
uint8_t frame[MAX_FRAME_SIZE]={};
assert(usb_serial_interface.checkRecvFrame(frame)==0);
assert(!usb_serial_interface.hasReceivedFrame());
serviceUsbTerminal();
assert(usb_mota_mode==attach_succeeds && !the_mesh.terminal);
assert(!usb_binary_startup_probe.isActive());
assert(usb_serial_interface.isPassthroughMode()==attach_succeeds);
const unsigned banners=the_mesh.banners;
input.bytes.push_back('x'); g_mock_millis+=1001;
expireUsbBinaryStartupProbeBeforeDispatch();
assert(usb_mota_mode==attach_succeeds && !the_mesh.terminal);
assert(the_mesh.banners==banners && input.available()==1);
}
}
// A deferred ASCII default from a prior network handoff must also lose to
// deliberate mOTA ownership, including Binary-origin attach failure.
for (bool attach_succeeds : {false,true}) {
for (auto origin : {mesh::UsbMotaEntryOrigin::ASCII,
mesh::UsbMotaEntryOrigin::BINARY}) {
reset(); leaveUsbTerminalMode(false); network=true;
usb_ascii_session_default.request(usb_serial_interface.getCompletedFrameCount());
usb_binary_startup_probe.start(millis(),usb_serial_interface.getCompletedFrameCount());
the_mesh.attach_succeeds=attach_succeeds;
assert(enterUsbMotaMode(origin)==attach_succeeds);
assert(!usb_binary_startup_probe.isActive());
network=false; serviceUsbAsciiSessionDefault();
assert(!usb_ascii_session_default.shouldRestore(
true,false,usb_serial_interface.getCompletedFrameCount()));
assert(usb_mota_mode==attach_succeeds);
assert(the_mesh.terminal==(!attach_succeeds && origin==mesh::UsbMotaEntryOrigin::ASCII));
}
}
}
int main() { responseHandoffs(); probeHandoffs(); }
'''
class CompanionUsbLoggingHandoffTests(unittest.TestCase):
def test_actual_logging_and_mota_ownership(self):
functions = "\n".join(extract_braced(MAIN, signature) for signature in (
"static void clearUsbTerminalLine()",
"static bool acceptUsbBinaryStartupFrame(uint32_t completed_at)",
"static void enterUsbTerminalMode(bool show_banner",
"static void enterUsbLoggingTerminalMode()",
"static void leaveUsbTerminalMode(bool acknowledge)",
"static void resetUsbMotaMode()",
"static void leaveUsbMotaMode(bool acknowledge)",
"static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin)",
"static void serviceUsbMota()",
"static bool serviceUsbMotaResponseBoundary()",
"static void resetUsbTerminalHostSession()",
"static void serviceUsbAsciiSessionDefault()",
"static void serviceUsbLoggingOwnership(bool logging_enabled) {",
"static void serviceUsbTerminal()",
"static void expireUsbBinaryStartupProbeBeforeDispatch()",
))
functions = functions.replace(
"static void enterUsbTerminalMode(bool show_banner)",
"static void enterUsbTerminalMode(bool show_banner = true)")
harness = HARNESS[:HARNESS.index("int main()")]
harness = harness.replace("#define COMPANION_FEATURE_NETWORK_TERMINAL 0",
"#define COMPANION_FEATURE_NETWORK_TERMINAL 1")
harness = harness.replace(
"static void serviceUsbLoggingOwnership(bool) {}",
"static bool network=false;\n"
"static bool usb_terminal_host_reset_completion_pending=false;\n"
"static bool isNetworkTerminalActive() { return network; }\n"
"static void serviceUsbLoggingOwnership(bool);")
source = harness.replace("@FUNCTIONS@", functions).replace(
"@SOURCE_ACCESSOR@",
extract_braced(CONTEXT, "static SerialMotaSource& serialFolderSource()"))
with tempfile.TemporaryDirectory() as directory:
cpp = Path(directory) / "usb-handoff.cpp"
cpp.write_text(source + CASES, encoding="ascii")
for sanitizer in (False, True):
with self.subTest(sanitizer=sanitizer):
binary = Path(directory) / ("usb-handoff-" + str(sanitizer))
flags = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sanitizer else [])
build = subprocess.run([
"g++", "-std=c++17", *flags,
"-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
str(cpp), str(ROOT / "src/helpers/ArduinoSerialInterface.cpp"),
str(ROOT / "src/helpers/ota/MotaSourceSerial.cpp"),
"-o", str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
run = subprocess.run([str(binary)], capture_output=True,
text=True, timeout=20)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
if __name__ == "__main__":
unittest.main()
+12
View File
@@ -27,11 +27,21 @@ HARNESS = r'''
namespace mesh {
enum class UsbMotaEntryOrigin { BINARY, ASCII };
static void discardUsbTerminalOutput() {}
namespace ota {
struct Source {
bool hasPendingResponse() const { return false; }
void resetSessionState() {}
};
struct OtaContext {
static Source& serialFolderSource() { static Source source; return source; }
};
}
}
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 usb_mota_discard_line = false;
static bool ascii = false;
static int drained = 0, folder_off = 0;
static char control_reply[224];
@@ -61,6 +71,7 @@ static void prepare(mesh::UsbMotaEntryOrigin origin) {
usb_mota_mode = usb_mota_disconnect_armed = true;
usb_mota_entry_origin = origin;
usb_mota_line_len = 3;
usb_mota_discard_line = true;
strcpy(usb_mota_line, "old");
usb_serial_interface.passthrough = true;
ascii = false;
@@ -72,6 +83,7 @@ int main() {
leaveUsbMotaMode(true);
assert(ascii && usb_serial_interface.passthrough);
assert(!usb_mota_mode && !usb_mota_disconnect_armed && usb_mota_line_len == 0);
assert(!usb_mota_discard_line);
assert(folder_off == 1 && drained == 1);
assert(strstr(control_reply, "OK - Terminal mode") != nullptr);
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Run real native mOTA serial tests with actual production code and sanitizers."""
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class MotaSerialDeadlineTests(unittest.TestCase):
def test_actual_native_serial_cases(self):
native = (ROOT / "test/test_ota/test_ota_core.cpp").read_text()
start = native.index("class FakeMotaSeederStream")
end = native.index("class FakeFolderMotaSeederStream", start)
source = r'''
#include <gtest/gtest.h>
#include <algorithm>
#include <array>
#include <vector>
#include <cstring>
#include <type_traits>
#include <utility>
#include "helpers/ota/MotaSourceSerial.h"
#include "helpers/BleMotaStream.h"
#include "helpers/ota/MotaSeederProto.h"
#include "helpers/ota/OtaByteIO.h"
#include "helpers/ota/FolderMotaStore.h"
using namespace mesh::ota;
'''
source += native[start:end]
source += r'''
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
'''
googletest = ROOT / ".pio/libdeps/native/googletest/googletest"
self.assertTrue(googletest.is_dir(), "Native googletest dependency must be present")
with tempfile.TemporaryDirectory() as directory:
cpp = Path(directory) / "serial-deadline.cpp"
cpp.write_text(source)
for sanitizer in (False, True):
with self.subTest(sanitizer=sanitizer):
binary = Path(directory) / ("serial-deadline-" + str(sanitizer))
flags = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sanitizer else [])
build = subprocess.run([
"g++", "-std=c++17", "-pthread", *flags,
"-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
"-I", str(googletest / "include"), "-I", str(googletest),
str(cpp), str(ROOT / "src/helpers/ota/MotaSourceSerial.cpp"),
str(ROOT / "src/helpers/ota/FolderMotaStore.cpp"),
str(googletest / "src/gtest-all.cc"), "-o", str(binary)],
capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
run = subprocess.run([str(binary), "--gtest_brief=1"],
capture_output=True, text=True, timeout=30)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
if __name__ == "__main__":
unittest.main()
+408 -1
View File
@@ -4,6 +4,7 @@
#include <vector>
#include <cstring>
#include <type_traits>
#include <utility>
#include "helpers/ota/MotaContainer.h"
#include "helpers/ota/MerkleTree.h"
@@ -12,6 +13,7 @@
#include "helpers/ota/FirmwareInfo.h"
#include "helpers/ota/MotaSeederProto.h"
#include "helpers/ota/MotaSourceSerial.h"
#include "helpers/BleMotaStream.h"
#include "helpers/ota/FolderMotaStore.h"
#include "helpers/ota/SignerAllowlist.h"
#include "helpers/ota/OtaStore.h"
@@ -886,6 +888,10 @@ public:
return -1;
}
int available() override {
return static_cast<int>(response.size() - response_pos);
}
void flush() override {
++flush_calls;
if (flush_discards_receive) {
@@ -900,7 +906,7 @@ public:
bool flush_discards_receive = false;
size_t flush_calls = 0;
private:
protected:
std::vector<uint8_t> response;
size_t response_pos = 0;
};
@@ -955,6 +961,10 @@ public:
return -1;
}
int available() override {
return static_cast<int>(response.size() - response_pos);
}
void flush() override { ++flush_calls; }
std::vector<uint8_t> encoded;
@@ -1065,6 +1075,403 @@ TEST(MotaSourceSerial, DeflateExtensionFailureLeavesRawFallbackAvailable) {
EXPECT_EQ(stream.lengths, (std::vector<uint16_t>{0}));
}
class TimedMotaSeederStream : public FakeMotaSeederStream {
public:
std::vector<uint32_t> byte_at;
uint32_t started = 0;
uint32_t delay_per_byte = 0;
bool wrong_op = false, corrupt_checksum = false, error_status = false;
unsigned stale_remaining = 0, stale_refills = 0, stale_reads = 0;
unsigned stale_reads_at_request = 0;
size_t write(const uint8_t* request, size_t len) override {
stale_reads_at_request = stale_reads;
stale_remaining = stale_refills = 0;
started = millis();
const size_t written = FakeMotaSeederStream::write(request, len);
if (wrong_op) ++response[2];
if (error_status) {
response.resize(5);
response[3] = MS_STATUS_ERR;
}
uint8_t checksum = 0;
for (size_t i = 0; i + 1 < response.size(); ++i) checksum ^= response[i];
response.back() = checksum ^ (corrupt_checksum ? 1 : 0);
return written;
}
uint32_t byteTime(size_t index) const {
return index < byte_at.size() ? byte_at[index]
: uint32_t(index) * delay_per_byte;
}
int available() override {
if (stale_remaining) return static_cast<int>(stale_remaining);
const uint32_t elapsed = millis() - started;
size_t count = 0;
while (response_pos + count < response.size()
&& byteTime(response_pos + count) <= elapsed) ++count;
return static_cast<int>(count);
}
int read() override {
if (stale_remaining) {
--stale_remaining;
++stale_reads;
if (stale_refills) { --stale_refills; ++stale_remaining; }
return 'x';
}
if (response_pos >= response.size()
|| uint32_t(millis() - started) < byteTime(response_pos)) return -1;
return FakeMotaSeederStream::read();
}
size_t unreadResponse() const { return response.size() - response_pos; }
};
TEST(MotaSourceSerial, SharedDeadlineBoundsTricklingPayload) {
resetArduinoMock();
TimedMotaSeederStream stream;
stream.byte_at.resize(MOTA_SEEDER_READ_MAX + 5);
for (size_t i = 4; i < stream.byte_at.size(); ++i) {
stream.byte_at[i] = uint32_t(i - 3) * 590;
}
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 600);
std::array<uint8_t, MOTA_SEEDER_READ_MAX> data{};
EXPECT_FALSE(source.read(0, 0, data.data(), data.size()));
EXPECT_EQ(millis(), 600u);
EXPECT_EQ(data[0], 0u);
EXPECT_EQ(data[1], 0u);
EXPECT_GT(stream.unreadResponse(), 1u);
}
TEST(MotaSourceSerial, SyncHeaderPayloadAndChecksumShareOneDeadline) {
resetArduinoMock();
TimedMotaSeederStream stream;
stream.byte_at = {0, 300, 350, 450, 550, 575, 600};
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 600);
uint8_t data[2] = {};
EXPECT_FALSE(source.read(0, 0, data, sizeof(data)));
EXPECT_EQ(millis(), 600u);
EXPECT_EQ(stream.unreadResponse(), 1u);
}
TEST(MotaSourceSerial, DeadlineIsStrictAndHandlesMillisRollover) {
for (uint32_t started : {0u, UINT32_MAX - 20}) {
for (uint32_t checksum_at : {599u, 600u}) {
SCOPED_TRACE(started);
SCOPED_TRACE(checksum_at);
resetArduinoMock();
g_mock_millis = started;
TimedMotaSeederStream stream;
stream.byte_at = {0, 0, 0, 0, 0, checksum_at};
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 600);
uint8_t data = 0;
EXPECT_EQ(source.read(0, 0, &data, 1), checksum_at < 600);
EXPECT_EQ(uint32_t(millis() - started), checksum_at);
EXPECT_EQ(stream.unreadResponse(), checksum_at < 600 ? 0u : 1u);
}
}
}
TEST(MotaSourceSerial, ActualReadCompletionMustPrecedeDeadline) {
class AdvancingReadStream : public FakeMotaSeederStream {
public:
int read() override {
const int value = FakeMotaSeederStream::read();
if (value >= 0) ++g_mock_millis;
return value;
}
};
for (uint32_t start : {0u, UINT32_MAX - 3}) {
for (uint32_t timeout : {5u, 6u, 7u}) {
for (bool shared : {false, true}) {
SCOPED_TRACE(start);
SCOPED_TRACE(timeout);
SCOPED_TRACE(shared);
resetArduinoMock();
g_mock_millis = start;
AdvancingReadStream stream;
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, timeout);
if (shared) source.enableSharedTextControl();
uint8_t data = 0;
EXPECT_EQ(source.read(0, 0, &data, 1), timeout > 6);
EXPECT_EQ(uint32_t(millis() - start), std::min(timeout, 6u));
EXPECT_EQ(source.hasPendingResponse(), shared && timeout < 6);
}
}
}
}
TEST(MotaSourceSerial, LargestRepliesFitExistingUsbUartAndWirelessDeadlines) {
for (const auto profile : {std::pair<uint32_t, uint32_t>{400, 2},
std::pair<uint32_t, uint32_t>{600, 3},
std::pair<uint32_t, uint32_t>{3000, 15}}) {
SCOPED_TRACE(profile.first);
resetArduinoMock();
TimedMotaSeederStream stream;
stream.delay_per_byte = profile.second;
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, profile.first);
std::array<uint8_t, MOTA_SEEDER_READ_MAX> data{};
ASSERT_TRUE(source.read(0, 0x1000, data.data(), data.size()));
EXPECT_EQ(millis(), uint32_t(MOTA_SEEDER_READ_MAX + 4) * profile.second);
EXPECT_LT(millis(), profile.first);
for (size_t i = 0; i < data.size(); ++i) EXPECT_EQ(data[i], uint8_t(i));
}
}
TEST(MotaSourceSerial, StaleDrainDoesNotChaseNewlyArrivingInput) {
resetArduinoMock();
TimedMotaSeederStream stream;
stream.stale_remaining = 4;
stream.stale_refills = 512;
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 600);
uint8_t data = 0;
ASSERT_TRUE(source.read(0, 0x5A, &data, 1));
EXPECT_EQ(stream.stale_reads_at_request, 4u);
EXPECT_EQ(data, 0x5A);
}
TEST(MotaSourceSerial, ErrorChecksumAndWrongOpStayFailClosedAndRetryResynchronizes) {
for (unsigned failure = 0; failure < 3; ++failure) {
SCOPED_TRACE(failure);
resetArduinoMock();
TimedMotaSeederStream stream;
stream.error_status = failure == 0;
stream.corrupt_checksum = failure == 1;
stream.wrong_op = failure == 2;
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 600);
uint8_t data[8] = {};
EXPECT_FALSE(source.read(0, 0x20, data, sizeof(data)));
stream.error_status = stream.corrupt_checksum = stream.wrong_op = false;
ASSERT_TRUE(source.read(0, 0x30, data, sizeof(data)));
EXPECT_EQ(stream.unreadResponse(), 0u);
for (size_t i = 0; i < sizeof(data); ++i) EXPECT_EQ(data[i], uint8_t(0x30 + i));
}
}
class ManualMotaSeederStream : public Stream {
public:
using Stream::write;
std::vector<std::vector<uint8_t>> replies;
std::vector<uint8_t> input;
size_t position = 0, writes = 0;
size_t write(const uint8_t*, size_t len) override {
if (writes < replies.size()) push(replies[writes]);
++writes;
return len;
}
void push(const std::vector<uint8_t>& bytes) {
input.insert(input.end(), bytes.begin(), bytes.end());
}
int available() override { return static_cast<int>(input.size() - position); }
int read() override { return position < input.size() ? input[position++] : -1; }
};
static std::vector<uint8_t> serialResponse(uint8_t op,
const std::vector<uint8_t>& payload,
uint8_t status = MS_STATUS_OK) {
std::vector<uint8_t> response = {'m', 's', op, status};
response.insert(response.end(), payload.begin(), payload.end());
uint8_t checksum = 0;
for (uint8_t value : response) checksum ^= value;
response.push_back(checksum);
return response;
}
static std::vector<uint8_t> serialControlBytes(SerialMotaSource& source) {
std::vector<uint8_t> result;
int pending = source.availableControlBytes();
while (pending-- > 0) {
const int value = source.readControlByte();
if (value == -1) break;
if (value >= 0) result.push_back(static_cast<uint8_t>(value));
}
return result;
}
TEST(MotaSourceSerial, LateReplyBoundariesCoverAllSourceOperationsAndErrorStatus) {
const char stop[] = "ota folder off\r\n";
const std::vector<uint8_t> controls(stop, stop + sizeof(stop) - 1);
for (uint8_t op : {MS_OP_COUNT, MS_OP_DESCRIBE, MS_OP_READ, MS_OP_DEFLATE_BLOCK}) {
for (bool error : {false, true}) {
std::vector<uint8_t> payload;
if (!error) {
if (op == MS_OP_COUNT) payload = {1};
else if (op == MS_OP_DESCRIBE) payload.assign(MOTA_DESC_WIRE, 'x');
else if (op == MS_OP_DEFLATE_BLOCK) payload = {17, 0};
else {
const char body[] = "x\nota folder off\n";
payload.assign(body, body + sizeof(body) - 1);
}
}
const auto response = serialResponse(op, payload, error ? MS_STATUS_ERR : MS_STATUS_OK);
for (size_t prefix = 0; prefix < response.size(); ++prefix) {
SCOPED_TRACE(op);
SCOPED_TRACE(error);
SCOPED_TRACE(prefix);
resetArduinoMock();
ManualMotaSeederStream stream;
stream.replies.emplace_back(response.begin(), response.begin() + prefix);
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 4);
source.enableSharedTextControl();
uint8_t data[64] = {};
uint16_t encoded_len = 123;
MotaDesc desc;
if (op == MS_OP_COUNT) EXPECT_EQ(source.count(), 0);
else if (op == MS_OP_DESCRIBE) EXPECT_FALSE(source.describe(0, desc));
else if (op == MS_OP_DEFLATE_BLOCK) {
EXPECT_FALSE(source.read_deflated_block(0, 0, data, sizeof(data), &encoded_len));
EXPECT_EQ(encoded_len, 0u);
} else EXPECT_FALSE(source.read(0, 0, data, payload.size() ? payload.size() : 17));
EXPECT_TRUE(source.hasPendingResponse());
for (unsigned retry = 0; retry < 3; ++retry) {
EXPECT_FALSE(source.read(0, 0, data, 7));
EXPECT_EQ(stream.writes, 1u);
EXPECT_TRUE(source.hasPendingResponse());
}
stream.push(std::vector<uint8_t>(response.begin() + prefix, response.end()));
stream.push(controls);
EXPECT_EQ(serialControlBytes(source), controls);
EXPECT_FALSE(source.hasPendingResponse());
}
}
}
}
TEST(MotaSourceSerial, StaleDrainRetainsOldBodyLengthAcrossDifferentSizeRetries) {
resetArduinoMock();
ManualMotaSeederStream stream;
const auto old_response = serialResponse(MS_OP_READ, {'x', '\n', 'o', 't', 'a', ' ', 'f', 'o',
'l', 'd', 'e', 'r', ' ', 'o', 'f', 'f', '\n'});
stream.replies.emplace_back(old_response.begin(), old_response.begin() + 6);
stream.replies.push_back(serialResponse(MS_OP_READ, {42, 43}));
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 4);
source.enableSharedTextControl();
uint8_t old_data[17] = {};
EXPECT_FALSE(source.read(0, 0, old_data, sizeof(old_data)));
uint8_t new_data[2] = {};
for (size_t index = 6; index + 1 < old_response.size(); ++index) {
stream.push({old_response[index]});
EXPECT_FALSE(source.read(0, 99, new_data, sizeof(new_data)));
EXPECT_EQ(stream.writes, 1u);
EXPECT_TRUE(source.hasPendingResponse());
EXPECT_TRUE(serialControlBytes(source).empty());
}
stream.push({old_response.back()});
const char stop[] = "ota folder off\r\n";
const std::vector<uint8_t> controls(stop, stop + sizeof(stop) - 1);
stream.push(controls);
EXPECT_TRUE(source.read(0, 99, new_data, sizeof(new_data)));
EXPECT_EQ(stream.writes, 2u);
EXPECT_EQ(new_data[0], 42u);
EXPECT_EQ(new_data[1], 43u);
EXPECT_EQ(serialControlBytes(source), controls);
EXPECT_FALSE(source.hasPendingResponse());
}
TEST(MotaSourceSerial, LateDeflateChunkLengthIncludesItsTwoByteSizePrefix) {
const char body[] = "x\nota folder off\n";
std::vector<uint8_t> payload = {sizeof(body) - 1, 0};
payload.insert(payload.end(), body, body + sizeof(body) - 1);
const auto delayed = serialResponse(MS_OP_DEFLATE_BLOCK, payload);
const char stop[] = "ota folder off\r\n";
const std::vector<uint8_t> controls(stop, stop + sizeof(stop) - 1);
for (size_t prefix = 0; prefix < delayed.size(); ++prefix) {
SCOPED_TRACE(prefix);
resetArduinoMock();
ManualMotaSeederStream stream;
stream.replies.push_back(serialResponse(MS_OP_DEFLATE_BLOCK, {sizeof(body) - 1, 0}));
stream.replies.emplace_back(delayed.begin(), delayed.begin() + prefix);
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 4);
source.enableSharedTextControl();
uint8_t data[64] = {};
uint16_t encoded_len = 123;
EXPECT_FALSE(source.read_deflated_block(0, 0, data, sizeof(data), &encoded_len));
EXPECT_EQ(encoded_len, 0u);
EXPECT_EQ(stream.writes, 2u);
EXPECT_TRUE(source.hasPendingResponse());
stream.push(std::vector<uint8_t>(delayed.begin() + prefix, delayed.end()));
stream.push(controls);
EXPECT_EQ(serialControlBytes(source), controls);
EXPECT_FALSE(source.hasPendingResponse());
}
}
TEST(MotaSourceSerial, ControlQueueOverflowRejectsWholeLineAndResetClearsQuarantine) {
resetArduinoMock();
ManualMotaSeederStream stream;
std::vector<uint8_t> malformed(100, 'x');
const char suffix[] = "ota folder off\n";
malformed.insert(malformed.end(), suffix, suffix + sizeof(suffix) - 1);
stream.push(malformed);
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 4);
source.enableSharedTextControl();
uint8_t data = 0;
EXPECT_FALSE(source.read(0, 0, &data, 1));
EXPECT_TRUE(source.hasPendingResponse());
EXPECT_EQ(serialControlBytes(source), (std::vector<uint8_t>{0, '\n'}));
source.resetSessionState();
EXPECT_FALSE(source.hasPendingResponse());
EXPECT_EQ(source.availableControlBytes(), 0);
stream.replies.resize(2);
stream.replies[1] = serialResponse(MS_OP_READ, {42});
EXPECT_TRUE(source.read(0, 0, &data, 1));
EXPECT_EQ(data, 42u);
}
TEST(MotaSourceSerial, DefaultBleRetryAndNewRingSessionPreserveLegacyBehavior) {
resetArduinoMock();
BleMotaStream stream;
struct Host {
BleMotaStream* stream;
unsigned requests = 0;
std::vector<uint8_t> reply;
} host{&stream, 0, {'m', 's', MS_OP_READ, MS_STATUS_OK, 'x'}};
stream.setSender([](void* context, const uint8_t*, size_t len) {
auto& host = *static_cast<Host*>(context);
++host.requests;
if (!host.reply.empty()) EXPECT_TRUE(host.stream->pushRx(host.reply.data(), host.reply.size()));
return len;
}, &host);
stream.setActive(true);
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 4);
uint8_t data[17] = {};
EXPECT_FALSE(source.read(0, 0, data, sizeof(data)));
EXPECT_FALSE(source.hasPendingResponse());
host.reply = serialResponse(MS_OP_COUNT, {1});
EXPECT_EQ(source.count(), 1);
EXPECT_EQ(host.requests, 2u); // Default non-text transports retain ordinary retries.
stream.setActive(false);
stream.setActive(true); // New generation drops the old ring contents/writers.
EXPECT_EQ(stream.available(), 0);
EXPECT_FALSE(source.hasPendingResponse());
source.resetSessionState(); // The production BLE start pairs these two resets.
host.reply = serialResponse(MS_OP_COUNT, {1});
EXPECT_EQ(source.count(), 1);
EXPECT_EQ(host.requests, 3u);
EXPECT_FALSE(source.hasPendingResponse());
}
TEST(MotaSourceSerial, DefaultRetryCanShareTcpWithLegacyFolderStoreReads) {
resetArduinoMock();
ManualMotaSeederStream stream;
stream.replies.push_back({'m', 's', MS_OP_COUNT, MS_STATUS_OK});
stream.replies.push_back(serialResponse(MS_OP_STAT, {1, 13, 0, 0, 0}));
stream.replies.push_back(serialResponse(MS_OP_COUNT, {1}));
SerialMotaSource source(stream, MotaStreamWritePolicy::NoFlush, 4);
FolderMotaStore store(stream, MotaStreamWritePolicy::NoFlush, 4);
EXPECT_EQ(source.count(), 0);
EXPECT_FALSE(source.hasPendingResponse());
const auto old_response = serialResponse(MS_OP_COUNT, {1});
stream.push(std::vector<uint8_t>(old_response.begin() + 4, old_response.end()));
EXPECT_TRUE(store.reopen()); // Its independent legacy drain consumes the old tail.
EXPECT_EQ(store.staged_size(), 13u);
EXPECT_EQ(source.count(), 1); // No USB-only retained state poisons this source.
EXPECT_EQ(stream.writes, 3u);
EXPECT_FALSE(source.hasPendingResponse());
}
class FakeFolderMotaSeederStream : public Stream {
public:
using Stream::write;