Merge USB session safety and preserve GPS preference compatibility

This commit is contained in:
mikecarper committed 2026-10-03 16:12:32 -07:00
commit 24fcbf3247
62 files changed
+2479 -240

No files matched your search

@@ -7,6 +7,7 @@ uint32_t tud_cdc_n_write_available(uint8_t instance);
uint32_t tud_cdc_n_write(uint8_t instance, const void* data, uint32_t size);
uint32_t tud_cdc_n_write_flush(uint8_t instance);
bool tud_cdc_n_write_clear(uint8_t instance);
void tud_cdc_n_read_flush(uint8_t instance);
bool tud_mounted();
bool tud_connect();
bool tud_disconnect();
@@ -2,6 +2,8 @@
#include <cassert>
#include <iostream>
#include <string>
#include <atomic>
#include <thread>
#include "helpers/UsbLogging.h"
#include "helpers/UsbLoggingClientActivity.h"
#include "MeshCore.h"
@@ -9,9 +11,9 @@
MockSerial Serial;
static bool connected = true;
static bool mounted = true;
static unsigned disconnect_calls = 0;
static unsigned connect_calls = 0;
static bool mounted = true, attached = true, arm_packets = false;
static std::string endpoint;
static unsigned detach_calls = 0, attach_calls = 0;
static bool auto_drain = false;
static std::string fifo;
static std::string host;
@@ -19,10 +21,26 @@ static unsigned write_calls = 0;
static unsigned flush_calls = 0;
static unsigned clear_calls = 0;
static void (*during_write)() = nullptr;
static void (*during_disconnect)() = nullptr;
extern "C" void meshEsp32TinyUsbCdcLineState(bool dtr);
extern "C" void meshEsp32TinyUsbTxComplete();
extern "C" bool meshEsp32TinyUsbAcceptRx();
extern "C" void meshEsp32TinyUsbDeviceSessionBoundary(bool mounted);
extern "C" bool meshEsp32TinyUsbTxPending() { return !endpoint.empty(); }
bool tud_mounted() { return mounted; }
bool tud_disconnect() { ++disconnect_calls; mounted = false; connected = false; return true; }
bool tud_connect() { ++connect_calls; return true; }
bool tud_disconnect() {
++detach_calls;
if (during_disconnect) during_disconnect();
attached = false;
connected = false;
return true;
}
bool tud_connect() { ++attach_calls; attached = true; return true; }
void tud_cdc_n_read_flush(uint8_t instance) {
assert(instance == 0);
assert(mounted && "RX flush must not rearm a reset/unopened OUT endpoint");
}
bool tud_cdc_n_connected(uint8_t instance) {
assert(!mock_isr);
@@ -47,6 +65,12 @@ uint32_t tud_cdc_n_write_flush(uint8_t instance) {
assert(!mock_isr);
assert(instance == 0);
++flush_calls;
if (arm_packets) {
if (!endpoint.empty()) return 0;
const auto size = fifo.size();
endpoint.swap(fifo);
return size;
}
if (!auto_drain) return 0;
const auto size = fifo.size();
host += fifo;
@@ -78,10 +102,10 @@ static void drain_all() {
static void fresh_session() {
connected = false;
arduino_usb_cdc_event_data_t event;
event.line_state.dtr = false;
Serial.callback(nullptr, nullptr, ARDUINO_USB_CDC_LINE_STATE_EVENT, &event);
meshEsp32TinyUsbCdcLineState(false); // synchronous TinyUSB owner hook
assert(!meshEsp32TinyUsbAcceptRx()); // late old RX cannot refill Arduino queue
connected = true; // fast close/reopen, without an intervening service poll
meshEsp32TinyUsbCdcLineState(true);
mesh::serviceUsbLoggingPort();
assert(mesh::takeUsbTerminalSessionReset());
assert(!mesh::takeUsbTerminalSessionReset());
@@ -113,25 +137,33 @@ static void check_logging_watchdog_transport_observations_and_recovery() {
// Purge and probe merely change queues; neither is host acknowledgement.
assert(mesh::observeUsbLoggingTransport().tx_progress == initial.tx_progress);
host += fifo; fifo.clear();
Serial.callback(nullptr, nullptr, ARDUINO_USB_CDC_TX_EVENT, nullptr);
assert(Serial.callback == nullptr); // No delayed event owns health/session state.
meshEsp32TinyUsbTxComplete();
const auto completed = mesh::observeUsbLoggingTransport();
assert(completed.tx_progress == initial.tx_progress + 1);
assert(!completed.pending);
const auto detached_at = millis();
const auto previous_detach = detach_calls, previous_attach = attach_calls;
assert(mesh::recoverUsbLoggingTransport(2) == mesh::UsbLoggingRecoveryResult::Attempted);
assert(disconnect_calls == 1 && connect_calls == 0);
assert(detach_calls == previous_detach + 1 && attach_calls == previous_attach);
assert(mesh::isUsbLoggingTransportRecoveryPending());
assert(millis() == detached_at); // No blocking wait or descriptor rebuild.
mounted = false;
meshEsp32TinyUsbDeviceSessionBoundary(false);
g_mock_millis += 49;
mesh::serviceUsbLoggingPort();
assert(connect_calls == 0);
assert(attach_calls == previous_attach);
++g_mock_millis;
mesh::serviceUsbLoggingPort();
assert(connect_calls == 1 && !mesh::observeUsbLoggingTransport().host_connected);
assert(attach_calls == previous_attach + 1 && !mesh::observeUsbLoggingTransport().host_connected);
assert(!mesh::isUsbLoggingTransportRecoveryPending());
assert(!meshEsp32TinyUsbAcceptRx()); // Await fresh owner mount, not queue admission.
mock_isr = true;
assert(mesh::recoverUsbLoggingTransport(1) == mesh::UsbLoggingRecoveryResult::Deferred);
mesh::probeUsbLoggingTransport();
mock_isr = false;
mounted = connected = true;
meshEsp32TinyUsbDeviceSessionBoundary(true);
fresh_session();
}
@@ -152,6 +184,32 @@ static void check_native_short_writes_and_mota() {
fresh_session();
}
static void check_watchdog_restarts_abandoned_mount() {
fresh_session();
const unsigned old_detaches = detach_calls, old_attaches = attach_calls;
assert(mesh::recoverUsbLoggingTransport(2) == mesh::UsbLoggingRecoveryResult::Attempted);
mounted = false;
meshEsp32TinyUsbDeviceSessionBoundary(false);
delay(50);
mesh::serviceUsbLoggingPort();
assert(detach_calls == old_detaches + 1 && attach_calls == old_attaches + 1);
assert(!meshEsp32TinyUsbAcceptRx()); // Host never sends fresh mount.
// A later watchdog stage must not get permanently stuck behind the old
// awaiting-mount quarantine, nor attach before its own full 50 ms deadline.
assert(mesh::recoverUsbLoggingTransport(2) == mesh::UsbLoggingRecoveryResult::Attempted);
assert(detach_calls == old_detaches + 2);
delay(49);
mesh::serviceUsbLoggingPort();
assert(attach_calls == old_attaches + 1);
delay(1);
mesh::serviceUsbLoggingPort();
assert(attach_calls == old_attaches + 2);
assert(!meshEsp32TinyUsbAcceptRx() && !mesh::isUsbLoggingTransportRecoveryPending());
mounted = true;
meshEsp32TinyUsbDeviceSessionBoundary(true);
fresh_session();
}
static void check_ordered_text_and_functional_reserve() {
const std::string log = "RAW: " + std::string(545, 'L') + "\r\n";
const std::string reply = " -> " + std::string(2177, 'R') + "\r\n";
@@ -189,8 +247,17 @@ static void check_stalled_host_and_visible_overflow() {
static void check_disconnect_cleanup_keeps_new_host_input() {
assert(put(mesh::usbConsolePort(), std::string(1000, 'O')) == 1000);
const auto before = clear_calls;
Serial.rx_count = 4;
fresh_session();
Serial.rx_count = 12; // old owner queued commands while output was paused
connected = false;
meshEsp32TinyUsbCdcLineState(false);
assert(Serial.rx_count == 0);
connected = true;
meshEsp32TinyUsbCdcLineState(true);
Serial.rx_count = 4; // new host sends its first query BEFORE main-loop cleanup
mesh::serviceUsbLoggingPort();
assert(mesh::takeUsbTerminalSessionReset());
assert(mesh::tryCompleteUsbTerminalSessionReset());
host.clear();
assert(clear_calls > before);
assert(!mesh::hasPendingUsbTerminalOutput());
assert(mesh::usbConsolePort().available() == 4);
@@ -202,6 +269,118 @@ static void check_disconnect_cleanup_keeps_new_host_input() {
fresh_session();
}
static void check_armed_endpoint_reenumerates_without_leaking() {
fresh_session();
arm_packets = true;
const unsigned disconnected_before = detach_calls, connected_before = attach_calls;
assert(put(mesh::usbConsolePort(), "old packet") == 10);
assert(endpoint == "old packet" && fifo.empty());
Serial.rx_count = 12;
connected = false;
meshEsp32TinyUsbCdcLineState(false);
assert(detach_calls == disconnected_before + 1 && !attached);
assert(Serial.rx_count == 0 && !meshEsp32TinyUsbAcceptRx());
assert(put(mesh::usbCompanionPort(), "racing old writer") == 0);
mesh::serviceUsbLoggingPort();
assert(attach_calls == connected_before); // real detach interval, no wait loop
delay(21);
mesh::serviceUsbLoggingPort();
assert(attach_calls == connected_before + 1 && attached);
assert(!meshEsp32TinyUsbAcceptRx()); // retain quarantine until reset/mount
endpoint.clear(); // actual bus reset cancels controller packet, not write_clear
fifo.clear();
meshEsp32TinyUsbDeviceSessionBoundary(true);
connected = true;
meshEsp32TinyUsbCdcLineState(true);
Serial.rx_count = 4;
assert(meshEsp32TinyUsbAcceptRx());
mesh::serviceUsbLoggingPort();
assert(mesh::takeUsbTerminalSessionReset());
assert(mesh::tryCompleteUsbTerminalSessionReset());
assert(mesh::usbConsolePort().available() == 4);
arm_packets = false;
drain_all();
assert(host.find("old packet") == std::string::npos);
fresh_session();
}
static void check_unmount_purges_rx_without_rearming_endpoint() {
fresh_session();
Serial.rx_count = 12;
mounted = false;
connected = false;
meshEsp32TinyUsbDeviceSessionBoundary(false);
assert(Serial.rx_count == 0);
assert(!meshEsp32TinyUsbAcceptRx());
mesh::serviceUsbLoggingPort();
mounted = true;
meshEsp32TinyUsbDeviceSessionBoundary(true);
connected = true;
meshEsp32TinyUsbCdcLineState(true);
Serial.rx_count = 4;
mesh::serviceUsbLoggingPort();
assert(mesh::takeUsbTerminalSessionReset());
assert(mesh::tryCompleteUsbTerminalSessionReset());
assert(mesh::usbConsolePort().available() == 4);
fresh_session();
}
static void check_logging_disable_retains_functional_reply() {
fresh_session();
const std::string reply(500, 'R');
assert(put(mesh::usbConsolePort(), reply) == reply.size());
mesh::setUsbLoggingEnabled(false);
assert(mesh::hasPendingUsbTerminalOutput());
assert(put(mesh::usbConsolePort(), "next reply") == 10);
drain_all();
assert(host == reply + "next reply");
mesh::setUsbLoggingEnabled(true);
fresh_session();
}
static std::atomic<bool> writer_started{false}, writer_release{false};
static std::thread* racing_writer = nullptr;
static unsigned race_attach_baseline = 0;
static void check_detach_publication_waits_for_physical_disconnect() {
fresh_session();
writer_started = false;
writer_release = false;
during_write = [] {
writer_started.store(true, std::memory_order_release);
while (!writer_release.load(std::memory_order_acquire)) std::this_thread::yield();
};
std::thread writer([] { assert(put(mesh::usbCompanionPort(), "racing old") == 10); });
racing_writer = &writer;
while (!writer_started.load(std::memory_order_acquire)) std::this_thread::yield();
race_attach_baseline = attach_calls;
during_disconnect = [] {
// The writer finishes just after the owner's failed exclusive attempt.
writer_release.store(true, std::memory_order_release);
racing_writer->join();
delay(25); // stale deadline would now allow the other core to reconnect
mesh::serviceUsbLoggingPort();
assert(attach_calls == race_attach_baseline);
};
connected = false;
meshEsp32TinyUsbCdcLineState(false);
during_disconnect = nullptr;
during_write = nullptr;
assert(!attached && !meshEsp32TinyUsbAcceptRx());
mesh::serviceUsbLoggingPort();
assert(attach_calls == race_attach_baseline);
delay(21);
mesh::serviceUsbLoggingPort();
assert(attach_calls == race_attach_baseline + 1 && fifo.empty());
meshEsp32TinyUsbDeviceSessionBoundary(true);
connected = true;
meshEsp32TinyUsbCdcLineState(true);
mesh::serviceUsbLoggingPort();
assert(mesh::takeUsbTerminalSessionReset());
assert(mesh::tryCompleteUsbTerminalSessionReset());
assert(host.find("racing old") == std::string::npos);
fresh_session();
}
static void check_debug_formatter_and_reentrancy() {
mesh::setUsbDebugEnabled(true);
auto_drain = true;
@@ -273,10 +452,11 @@ static void check_protocol_switch_cancels_text_and_overflow_marker() {
assert(put(mesh::usbConsolePort(), std::string(5000, 'X')) == 0);
assert(put(mesh::usbLoggingPort(), std::string(550, 'L')) == 550);
mesh::setUsbLoggingEnabled(false);
assert(!mesh::hasPendingUsbTerminalOutput());
assert(mesh::hasPendingUsbTerminalOutput()); // no protocol switch yet
// A protocol owner may discard application text without resetting USB. The
// already accepted FIFO prefix stays ordered before the new binary frame.
mesh::discardUsbTerminalOutput();
assert(!mesh::hasPendingUsbTerminalOutput());
host += fifo;
fifo.clear();
assert(put(mesh::usbCompanionPort(), "binary") == 6);
@@ -297,17 +477,24 @@ int main() {
#if MESH_ESP32_TINYUSB_NONBLOCKING
mesh::beginUsbLoggingPort();
mesh::beginUsbLoggingPort();
meshEsp32TinyUsbDeviceSessionBoundary(true);
meshEsp32TinyUsbCdcLineState(true);
mesh::setUsbLoggingEnabled(true);
assert(Serial.registrations == 1);
assert(Serial.registrations == 0); // delayed Arduino events cannot purge new RX
assert(!Serial.debug_enabled);
mesh::serviceUsbLoggingPort();
assert(&mesh::usbConsolePort() == &mesh::usbTerminalPort());
check_logging_watchdog_transport_observations_and_recovery();
check_watchdog_restarts_abandoned_mount();
check_independent_debug_gate();
check_native_short_writes_and_mota();
check_ordered_text_and_functional_reserve();
check_stalled_host_and_visible_overflow();
check_disconnect_cleanup_keeps_new_host_input();
check_armed_endpoint_reenumerates_without_leaking();
check_unmount_purges_rx_without_rearming_endpoint();
check_logging_disable_retains_functional_reply();
check_detach_publication_waits_for_physical_disconnect();
check_debug_formatter_and_reentrancy();
check_cached_logging_gate_and_isr();
check_protocol_switch_cancels_text_and_overflow_marker();
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <cstdint>
struct MockNrfUsbd { uint32_t USBPULLUP = 1; };
extern MockNrfUsbd mock_usbd;
#define NRF_USBD (&mock_usbd)
inline void __ISB() {}
inline void __DSB() {}
+8 -3
View File
@@ -1,5 +1,6 @@
#include <Arduino.h>
#include <Adafruit_TinyUSB.h>
#include <nrf.h>
#include <helpers/UsbLogging.h>
#include <helpers/UsbLoggingClientActivity.h>
#include <cassert>
@@ -9,6 +10,7 @@
MockSerial Serial;
MockUsbDevice TinyUSBDevice;
MockNrfUsbd mock_usbd;
static bool mounted = true, dtr = true, dfu = false;
static bool attached = true, armed_in = false;
static unsigned detaches = 0, attaches = 0;
@@ -22,8 +24,8 @@ extern "C" void tud_umount_cb();
bool tud_mounted() { return mounted; }
bool tud_connect() { mounted = true; return true; }
bool tud_disconnect() { mounted = false; dtr = false; return true; }
void MockUsbDevice::detach() { attached = false; ++detaches; }
void MockUsbDevice::attach() { attached = true; ++attaches; }
void MockUsbDevice::detach() { attached = false; mock_usbd.USBPULLUP = 0; ++detaches; }
void MockUsbDevice::attach() { attached = true; mock_usbd.USBPULLUP = 1; ++attaches; }
extern "C" bool mesh_tud_cdc_n_tx_pending(uint8_t n) {
assert(n == 0);
return armed_in;
@@ -141,8 +143,11 @@ int main() {
armed_in = true;
dtr = false;
tud_cdc_line_state_cb(0, false, false);
assert(fifo.empty() && armed_in && !attached && detaches == 1);
assert(fifo.empty() && armed_in && mock_usbd.USBPULLUP == 0);
assert(attached && detaches == 0); // Owner callback only quarantines pull-up.
assert(mesh::isUsbLoggingTransportRecoveryPending());
mesh::serviceUsbLoggingPort(); // Actual stack detach belongs to application loop.
assert(!attached && detaches == 1);
assert(mesh::recoverUsbLoggingTransport(2) == mesh::UsbLoggingRecoveryResult::Attempted);
const auto detached_at = g_mock_millis;
g_mock_millis += 20;
+2
View File
@@ -48,8 +48,10 @@ extern MockFicr mock_ficr;
#define WB_IO2 2
#define OUTPUT 1
#define HIGH 1
#define INPUT_PULLUP 2
inline void pinMode(int, int) {}
inline void digitalWrite(int, int) {}
inline int digitalRead(int) { return HIGH; }
inline void vTaskDelete(void*) {}
inline void vTaskDelay(uint32_t value) { delay(value); }
inline uint32_t pdMS_TO_TICKS(uint32_t value) { return value; }
@@ -0,0 +1,29 @@
#pragma once
#include <Arduino.h>
#define ETH_MISO_PIN 1
#define ETH_MOSI_PIN 2
#define ETH_SCLK_PIN 3
#define ETH_CS_PIN 4
#define ETH_INT_PIN 5
struct ch390_config_t {
int spi_miso_gpio = 0;
int spi_mosi_gpio = 0;
int spi_sck_gpio = 0;
int spi_cs_gpio = 0;
int int_gpio = 0;
};
#define CH390_DEFAULT_CONFIG() ch390_config_t{}
class MockCh390 {
public:
bool valid_hostname = false;
unsigned begins = 0;
bool setHostname(const char*) const { return valid_hostname; }
bool begin(const ch390_config_t&) { ++begins; return true; }
};
extern MockCh390 CH390;
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <Arduino.h>
#include <array>
class TwoWire {
public:
bool present = true;
bool all_addresses_present = false;
unsigned probes = 0;
unsigned fail_probe_count = 0;
std::array<uint8_t, 5> frame{{0, 1, 2, 3, 4}};
uint8_t reply_length = 5;
uint8_t current_address = 0;
unsigned cursor = 0;
void beginTransmission(uint8_t address) { current_address = address; ++probes; }
uint8_t endTransmission() const {
if (probes <= fail_probe_count) return 2;
return (all_addresses_present || (present && current_address == 0x2E)) ? 0 : 2;
}
uint8_t requestFrom(uint8_t, uint8_t) { cursor = 0; return reply_length; }
int available() const { return static_cast<int>(reply_length) - static_cast<int>(cursor); }
int read() { return cursor < reply_length ? frame[cursor++] : -1; }
};
extern TwoWire Wire;
+95
View File
@@ -1,6 +1,8 @@
#include <helpers/NonBlockingWriteStream.h>
#include <helpers/nrf52/EthernetCLI.h>
#include "NetworkLoggingMacro.h"
#include <helpers/ui/CHSC6XTouch.h>
#include <helpers/ethernet/ch390/CH390Config.h>
#include <iostream>
@@ -8,6 +10,8 @@ uint32_t mock_millis = 0;
RawSerial Serial;
MockFicr mock_ficr;
MockEthernet Ethernet;
TwoWire Wire;
MockCh390 CH390;
std::string ethernet_output;
std::string ethernet_input;
bool accept_client = false;
@@ -153,5 +157,96 @@ int main() {
assert(evaluated == 0 && usb.output.empty() && usb.tries == 0);
#endif
assert(mock_millis == 0);
// Touch probe, late-presence and optional raw frames use the same runtime
// logging gate as every other diagnostic, including protected boot mode.
reset();
logging_enabled = false;
Wire = TwoWire();
CHSC6XTouch found;
assert(found.begin());
Wire.frame[0] = 0x1F;
assert(!found.checkTap(500));
assert(found.checkTap(600));
assert(usb.output.empty() && usb.tries == 0);
Wire = TwoWire();
Wire.present = false;
CHSC6XTouch late;
assert(!late.begin());
assert(Wire.probes == 1); // No diagnostic I2C scan when logging is off.
Wire.present = true;
Wire.frame[0] = 0x1F;
assert(!late.checkTap(500));
assert(late.checkTap(600));
Wire.reply_length = 2;
late.checkTap(700);
assert(usb.output.empty() && usb.tries == 0);
reset();
debug_enabled = false;
Wire = TwoWire();
CHSC6XTouch packet_only;
assert(packet_only.begin());
Wire.frame[0] = 0x1F;
assert(!packet_only.checkTap(500));
assert(packet_only.checkTap(600));
assert(logging_enabled && usb.output.empty() && usb.tries == 0);
Wire = TwoWire();
Wire.present = false;
CHSC6XTouch quiet_scan;
assert(!quiet_scan.begin() && Wire.probes == 1);
reset();
Wire = TwoWire();
CHSC6XTouch visible;
assert(visible.begin());
assert(usb.output == "Touch: CHSC6X found at 0x2E\n");
visible.checkTap(500);
#if defined(DISPLAY_TOUCH_DEBUG)
assert(usb.output.find("Touch: raw 00 01 02 03 04") != std::string::npos);
#endif
usb.output.clear();
usb.capacity = 0;
CHSC6XTouch stalled;
assert(stalled.begin());
stalled.checkTap(500);
assert(usb.output.empty());
reset();
Wire = TwoWire();
Wire.present = false;
Wire.all_addresses_present = false;
CHSC6XTouch absent;
assert(!absent.begin());
assert(usb.output.find("Touch: idle or absent at 0x2E; I2C bus currently answers:\n") == 0);
assert(Wire.probes == 113);
reset();
usb.capacity = 640;
Wire = TwoWire();
Wire.all_addresses_present = true;
Wire.fail_probe_count = 1; // Idle boot probe, then every scan address replies.
CHSC6XTouch full_scan;
assert(!full_scan.begin());
assert(usb.output.size() > 600 && usb.output.size() < 640 && usb.tries == 1);
assert(usb.output.substr(usb.output.size() - 6) == " 0x77\n");
reset();
CH390 = MockCh390();
logging_enabled = false;
assert(beginConfiguredCH390("invalid hostname"));
assert(CH390.begins == 1 && usb.output.empty() && usb.tries == 0);
logging_enabled = true;
debug_enabled = false;
assert(beginConfiguredCH390("invalid hostname"));
assert(usb.output.empty() && usb.tries == 0);
debug_enabled = true;
assert(beginConfiguredCH390("invalid hostname"));
assert(usb.output == "Network: invalid Ethernet hostname invalid hostname\n");
usb.output.clear();
usb.capacity = 0;
assert(beginConfiguredCH390("invalid hostname"));
assert(usb.output.empty());
std::cout << "PASS: gated USB diagnostics\n";
}