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";
}
+9 -1
View File
@@ -19,6 +19,7 @@ HARNESS = r'''
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
#include <helpers/AtomicFileWriter.h>
#include <helpers/PrefsSaveRouting.h>
#include <helpers/GpsPowerPolicy.h>
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define MIN_LORA_TX_POWER -9
#define MAX_LORA_TX_POWER 22
@@ -57,6 +58,7 @@ struct CommonCLI {
prefs.freq=910; prefs.bw=62.5f; prefs.sf=7; prefs.cr=5;
prefs.tx_power_dbm=30; prefs.primary_radio_preamble=48;
prefs.espnow_bridge_enabled=1;
prefs.gps_sync_interval_hours=336;
prefs.rx_ps_rx_us=111; prefs.rx_ps_sleep_us=222;
}
void savePrefs(FILESYSTEM*,PrefsSaveRouting::Scope);
@@ -84,9 +86,11 @@ int main() {
auto original=cli.fs.files["/com_prefs"];
// Appending preferences must not move the established preamble bytes.
const size_t preamble_offset=866;
assert(original.size() >= preamble_offset+2);
assert(original.size()==873);
assert(original[preamble_offset]==48 && original[preamble_offset+1]==0);
assert(original[preamble_offset+2]==1);
assert(original[preamble_offset+3]==0x50 && original[preamble_offset+4]==0x01);
assert(original[871]==0 && original[872]==0); // debug and trace remain appended
Capture capture; assert(writeCommonPrefsImage(capture,&cli.prefs));
assert(capture.bytes==original); // Both serializers retain the same layout.
for (int fault : {0,1,2,3,4}) {
@@ -114,6 +118,8 @@ int main() {
assert(freq==920 && bw==125 && committed[112]==9 && committed[113]==6);
assert(committed[preamble_offset]==96 && committed[preamble_offset+1]==0);
assert(committed[preamble_offset+2]==1);
assert(committed[preamble_offset+3]==0x50 && committed[preamble_offset+4]==0x01);
assert(committed[871]==0 && committed[872]==0);
assert(cli._radio_profiles.saved==96 && cli.prefs.tx_power_dbm==22);
assert(cli.prefs.rx_ps_rx_us==900 && cli.prefs.rx_ps_sleep_us==1200);
// Old and torn tails preserve the imported legacy preamble; valid tails win.
@@ -123,6 +129,8 @@ int main() {
assert(cli.loadTail({0,7,0})==64);
assert(cli.loadTail({0,0,0})==0);
assert(cli.loadTail({0,64,0,0})==64 && cli.prefs.espnow_bridge_enabled==0);
assert(cli.loadTail({0,64,0,1,48,0})==64 && cli.prefs.gps_sync_interval_hours==48);
assert(cli.loadTail({0,64,0,1,0xff})==64 && cli.prefs.gps_sync_interval_hours==48);
for(float bad : {NAN,INFINITY,149.0f,2501.0f})
assert(!cli.savePrimaryRadioParams(bad,125,9,6,0));
assert(cli.fs.files["/com_prefs"]==committed);
+11 -5
View File
@@ -35,6 +35,7 @@ class TestFS : public MemoryFS {
#include <helpers/MQTTPrefsAtomicStore.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/RepeaterRadioTiming.h>
#include <helpers/GpsPowerPolicy.h>
#include <helpers/bridges/ESPNowBridgeFormat.h>
#include <helpers/radiolib/LR2021SideDetectorConfig.h>
#define MIN_LORA_TX_POWER -9
@@ -157,13 +158,14 @@ int main() {
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(cli._common_save_succeeded);
const auto baseline = cli.fs.files["/com_prefs"];
assert(baseline.size() == 871);
const size_t debug_offset = baseline.size() - 2;
const size_t logging_offset = baseline.size() - 8;
const size_t preamble_offset = baseline.size() - 5;
const size_t espnow_offset = baseline.size() - 3;
assert(baseline.size() == 873);
const size_t debug_offset = 871, trace_offset = 872;
const size_t logging_offset = 863, preamble_offset = 866;
const size_t espnow_offset = 868, gps_offset = 869;
assert(baseline[debug_offset] == 0 && baseline[logging_offset] == 1);
assert(baseline[preamble_offset] == 48 && baseline[espnow_offset] == 1);
assert(baseline[gps_offset] == 0 && baseline[gps_offset + 1] == 0);
assert(baseline[trace_offset] == 0);
Capture capture;
assert(writeCommonPrefsImage(capture, &cli.prefs));
assert(capture.bytes == baseline); // Checked and ordinary writers agree.
@@ -253,6 +255,7 @@ int main() {
// after loading debug ON; a missing later trace byte cannot erase debug.
auto image = baseline;
image[logging_offset] = 0; image[debug_offset] = 1;
image[gps_offset] = 0x50; image[gps_offset + 1] = 0x01;
for (size_t size = 0; size <= debug_offset; ++size) {
CommonCLI reader;
reader.prefs.usb_debug_enabled = 1;
@@ -260,6 +263,8 @@ int main() {
reader.loadPrefsInt(&reader.fs, "/com_prefs");
assert(reader.prefs.usb_debug_enabled == 0);
if (size > logging_offset) assert(reader.prefs.usb_logging_enabled == 0);
if (size >= gps_offset + 2) assert(reader.prefs.gps_sync_interval_hours == 336);
assert(reader.prefs.trace_when_repeat_off == 0);
}
for (uint8_t saved : {0, 1, 2, 255}) {
image[debug_offset] = saved;
@@ -269,6 +274,7 @@ int main() {
assert(cli.prefs.usb_debug_enabled == (saved == 1 ? 1 : 0));
assert(cli.prefs.usb_logging_enabled == 0);
assert(cli.prefs.primary_radio_preamble == 48 && cli.prefs.espnow_bridge_enabled == 1);
assert(cli.prefs.gps_sync_interval_hours == 336);
}
cli.prefs.usb_debug_enabled = 1;
cli.loadPrefsInt(&cli.fs, "/missing");
+8 -8
View File
@@ -32,13 +32,14 @@ int main(){
assert(mesh.savePrefs());
const auto on_disk=mesh.store.fs.files["/new_prefs"];
#if defined(TBEAM_1W)
assert(on_disk.size()==240);
assert(on_disk.size()==242);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(on_disk.size()==330);
assert(on_disk.size()==332);
#else
assert(on_disk.size()==233);
assert(on_disk.size()==235);
#endif
assert(on_disk[on_disk.size()-2]==1&&on_disk.back()==0);
const size_t bluetooth_offset=on_disk.size()-4;
assert(on_disk[bluetooth_offset]==1&&on_disk.back()==0);
// Both choices survive a fresh instance reading the actual on-device image.
for(bool enabled : {false,true,false}){
assert(mesh.setBluetoothEnabledPreference(enabled));
@@ -50,7 +51,7 @@ int main(){
assert(prefs.bluetooth_stealth_mode==2&&prefs.one_key_dm_enabled==1);
assert(prefs.wifi_enabled==0&&prefs.usb_logging_enabled==1);
const auto& saved=reboot.fs.files["/new_prefs"];
assert(saved[saved.size()-2]==enabled&&saved.back()==0);
assert(saved[bluetooth_offset]==enabled&&saved.back()==0);
}
const auto off_disk=mesh.store.fs.files["/new_prefs"];
// The public setter must roll live state back when the atomic save fails.
@@ -70,7 +71,7 @@ int main(){
assert(mesh.isBluetoothEnabledPreference());
mesh.store.fs.fail_write=false;
// Legacy images cannot accidentally inherit an off value from the caller.
for(unsigned size : {84u,156u,215u,226u,unsigned(on_disk.size()-2)}){
for(unsigned size : {84u,156u,215u,226u,unsigned(bluetooth_offset)}){
DataStore legacy;legacy.fs.files["/new_prefs"]=on_disk;
legacy.fs.files["/new_prefs"].resize(size);
CompanionNodePrefs prefs;prefs.bluetooth_enabled=0;
@@ -81,8 +82,7 @@ int main(){
// Invalid new encoding rejects the transaction rather than partially loading.
for(uint8_t corrupt : {2,255}){
DataStore damaged;damaged.fs.files["/new_prefs"]=off_disk;
auto& damaged_disk=damaged.fs.files["/new_prefs"];
damaged_disk[damaged_disk.size()-2]=corrupt;
damaged.fs.files["/new_prefs"][bluetooth_offset]=corrupt;
CompanionNodePrefs prefs;prefs.bluetooth_enabled=1;
strcpy(prefs.node_name,"unchanged");double lat=1,lon=2;
assert(!damaged.loadPrefsInt("/new_prefs",prefs,lat,lon));
+38 -4
View File
@@ -1,5 +1,6 @@
"""Exercise real Companion preferences saves and startup recovery with I/O faults."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
@@ -54,6 +55,8 @@ int main(int argc,char** argv){
original.flood_retry_attempts=4;original.flood_retry_max_path=3;
original.flood_retry_group_max_path=2;original.flood_retry_advert_enabled=0;
original.one_key_dm_enabled=1;
original.gps_sync_interval_hours=336;
original.usb_debug_enabled=1;
#ifdef TBEAM_1W
strcpy(original.fan_mode,"on");original.fan_lo=33;original.fan_hi=42;
#endif
@@ -90,6 +93,8 @@ int main(int argc,char** argv){
assert(loaded.flood_retry_attempts==4&&loaded.flood_retry_max_path==3);
assert(loaded.flood_retry_group_max_path==2&&loaded.flood_retry_advert_enabled==0);
assert(loaded.one_key_dm_enabled==1);
assert(loaded.gps_sync_interval_hours==336);
assert(loaded.usb_debug_enabled==1);
#ifdef TBEAM_1W
assert(!strcmp(loaded.fan_mode,"on")&&loaded.fan_lo==33&&loaded.fan_hi==42);
#endif
@@ -194,11 +199,38 @@ int main(int argc,char** argv){
} else if(scenario==7){
// Pre-consent image: all earlier settings survive with default-off consent.
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
legacy.fs.files["/new_prefs"].resize(disk.size()-3);
legacy.fs.files["/new_prefs"].resize(disk.size()-5);
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.flood_retry_advert_enabled==0);
assert(loaded.one_key_dm_enabled==0);
} else if(scenario==9){
// The previous complete image loads with the board policy even when the
// receiving prefs object previously held an explicit configured interval.
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
legacy.fs.files["/new_prefs"].resize(disk.size()-3);
CompanionNodePrefs loaded;loaded.gps_sync_interval_hours=24;loaded.usb_debug_enabled=1;
double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.gps_sync_interval_hours==0 && loaded.one_key_dm_enabled==1);
assert(loaded.usb_debug_enabled==0);
// Half of the two-byte append is never accepted as a complete image.
DataStore torn;torn.fs.files["/new_prefs"]=disk;
torn.fs.files["/new_prefs"].resize(disk.size()-2);
assert(!torn.loadPrefsInt("/new_prefs",loaded,lat,lon));
// The complete published GPS image is accepted without borrowing either
// GPS byte as the new debug preference.
DataStore gps_image;gps_image.fs.files["/new_prefs"]=disk;
gps_image.fs.files["/new_prefs"].resize(disk.size()-1);
loaded.usb_debug_enabled=1;
assert(gps_image.loadPrefsInt("/new_prefs",loaded,lat,lon));
assert(loaded.gps_sync_interval_hours==336&&loaded.usb_debug_enabled==0);
// The compact known-size table must never narrow the actual file length.
// A large unknown image sharing a recognized low 16 bits remains invalid.
DataStore oversized;oversized.fs.files["/new_prefs"]=disk;
oversized.fs.files["/new_prefs"].resize(disk.size()+65536);
assert(!oversized.loadPrefsInt("/new_prefs",loaded,lat,lon));
assert(loaded.gps_sync_interval_hours==336&&loaded.usb_debug_enabled==0);
} else if(scenario==8){
for(uint8_t enabled : {0,1}){
CompanionNodePrefs saved=original;saved.wifi_enabled=enabled;
@@ -288,10 +320,12 @@ inline char* utoa(unsigned int value,char* output,int base){
'ESP32_PLATFORM,TBEAM_1W', 'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE'):
with self.subTest(platform=platform):
binary = work / 'test'
sanitizer_flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
build = subprocess.run(['g++', '-std=c++17',
*['-D'+flag+'=1' for flag in platform.split(',')],
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie', '-include', str(work / 'platform_shim.h'),
*sanitizer_flags, '-include', str(work / 'platform_shim.h'),
'-I', str(work),
'-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'),
'-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
@@ -303,7 +337,7 @@ inline char* utoa(unsigned int value,char* output,int base){
str(ROOT / 'src/helpers/TxtDataHelpers.cpp'),
'-o', str(binary)], capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
for scenario in range(9):
for scenario in range(10):
with self.subTest(scenario=scenario):
run = subprocess.run([str(binary), str(scenario)], capture_output=True, text=True)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
+190
View File
@@ -0,0 +1,190 @@
"""Verify the compact Companion tail preserves bytes and atomic failure handling."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_companion_preferences_transaction import HARNESS, esp_recovery_helpers
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
LEGACY_TAIL = r'''
success = success && file.write((uint8_t *)&_prefs.flood_retry_attempts, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.flood_retry_max_path, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.flood_retry_group_max_path, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.flood_retry_advert_enabled, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.one_key_dm_enabled, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.bluetooth_enabled, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.gps_sync_interval_hours, 2) == 2;
success = success && file.write((uint8_t *)&_prefs.usb_debug_enabled, 1) == 1;
'''
TESTS = r'''
int main() {
CompanionNodePrefs original;
strcpy(original.node_name, "old complete image");
original.freq=915.125f; original.ble_pin=456789;
original.flood_retry_attempts=4; original.flood_retry_max_path=3;
original.flood_retry_group_max_path=2; original.flood_retry_advert_enabled=1;
original.one_key_dm_enabled=1; original.bluetooth_enabled=0;
original.gps_sync_interval_hours=336; original.usb_debug_enabled=1;
#ifdef TBEAM_1W
strcpy(original.fan_mode,"on"); original.fan_lo=32; original.fan_hi=45;
#endif
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
strcpy(original.wifi_ssid,"tail-test"); strcpy(original.wifi_pwd,"same bytes");
#endif
// Compare every byte against the previous scalar serializer, including
// arbitrary byte values that a raw caller might pass before CLI validation.
for (unsigned value=0; value<256; ++value) {
CompanionNodePrefs prefs=original;
prefs.flood_retry_attempts=uint8_t(value);
prefs.flood_retry_max_path=uint8_t(value+17);
prefs.flood_retry_group_max_path=uint8_t(value+37);
prefs.flood_retry_advert_enabled=uint8_t(value+53);
prefs.one_key_dm_enabled=uint8_t(value+71);
prefs.bluetooth_enabled=uint8_t(value+89);
prefs.gps_sync_interval_hours=uint16_t(value*257);
prefs.usb_debug_enabled=uint8_t(value+103);
DataStore compact, legacy;
assert(compact.savePrefs(prefs,47.1,-122.2));
assert(legacy.savePrefsLegacy(prefs,47.1,-122.2));
const auto& image=compact.fs.files["/new_prefs"];
assert(image==legacy.fs.files["/new_prefs"]);
#if defined(TBEAM_1W)
assert(image.size()==242);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(image.size()==332);
#else
assert(image.size()==235);
#endif
const uint8_t expected[]={prefs.flood_retry_attempts,prefs.flood_retry_max_path,
prefs.flood_retry_group_max_path,prefs.flood_retry_advert_enabled,
prefs.one_key_dm_enabled,prefs.bluetooth_enabled,0,0,prefs.usb_debug_enabled};
assert(!memcmp(image.data()+image.size()-9,expected,6));
assert(!memcmp(image.data()+image.size()-3,&prefs.gps_sync_interval_hours,2));
assert(image.back()==prefs.usb_debug_enabled);
}
DataStore initial; assert(initial.savePrefs(original,47.1,-122.2));
const auto disk=initial.fs.files["/new_prefs"];
CompanionNodePrefs changed=original;
strcpy(changed.node_name,"new complete image"); changed.freq=910.5f;
changed.flood_retry_attempts=7; changed.bluetooth_enabled=1;
changed.gps_sync_interval_hours=24; changed.usb_debug_enabled=0;
auto expectOld=[&](DataStore& store) {
assert(store.fs.files["/new_prefs"]==disk);
assert(!store.fs.exists("/new_prefs.tmp"));
DataStore reboot; reboot.fs.files["/new_prefs"]=disk;
CompanionNodePrefs loaded; double lat=0,lon=0;
assert(reboot.loadPrefs(loaded,lat,lon));
assert(!strcmp(loaded.node_name,original.node_name));
assert(loaded.freq==original.freq && loaded.ble_pin==original.ble_pin);
assert(loaded.flood_retry_attempts==4 && loaded.bluetooth_enabled==0);
assert(loaded.gps_sync_interval_hours==336 && loaded.usb_debug_enabled==1);
assert(lat==47.1 && lon==-122.2);
};
// A short return at each boundary of the single final write must abort the
// transaction, including either byte of the two-byte GPS field.
for (unsigned boundary=0; boundary<9; ++boundary) {
DataStore store; store.fs.files["/new_prefs"]=disk;
store.fs.fail_write_after=int(disk.size()-9+boundary);
assert(!store.savePrefs(changed,42.3,-121.2)); expectOld(store);
store.fs.fail_write_after=-1;
assert(store.savePrefs(changed,42.3,-121.2));
assert(store.fs.files["/new_prefs"]!=disk);
}
// Fully written bytes still cannot replace the live image until readback
// verifies every final-tail boundary. LittleFS's retries also must fail.
for (unsigned boundary=0; boundary<9; ++boundary) {
DataStore store; store.fs.files["/new_prefs"]=disk;
store.fs.fail_read_after=int(disk.size()-9+boundary);
assert(!store.savePrefs(changed,42.3,-121.2)); expectOld(store);
}
{
DataStore store; store.fs.files["/new_prefs"]=disk;
store.fs.fail_read_open=true;
assert(!store.savePrefs(changed,42.3,-121.2)); expectOld(store);
}
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
const unsigned rename_steps=1;
#else
const unsigned rename_steps=2;
#endif
for (unsigned step=1; step<=rename_steps; ++step) {
DataStore store; store.fs.files["/new_prefs"]=disk;
store.fs.fail_rename=int(step);
assert(!store.savePrefs(changed,42.3,-121.2)); expectOld(store);
}
assert(initial.savePrefs(changed,42.3,-121.2));
CompanionNodePrefs loaded; double lat=0,lon=0;
assert(initial.loadPrefs(loaded,lat,lon));
assert(!strcmp(loaded.node_name,changed.node_name));
assert(loaded.flood_retry_attempts==7 && loaded.bluetooth_enabled==1);
assert(loaded.gps_sync_interval_hours==24 && loaded.usb_debug_enabled==0);
assert(lat==42.3 && lon==-121.2);
}
'''
class CompanionPrefsTailTests(unittest.TestCase):
def test_full_image_parity_and_atomic_tail_faults(self):
source = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
save = method(source, 'bool DataStore::savePrefs(')
tail_start = save.index(' // Pack only the explicit persisted tail')
tail_end = save.index('\n#if defined(NRF52_PLATFORM)', tail_start)
legacy_save = save[:tail_start] + LEGACY_TAIL + save[tail_end:]
legacy_save = legacy_save.replace('DataStore::savePrefs(', 'DataStore::savePrefsLegacy(', 1)
methods = esp_recovery_helpers(source) + '\n'.join(method(source, signature) for signature in (
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt('))
methods += '\n' + save + '\n' + legacy_save
code = HARNESS.split('int main(')[0].replace('@METHODS@', methods)
code = code.replace(' bool savePrefs(const CompanionNodePrefs&, double, double);',
' bool savePrefs(const CompanionNodePrefs&, double, double);\n'
' bool savePrefsLegacy(const CompanionNodePrefs&, double, double);')
with tempfile.TemporaryDirectory(prefix='meshcore-prefs-tail-') as directory:
work = Path(directory)
(work / 'Utils.h').write_text('''#pragma once
#include <Arduino.h>
#include <cassert>
namespace mesh { struct Utils {
static void printHex(Stream&,const uint8_t*,size_t){assert(false);}
static void fromHex(uint8_t*,size_t,const char*){assert(false);}
}; }
''')
(work / 'platform_shim.h').write_text('''#include <cstdlib>
#include <cstdio>
inline char* utoa(unsigned int value,char* output,int base){
if(base!=10)abort();sprintf(output,"%u",value);return output;
}
''')
transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text()
(work / 'ContactFileTransaction.h').write_text(transaction.replace(
'#include "IdentityStore.h"', '#include <helpers/IdentityStore.h>'))
cpp = work / 'test.cpp'
cpp.write_text(code + TESTS)
for platform in ('STM32_PLATFORM', 'NRF52_PLATFORM', 'ESP32_PLATFORM', 'RP2040_PLATFORM',
'ESP32_PLATFORM,TBEAM_1W', 'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE'):
with self.subTest(platform=platform):
binary = work / 'test'
sanitizers = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
*['-D' + flag + '=1' for flag in platform.split(',')],
*sanitizers, '-include', str(work / 'platform_shim.h'),
'-I', str(work), '-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'),
'-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
'-I', str(ROOT / 'src/helpers'), '-I', str(ROOT), str(cpp),
*[str(ROOT / 'src/helpers' / name) for name in (
'ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp',
'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')],
'-o', str(binary)], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
if __name__ == '__main__':
unittest.main()
@@ -44,6 +44,7 @@ struct Prefs {
uint8_t manual_add_contacts=0,telemetry_mode_base=0,telemetry_mode_loc=0;
uint8_t telemetry_mode_env=0,advert_loc_policy=0,multi_acks=0;
uint8_t path_hash_mode=0,client_repeat=0,autoadd_config=0,autoadd_max_hops=2,gps_enabled=0;
uint16_t gps_sync_interval_hours=0;
uint8_t rx_boosted_gain=0,powersaving_enabled=0;
int8_t tx_power_dbm=3;
};
@@ -52,7 +53,9 @@ struct Sensors {
std::string gps="0",interval="60",other="old";
bool accepts=true,gps_available=true;
bool powersaving_enabled=false;
uint16_t gps_sync_hours=0;
void setPowerSavingEnabled(bool enabled) { powersaving_enabled=enabled; }
void applyGpsTimeSyncInterval(uint16_t hours) { gps_sync_hours=hours; }
const char* getSettingByKey(const char* key) {
if(!strcmp(key,"gps") && !gps_available)return nullptr;
return !strcmp(key,"gps") ? gps.c_str() : interval.c_str();
+3
View File
@@ -52,6 +52,8 @@ struct Board { bool isUsbHostConnected() const { return data_connected; } } boar
static bool usb_mota_mode = false, usb_logging_terminal_mode = false;
static bool usb_logging_network_parked = false;
static bool usb_protocol_initialized = true;
static bool usb_logging_reply_hold = false, usb_logging_reply_staged = false;
static bool usb_logging_reply_pending = false, usb_logging_reply_state = false;
static bool usb_host_session_connected = false, usb_terminal_discard_line = true;
static bool usb_terminal_host_reset_completion_pending = false;
static uint32_t usb_terminal_host_reset_retry_at = 0;
@@ -293,6 +295,7 @@ class CompanionUsbDefaultTest(unittest.TestCase):
loop = function("\nvoid loop() {")
self.assertLess(loop.index("serviceUsbTerminalHostSessionReset();"), loop.index("serviceUsbAsciiSessionDefault();"))
self.assertLess(loop.index("serviceUsbAsciiSessionDefault();"), loop.index("the_mesh.loop();"))
self.assertLess(loop.index("serviceUsbLoggingReplyBarrier();"), loop.index("the_mesh.loop();"))
self.assertLess(loop.index("serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());"), loop.index("the_mesh.loop();"))
self.assertLess(loop.index("expireUsbBinaryStartupProbeBeforeDispatch();"), loop.index("the_mesh.loop();"))
self.assertNotIn("COMPANION_RADIO_FULL", MAIN)
+8 -5
View File
@@ -51,17 +51,19 @@ int main(){
strcpy(node._prefs.node_name,"durable node");node._prefs.freq=910.525f;
node._prefs.ble_pin=876543;node._prefs.bluetooth_enabled=0;
node._prefs.one_key_dm_enabled=1;node._prefs.usb_logging_enabled=1;
node._prefs.gps_sync_interval_hours=257;
assert(node.savePrefs());
const auto original=node.store.fs.files["/new_prefs"];
#if defined(TBEAM_1W)
assert(original.size()==240);
assert(original.size()==242);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(original.size()==330);
assert(original.size()==332);
#else
assert(original.size()==233);
assert(original.size()==235);
#endif
assert(original.back()==0&&original[original.size()-2]==0);
assert(original[original.size()-3]==1&&original[158]==1);
assert(original.back()==0&&original[original.size()-4]==0);
assert(original[original.size()-3]==1&&original[original.size()-2]==1);
assert(original[original.size()-5]==1&&original[158]==1);
char reply[160]={};
for(const char* malformed : {"set usb.debug","set usb.debug ",
"set usb.debug 1","set usb.debug ON","set usb.debug on ",
@@ -114,6 +116,7 @@ int main(){
double lat=0,lon=0;assert(reboot.loadPrefs(loaded,lat,lon));
assert(loaded.usb_debug_enabled==enabled&&loaded.bluetooth_enabled==0);
assert(loaded.one_key_dm_enabled==1&&loaded.usb_logging_enabled==1);
assert(loaded.gps_sync_interval_hours==257);
assert(loaded.ble_pin==876543&&loaded.freq==910.525f);
}
// Existing complete tails retain their established offsets and always start
+265
View File
@@ -0,0 +1,265 @@
"""Exercise logging handoffs with real Companion USB parsing and routing."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "helpers/ArduinoSerialInterface.h"
#include "helpers/MultiSerialInterface.h"
#include "helpers/UsbAsciiBinarySwitch.h"
#include "helpers/CLICommandUtils.h"
#include "helpers/CompanionHardwareCommandCompat.h"
#define MESH_USB_LOGGING_AVAILABLE 1
#define COMPANION_FEATURE_USB_MOTA_SOURCE 0
#define COMPANION_FEATURE_NETWORK_TERMINAL 1
static bool connected=true, dedicated=false, logging=false, network=false;
static bool usb_logging_network_parked=false, usb_logging_terminal_mode=false;
static bool usb_protocol_initialized=true, usb_terminal_discard_line=false;
static bool usb_logging_reply_hold=false, usb_logging_reply_staged=false;
static bool usb_logging_reply_pending=false, usb_logging_reply_state=false;
static mesh::UsbBinaryStartupProbe usb_binary_startup_probe;
static mesh::UsbAsciiSessionDefault usb_ascii_session_default;
struct UsbStream : Stream {
std::vector<uint8_t> rx, tx;
size_t cursor=0;
int capacity=4096, max_write=4096;
int available() override { return static_cast<int>(rx.size()-cursor); }
int read() override { return cursor<rx.size()?rx[cursor++]:-1; }
int peek() override { return cursor<rx.size()?rx[cursor]:-1; }
int availableForWrite() override { return capacity; }
size_t write(const uint8_t* p,size_t n) override {
if(n>static_cast<size_t>(capacity)) n=capacity;
if(n>static_cast<size_t>(max_write)) n=max_write;
tx.insert(tx.end(),p,p+n);
return n;
}
void receive(const char* command) {
size_t n=strlen(command)+1;
rx={'<',static_cast<uint8_t>(n),0,mesh::companion::kRunCliCommand};
rx.insert(rx.end(),command,command+n-1);
cursor=0;
}
} stream;
struct Remote : BaseSerialInterface {
bool enabled=false;
std::vector<uint8_t> rx, tx;
void enable() override { enabled=true; }
void disable() override { enabled=false; }
bool isEnabled() const override { return enabled; }
bool isConnected() const override { return true; }
bool isReadBusy() const override { return false; }
bool isWriteBusy() const override { return false; }
size_t writeFrame(const uint8_t* p,size_t n) override {
tx.insert(tx.end(),p,p+n); return n;
}
size_t checkRecvFrame(uint8_t* out) override {
const size_t n=rx.size();
if(n) memcpy(out,rx.data(),n);
rx.clear(); return n;
}
} ble;
ArduinoSerialInterface usb_serial_interface;
MultiSerialInterface interface_manager;
static bool clientConnected() { return connected; }
static Stream& usbTerminalOutput() { return stream; }
static bool isNetworkTerminalActive() { return network; }
static void clearUsbTerminalLine() {}
static unsigned banners=0, cancellations=0;
namespace mesh {
bool hasDedicatedUsbLoggingPort() { return dedicated; }
bool isUsbLoggingEnabled() { return logging; }
void setUsbLoggingEnabled(bool value) { logging=value; }
void discardUsbTerminalOutput() {}
Stream& usbCompanionPort() { return stream; }
bool saveUsbLoggingBootPreference(bool) { return true; }
bool usbLoggingInterfaceRestartRequired() { return false; }
}
namespace CompanionMqttSetupPortal {
static bool saveEnabled(bool) { return true; }
}
static uint32_t futureMillis(uint32_t value) { return millis()+value; }
struct MyMesh {
struct { unsigned usb_logging_enabled=0; } _prefs;
BaseSerialInterface* _serial=&interface_manager;
bool terminal=false, _mqtt_enabled=false;
unsigned _scheduled_reboot_at=0;
uint8_t cmd_frame[MAX_FRAME_SIZE+1]{}, out_frame[MAX_FRAME_SIZE+4]{};
char reply_buf[166]{};
bool isTerminalMode() const { return terminal; }
void cancelSerialResponseStream() { ++cancellations; }
void cancelSerialOperationsForRoute(BaseSerialInterface*) {}
void enterTerminalMode(bool show_banner) {
terminal=true;
if(show_banner) { ++banners; usbTerminalOutput().print("BANNER"); }
}
bool savePrefs() { return true; }
void stopMQTT() {}
void applyUsbLoggingState(bool);
void beginUsbLoggingReplyBarrier(BaseSerialInterface*);
void endUsbLoggingReplyBarrier(bool);
void writeErrFrame(uint8_t) { assert(false); }
bool handleCommand(const char* command,uint32_t,char* reply) {
size_t reply_size=160;
@SETTERS@
strcpy(reply,"query"); return true;
}
void handleCmdFrame(size_t len) {
static constexpr uint8_t RESP_CODE_CLI_REPLY=29, ERR_CODE_ILLEGAL_ARG=1;
@CLI_FRAME@
}
} the_mesh;
static void leaveUsbTerminalMode(bool) { the_mesh.terminal=false; }
@FUNCTIONS@
static void reset() {
connected=true; dedicated=false; logging=false; network=false;
usb_logging_network_parked=usb_logging_terminal_mode=false;
usb_logging_reply_hold=usb_logging_reply_staged=false;
usb_logging_reply_pending=usb_logging_reply_state=false;
stream=UsbStream(); ble=Remote(); the_mesh=MyMesh();
usb_serial_interface=ArduinoSerialInterface();
interface_manager=MultiSerialInterface();
usb_serial_interface.begin(stream);
usb_serial_interface.setConnectedCheck(clientConnected);
usb_serial_interface.enableFlowControl(true);
assert(interface_manager.addInterface(InterfaceType::USB,&usb_serial_interface));
assert(interface_manager.addInterface(InterfaceType::Bluetooth,&ble));
interface_manager.enable(); banners=cancellations=0;
}
static void command(const char* text) {
stream.receive(text);
size_t len=interface_manager.checkRecvFrame(the_mesh.cmd_frame);
assert(len>0 && interface_manager.captureReplyRoute()==&usb_serial_interface);
the_mesh.handleCmdFrame(len);
}
static void checkReply() {
assert(stream.tx.size()>=4 && stream.tx[0]=='>');
const size_t size=stream.tx[1]+256*stream.tx[2];
assert(stream.tx.size()>=size+3 && stream.tx[3]==29);
assert(ble.tx.empty());
const std::string reply(stream.tx.begin()+4,stream.tx.begin()+size+3);
assert(reply.find("saved")!=std::string::npos);
}
int main() {
// Real CLI setters, not a mock apply call: both variants retain the request.
for(const char* setting : {"set usb.logging on","set logging.output both"}) {
reset(); command(setting);
assert(!logging && !the_mesh.terminal && usb_logging_reply_pending);
checkReply();
const size_t frame_bytes=stream.tx.size();
serviceUsbLoggingReplyBarrier();
assert(logging && the_mesh.terminal && !usb_logging_reply_pending);
assert(stream.tx.size()==frame_bytes+6);
assert(std::string(stream.tx.begin()+frame_bytes,stream.tx.end())=="BANNER");
assert(ble.tx.empty());
}
// Stall and partial writes never block the mesh or allow a banner to splice
// into a framed response; the complete reply precedes the logging handoff.
reset(); stream.capacity=0; command("set usb.logging on");
assert(stream.tx.empty() && usb_logging_reply_pending && !logging);
for(unsigned i=0;i<10000;++i) serviceUsbLoggingReplyBarrier();
assert(!logging && !the_mesh.terminal && stream.tx.empty());
stream.capacity=7; stream.max_write=3;
for(unsigned i=0;i<1000 && usb_logging_reply_pending;++i) {
serviceUsbLoggingReplyBarrier();
if(usb_logging_reply_pending) assert(!logging && !the_mesh.terminal);
}
assert(logging); checkReply();
// An incomplete *next* input does not hold the already admitted reply.
reset(); stream.capacity=0; command("set usb.logging on");
stream.rx={'<',5,0,1}; stream.cursor=0;
uint8_t input[176]; assert(usb_serial_interface.checkRecvFrame(input)==0);
assert(usb_serial_interface.isReadBusy());
stream.capacity=4096; serviceUsbLoggingReplyBarrier();
assert(logging && the_mesh.terminal); checkReply();
// A new read-only USB request while stalled must not erase the old intent.
reset(); stream.capacity=0; command("set usb.logging on");
command("get version"); assert(usb_logging_reply_pending && !logging);
stream.capacity=4096; serviceUsbLoggingReplyBarrier(); assert(logging);
// Disconnect/reset/ASCII takeover cancels this session's queued intent.
reset(); stream.capacity=0; command("set usb.logging on");
connected=false; serviceUsbLoggingReplyBarrier();
connected=true; stream.capacity=4096; serviceUsbLoggingReplyBarrier();
assert(!logging && !usb_logging_reply_pending && ble.tx.empty());
reset(); stream.capacity=0; command("set usb.logging on");
cancelUsbSerialOperations(); usb_serial_interface.resetSessionState();
stream.capacity=4096; serviceUsbLoggingReplyBarrier(); assert(!logging);
// A queue unable to accept the acknowledgement cannot start the handoff.
reset(); stream.capacity=0;
const uint8_t required[]={29,1};
for(unsigned i=0;i<4;++i) assert(usb_serial_interface.writeFrame(required,2)==2);
command("set usb.logging on");
assert(!usb_logging_reply_pending && !logging && ble.tx.empty());
// A later explicit setting supersedes an earlier pending on request.
reset(); stream.capacity=0; command("set usb.logging on");
the_mesh.applyUsbLoggingState(false);
stream.capacity=4096; serviceUsbLoggingReplyBarrier(); assert(!logging);
// Dedicated CDC and commands from another interface don't defer its reply.
reset(); dedicated=true; command("set usb.logging on");
assert(logging && !usb_logging_reply_pending && !the_mesh.terminal); checkReply();
reset(); the_mesh.beginUsbLoggingReplyBarrier(&ble);
the_mesh.applyUsbLoggingState(true); the_mesh.endUsbLoggingReplyBarrier(true);
assert(logging && !usb_logging_reply_pending && the_mesh.terminal);
// A real BLE framed command retains its route even when it parks USB.
// Network terminal ownership likewise survives this logging update.
for(bool network_owner : {false,true}) {
reset(); network=network_owner;
const char* setting="set usb.logging on";
ble.rx={mesh::companion::kRunCliCommand};
ble.rx.insert(ble.rx.end(),setting,setting+strlen(setting));
size_t len=interface_manager.checkRecvFrame(the_mesh.cmd_frame);
assert(len>0 && interface_manager.captureReplyRoute()==&ble);
the_mesh.handleCmdFrame(len);
assert(logging && !usb_logging_reply_pending && ble.tx[0]==29);
assert(interface_manager.captureReplyRoute()==&ble && cancellations==0);
assert(the_mesh.terminal!=network_owner && network==network_owner);
assert(usb_serial_interface.isPassthroughMode());
}
}
'''
class CompanionLoggingReplyTest(unittest.TestCase):
def test_logging_acknowledgement_precedes_single_tty_handoff(self):
main = (ROOT / 'examples/companion_radio/main.cpp').read_text()
mesh = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
signatures = (
'static void cancelUsbSerialOperations() {',
'static void enterUsbTerminalMode(bool show_banner = true) {',
'static void enterUsbLoggingTerminalMode() {',
'static void serviceUsbLoggingOwnership(bool logging_enabled) {',
'void MyMesh::applyUsbLoggingState(bool enabled) {',
'void MyMesh::beginUsbLoggingReplyBarrier(BaseSerialInterface* route) {',
'void MyMesh::endUsbLoggingReplyBarrier(bool reply_queued) {',
'static void serviceUsbLoggingReplyBarrier() {',
)
source = HARNESS.replace('@FUNCTIONS@', '\n'.join(
extract_braced(main, signature) for signature in signatures))
setters = '\n'.join(extract_braced(mesh, signature) for signature in (
'if (strncmp(command, "set usb.logging", 15) == 0',
'if (strncmp(command, "set logging.output ", 19) == 0)',
))
source = source.replace('@SETTERS@', setters).replace('@CLI_FRAME@',
extract_braced(mesh, 'if (mesh::companion::isRunCliFrame(cmd_frame[0], len))'))
with tempfile.TemporaryDirectory() as directory:
cpp, exe = Path(directory) / 'reply.cpp', Path(directory) / 'reply.exe'
cpp.write_text(source)
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-Wno-unused-parameter', '-Wno-sign-compare',
'-I' + str(ROOT / 'test/mocks'), '-I' + str(ROOT / 'src'),
str(cpp), str(ROOT / 'src/helpers/ArduinoSerialInterface.cpp'),
'-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
@@ -223,6 +223,27 @@ int main() {
drain(radio);
require(console.output.empty(), "canceled old-session output leaked");
}
// The real HWCDC setup falls back to these ring sizes after allocation
// failure. A stored row larger than the ring must finish without omission.
for (size_t capacity : {256u, 512u}) {
const std::string stored = std::string(639, 's') + "\nnext\n";
setupFile(stored);
MyMesh radio;
radio.dumpLogFile();
console.capacity = 0;
radio.servicePendingSerialOutput();
require(console.output.empty(), "fallback dump wrote to a stalled host");
drain(radio, capacity);
require(console.output == stored + eof, "small TX ring wedged or truncated a stored line");
}
setupFile("short\n");
{
MyMesh radio;
radio.dumpLogFile();
console.short_limit = 3;
drain(radio, 7);
require(console.output == "short\n" + eof, "short-write EOF was duplicated or truncated");
}
setupFile("");
{
MyMesh radio;
@@ -306,6 +327,47 @@ int main() {
'''
RECENT_TEST = r'''
setupFile("");
{
MyMesh radio;
radio.tables.rows.push_back({{0, 0, 1}, 3, -127});
radio.tables.rows.push_back({{0, 0, 2}, 3, -127});
console.short_limit = 3;
radio.printRecentRepeatersSerial();
require(radio.serial_recent_header, "partial header advanced cursor");
drain(radio, 7);
require(console.output == "Recent repeaters (2):\n000001,-31.75\n000002,-31.75\n",
"short-write recent listing duplicated or lost a prefix");
}
setupFile("");
{
MyMesh radio;
radio.tables.rows.push_back({{0, 0, 1}, 3, -127});
radio.printRecentRepeatersSerial();
console.short_limit = 3;
radio.servicePendingSerialOutput();
require(radio.serial_log_pending_size > 0, "partial recent row was not retained");
radio.tables.rows[0] = {{0, 0, 9}, 3, 0};
console.short_limit = 0;
drain(radio, 64);
require(console.output == "Recent repeaters (1):\n000001,-31.75\n",
"live table update changed a partially emitted recent row");
}
setupFile("");
{
MyMesh radio;
radio.tables.rows.push_back({{0, 0, 1}, 3, -127});
console.short_limit = 3;
radio.printRecentRepeatersSerial();
radio.cancelPendingSerialOutput();
console.output.clear();
console.short_limit = 0;
radio.tables.rows.clear();
radio.printRecentRepeatersSerial();
drain(radio, 64);
require(console.output == "Recent repeaters (0):\n-none-\r\n",
"canceled recent suffix leaked into the next session");
}
setupFile("");
{
MyMesh radio;
+5 -3
View File
@@ -183,7 +183,7 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase):
loop.index("servicePendingSerialOutput();"),
)
def test_recent_list_advances_only_after_whole_row_admission(self):
def test_recent_list_advances_only_after_retained_row_finishes(self):
text = source("examples/simple_repeater/MyMesh.cpp")
start = text.index("void MyMesh::servicePendingSerialOutput()")
start = text.index("if (serial_recent_next >= 0)", start)
@@ -194,10 +194,12 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase):
self.assertNotIn("getRecentRepeaterBySortedIdx", service)
self.assertNotRegex(service, r"\b(?:while|for)\s*\(")
self.assertLess(
service.index("console.availableForWrite() < length"),
service.index("if (serial_log_pending_size != 0) return;"),
service.index("++serial_recent_next"),
)
self.assertIn("!= static_cast<size_t>(length)) return;", service)
self.assertIn("if (serial_log_pending_size == 0)", service)
self.assertIn("memcpy(serial_log_pending, record, length);", service)
self.assertIn("write_pending();", service)
def test_functional_reserve_fits_default_full_acl_plus_command_echo(self):
logging = source("src/helpers/UsbLogging.cpp")
+114
View File
@@ -0,0 +1,114 @@
"""Build-local native CDC hooks must precede delayed framework events."""
from pathlib import Path
import importlib.util
import os
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("esp_usb_fix", ROOT / "scripts/esp32_usb_session_fix.py")
FIX = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(FIX)
CDC = '''#include "esp32-hal-tinyusb.h"
TUD_CDC_DESCRIPTOR(*itf, str_index, 0x85, 64, 0x03, 0x84, 64)
void tud_cdc_line_state_cb(uint8_t itf, bool dtr, bool rts)
{
devices[itf]->_onLineState(dtr, rts);
}
void USBCDC::_onLineState(bool _dtr, bool _rts){
if(dtr == _dtr && rts == _rts) return;
dtr = _dtr;
arduino_usb_event_post();
}
void USBCDC::_onRX(){
tud_cdc_n_read();
xQueueSend();
}
void tud_cdc_tx_complete_cb(uint8_t itf){
devices[itf]->_onTX();
}
'''
USB = '''#include "esp32-hal-tinyusb.h"
void tud_mount_cb(void){
arduino_usb_event_post();
}
void tud_umount_cb(void){
arduino_usb_event_post();
}
'''
class Esp32UsbSessionFixTests(unittest.TestCase):
def test_owner_hooks_precede_state_changes_and_rx_queue(self):
patched = FIX.patched_cdc_source(CDC)
self.assertLess(patched.index("meshEsp32TinyUsbCdcLineState(dtr);"),
patched.index("devices[itf]->_onLineState(dtr, rts);"))
self.assertLess(patched.index("!meshEsp32TinyUsbAcceptRx()"),
patched.index("xQueueSend();"))
self.assertLess(patched.index("meshEsp32TinyUsbTxComplete();"),
patched.index("devices[itf]->_onTX();"))
self.assertIn("usbd_edpt_busy(0, 0x84)", patched)
self.assertEqual(FIX.patched_cdc_source(patched), patched)
device = FIX.patched_usb_source(USB)
for signature in (FIX.MOUNT, FIX.UNMOUNT):
callback = device[device.index(signature):]
self.assertLess(callback.index("meshEsp32TinyUsbDeviceSessionBoundary"),
callback.index("arduino_usb_event_post"))
self.assertEqual(FIX.patched_usb_source(device), device)
def test_changed_sdk_or_endpoint_fails_closed(self):
for original, changed in (("0x84", "0x82"), (FIX.LINE_STATE, "unknown() {"),
(FIX.RX, "changed_rx() {"),
(FIX.TX_COMPLETE, "changed_tx() {")):
with self.subTest(changed=original), self.assertRaises(RuntimeError):
FIX.patched_cdc_source(CDC.replace(original, changed))
with self.assertRaises(RuntimeError):
FIX.patched_usb_source(USB.replace(FIX.UNMOUNT, "changed_unmount() {"))
self.assertEqual(FIX.patched_cdc_source(CDC.replace("\n", "\r\n")),
FIX.patched_cdc_source(CDC))
def test_only_primary_native_cdc_uses_build_local_copy(self):
class Environment(dict):
def subst(self, value):
return self["BUILD_DIR"]
def File(self, value):
return value
class Node:
def __init__(self, path): self.path = path
def srcnode(self): return self
def get_abspath(self): return str(self.path)
with tempfile.TemporaryDirectory(prefix="meshcore-esp-usb-hook-") as directory:
directory = Path(directory)
source = directory / "USBCDC.cpp"
source.write_text(CDC, encoding="utf-8")
node = Node(source)
env = Environment(BUILD_DIR=str(directory / "build"),
CPPDEFINES=[("ARDUINO_USB_CDC_ON_BOOT", 1), ("ARDUINO_USB_MODE", 0)])
result = Path(FIX.replace_source(env, node))
self.assertNotEqual(result, source)
self.assertEqual(source.read_text(), CDC)
self.assertEqual(result.read_text(), FIX.patched_cdc_source(CDC))
for definitions in ([], [("ARDUINO_USB_CDC_ON_BOOT", 1), ("ARDUINO_USB_MODE", 1)],
[("ARDUINO_USB_CDC_ON_BOOT", 0), ("ARDUINO_USB_MODE", 0)]):
env["CPPDEFINES"] = definitions
self.assertIs(FIX.replace_source(env, node), node)
def test_installed_sdk_compatibility_when_available(self):
# Package-free CI still exercises the golden anchors above. Check an
# installed SDK on Windows and Unix, including a custom PIO core dir.
core = Path(os.environ.get("PLATFORMIO_CORE_DIR", str(Path.home() / ".platformio")))
package = core / "packages/framework-arduinoespressif32/cores/esp32"
if not (package / "USBCDC.cpp").is_file():
self.skipTest("installed Arduino-ESP32 SDK unavailable")
for name, patcher in (("USBCDC.cpp", FIX.patched_cdc_source), ("USB.cpp", FIX.patched_usb_source)):
original = (package / name).read_text(encoding="utf-8")
self.assertIn("meshEsp32TinyUsb", patcher(original))
def test_all_esp32_profiles_inherit_hook(self):
config = (ROOT / "platformio.ini").read_text()
base = config[config.index("[esp32_base]"):config.index("[esp32_ota]")]
self.assertIn("pre:scripts/esp32_usb_session_fix.py", base)
if __name__ == "__main__":
unittest.main()
+296
View File
@@ -0,0 +1,296 @@
"""Execute GPS sync interval policy and both production provider timers."""
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_gps_upstream_adaptations import ARDUINO, NMEA, RTC
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
MESH = r'''
#pragma once
#include <Arduino.h>
namespace mesh {
class RTCClock {
uint32_t utc=1790899200U;
public:
unsigned writes=0;
uint32_t getCurrentTime(){return utc;}
void setCurrentTime(uint32_t value){utc=value;++writes;}
};
}
'''
BASE = r'''
#include <cassert>
#include <helpers/GpsPowerPolicy.h>
#include <helpers/sensors/LocationProvider.h>
void LocationProvider::sendSentence(const char*){}
struct Provider:LocationProvider {
using LocationProvider::periodicTimeSyncIntervalMillis;
long getLatitude()override{return 0;}
long getLongitude()override{return 0;}
long getAltitude()override{return 0;}
long satellitesCount()override{return 0;}
bool isValid()override{return false;}
long getTimestamp()override{return 0;}
void reset()override{}void begin()override{}void stop()override{}void loop()override{}
bool isEnabled()override{return true;}
void applied(){markTimeSyncApplied();stopTimeSync();}
};
'''
PARSER = BASE + r'''
int main(){
uint16_t hours=77;
for(const char* value:{"1","01","0001"}){
assert(mesh::gps::parseSyncIntervalHours(value,hours)&&hours==1);
}
for(const char* value:{"336","337","999","4294967296",
"999999999999999999999999999999999999999999999999"}){
assert(mesh::gps::parseSyncIntervalHours(value,hours)&&hours==336);
}
assert(mesh::gps::parseSyncIntervalHours("168",hours)&&hours==168);
for(const char* value:{"","0","000","-1","+1","1.0"," 1","1 ","1h",
"999999999999999999999999x","0x10"}){
hours=77;assert(!mesh::gps::parseSyncIntervalHours(value,hours));assert(hours==77);
}
assert(!mesh::gps::parseSyncIntervalHours(nullptr,hours));
// Position processing keeps its existing seconds semantics and bounds.
uint32_t seconds=77;
assert(mesh::gps::parseUpdateInterval("0",seconds)&&seconds==0);
assert(mesh::gps::updateIntervalMillis(seconds)==1000);
assert(mesh::gps::parseUpdateInterval("86400",seconds)&&seconds==86400);
assert(!mesh::gps::parseUpdateInterval("999999",seconds));
}
'''
POWER = BASE + r'''
int main(){
const uint32_t hour=3600000U;
Provider gps;now_ms=100;
assert(gps.getTimeSyncIntervalHours()==0);
assert(gps.periodicTimeSyncIntervalMillis()==1800000UL);
gps.setNextWake();assert(gps.getNextWake()==100+24UL*hour);
gps.setPowerSavingProfile(600,7UL*24UL*3600UL);
gps.setNextWake();assert(gps.getNextWake()==100+7UL*24UL*hour);
gps.setNextSleep();assert(gps.getNextSleep()==600100UL);
gps.setTimeSyncIntervalHours(1);
assert(gps.getTimeSyncIntervalHours()==1&&gps.periodicTimeSyncIntervalMillis()==hour);
assert(gps.getNextWake()==100+hour); // Replace an already armed legacy wake.
gps.setNextSleep();assert(gps.getNextSleep()==600100UL);
gps.stopTimeSync();gps.setNextWake();assert(gps.getNextWake()==100+hour);
gps.setTimeSyncIntervalHours(336);
assert(gps.periodicTimeSyncIntervalMillis()==1209600000UL);
assert(gps.getNextWake()==100+336UL*hour);
gps.setNextGPSOn(0);gps.setTimeSyncIntervalHours(1);
assert(gps.getNextWake()==0); // Changing policy does not start a disabled GPS.
// Successful time fixes anchor the next scheduled wake; position work cannot
// postpone the long time-sync interval by repeatedly stopping the receiver.
now_ms=1000;gps.applied();now_ms=10000;gps.setNextWake();
assert(gps.getNextWake()==1000+hour);
now_ms=20000;gps.setNextWake();assert(gps.getNextWake()==1000+hour);
gps.syncTimeForPowerSavingCycle();assert(!gps.waitingTimeSync());
now_ms=1000+hour-1;gps.syncTimeForPowerSavingCycle();assert(!gps.waitingTimeSync());
now_ms=1000+hour;gps.syncTimeForPowerSavingCycle();assert(gps.waitingTimeSync());
gps.stopTimeSync();
// An acquisition request without a fix is still paced, including later
// position wake-ups. It cannot cause every telemetry request to force sync.
now_ms=1000+hour+5000;gps.syncTimeForPowerSavingCycle();assert(!gps.waitingTimeSync());
gps.setNextWake();assert(gps.getNextWake()==1000+2UL*hour);
now_ms=1000+2UL*hour;gps.syncTimeForPowerSavingCycle();assert(gps.waitingTimeSync());
gps.applied();
// Direct manual/startup requests always override the configured pace.
++now_ms;gps.syncTime();assert(gps.waitingTimeSync());gps.stopTimeSync();
gps.setTimeSyncIntervalHours(0);
assert(gps.periodicTimeSyncIntervalMillis()==1800000UL);
gps.syncTimeForPowerSavingCycle();assert(gps.waitingTimeSync());
gps.setNextWake();assert(gps.getNextWake()==now_ms+7UL*24UL*hour);
// Deadline/elapsed arithmetic must remain valid through 32-bit millis wrap.
Provider wrap;wrap.setTimeSyncIntervalHours(336);
now_ms=UINT32_MAX-1000U;wrap.applied();
now_ms=1000;wrap.setNextWake();
assert(static_cast<uint32_t>(wrap.getNextWake())==
static_cast<uint32_t>(UINT32_MAX-1000U+336U*hour));
wrap.syncTimeForPowerSavingCycle();assert(!wrap.waitingTimeSync());
now_ms=static_cast<uint32_t>(UINT32_MAX-1000U+336U*hour);
wrap.syncTimeForPowerSavingCycle();assert(wrap.waitingTimeSync());
// Exhausting the acquisition window cannot leave an immediate retry loop.
wrap.stopTimeSync();now_ms+=336U*hour;wrap.setNextWake();
assert(static_cast<uint32_t>(wrap.getNextWake())==now_ms+336U*hour);
// The deadline's zero sentinel must not lose a real wake across rollover.
Provider zero;zero.setTimeSyncIntervalHours(1);now_ms=0U-hour;
zero.setNextWake();assert(zero.getNextWake()==1);
}
'''
PROVIDERS = r'''
#include <cassert>
#include <helpers/sensors/MicroNMEALocationProvider.h>
void LocationProvider::sendSentence(const char*){}
struct Uart:Stream {
std::deque<char> incoming;
int available()override{return static_cast<int>(incoming.size());}
int read()override{char c=incoming.front();incoming.pop_front();return c;}
void fix(){incoming.push_back('\n');}
};
struct Gnss {
bool getGnssFixOk(int){return true;}
long getLatitude(int){return 476100000;}
long getLongitude(int){return -1223300000;}
long getAltitude(int){return 125000;}
int getSIV(int){return 8;}
bool getDateValid(int){return true;}
bool getTimeValid(int){return true;}
long getUnixEpoch(int){return 1790942400;}
} ublox_GNSS;
@RAK@
struct NmeaProvider:MicroNMEALocationProvider {
using MicroNMEALocationProvider::MicroNMEALocationProvider;
uint32_t lastApplied()const{return _last_time_sync_applied_ms;}
};
struct RakProvider:RAK12500LocationProvider {
uint32_t lastApplied()const{return _last_time_sync_applied_ms;}
};
struct NmeaFixture {
Uart uart;mesh::RTCClock clock;NmeaProvider gps;
NmeaFixture():gps(uart,&clock,-1,-1){}
void tick(){uart.fix();gps.loop();}
};
struct RakFixture {
mesh::RTCClock clock;RakProvider gps;
RakFixture(){gps.setRTCClock(&clock);}
void tick(){gps.loop();}
};
template<class Fixture>void interval(uint16_t hours,uint32_t start){
Fixture fixture;
fixture.gps.setTimeSyncIntervalHours(hours);
// A receiver has already been serviced before uptime reaches rollover.
// Advance in gaps below half the target tick range to keep its sample timer
// meaningful, then request the test's reference sync at the selected start.
if(start>INT32_MAX){
for(uint32_t warm:{1U,1073741824U,2147483647U,3221225470U}){
now_ms=warm;fixture.tick();
}
}
const unsigned before=fixture.clock.writes;
fixture.gps.syncTime();
now_ms=start;fixture.tick();
if(hours&&fixture.gps.waitingTimeSync()){
const unsigned clears=MicroNMEA::clears;
fixture.gps.syncTimeForPowerSavingCycle();
assert(MicroNMEA::clears==clears&&fixture.gps.waitingTimeSync());
}
now_ms+=1100;fixture.tick();now_ms+=1100;fixture.tick();
assert(fixture.clock.writes==before+1&&!fixture.gps.waitingTimeSync());
const uint32_t applied=fixture.gps.lastApplied();
const uint32_t interval_ms=hours?static_cast<uint32_t>(hours)*3600000U:1800000U;
now_ms=applied+interval_ms-1U;fixture.tick();assert(fixture.clock.writes==before+1);
now_ms+=1100;fixture.tick();assert(fixture.clock.writes==before+2);
assert(!fixture.gps.waitingTimeSync()&&fixture.gps.consumeTimeSyncApplied());
// Manual sync remains immediate even shortly after a periodic write.
now_ms+=1100;fixture.gps.syncTime();assert(fixture.gps.waitingTimeSync());
fixture.tick();now_ms+=1100;fixture.tick();now_ms+=1100;fixture.tick();
assert(fixture.clock.writes==before+3&&!fixture.gps.waitingTimeSync());
// Unrelated position acquisition reuses the last actual sync rather than
// resetting the clock before the selected interval has elapsed.
now_ms+=1100;fixture.gps.syncTimeForPowerSavingCycle();
if(hours){assert(!fixture.gps.waitingTimeSync());fixture.tick();assert(fixture.clock.writes==before+3);}
else{assert(fixture.gps.waitingTimeSync());}
}
int main(){
for(uint16_t hours:{0U,1U,336U}){
interval<NmeaFixture>(hours,1);interval<RakFixture>(hours,1);
// Host Windows uses the target's 32-bit unsigned long ABI. On a 64-bit
// Unix host base policy rollover remains covered by the separate harness.
if(sizeof(unsigned long)==sizeof(uint32_t)){
interval<NmeaFixture>(hours,UINT32_MAX-3000U);
interval<RakFixture>(hours,UINT32_MAX-3000U);
}
}
}
'''
TELEMETRY = r'''
#include <cassert>
#include <helpers/SensorManager.h>
void LocationProvider::sendSentence(const char*){}
void SensorManager::setTelemetryLocationAccessAvailable(bool){}
struct Provider:LocationProvider {
long getLatitude()override{return 0;}long getLongitude()override{return 0;}
long getAltitude()override{return 0;}long satellitesCount()override{return 0;}
bool isValid()override{return false;}long getTimestamp()override{return 0;}
void reset()override{}void begin()override{}void stop()override{}void loop()override{}
bool isEnabled()override{return true;}
void applied(){markTimeSyncApplied();stopTimeSync();}
};
struct Sensors:SensorManager {
Provider& gps;explicit Sensors(Provider& p):gps(p){}
bool telemetryGpsDetected()const override{return true;}
LocationProvider* getLocationProvider()override{return &gps;}
};
int main(){
Provider gps;Sensors sensors(gps);gps.setGPSPowerSaving(true);
gps.setTimeSyncIntervalHours(336);now_ms=1000;gps.applied();
now_ms=1000+60000;assert(!sensors.requestGpsTelemetryTimeSync(60));
now_ms=1000+336UL*3600000UL-1;assert(!sensors.requestGpsTelemetryTimeSync(60));
++now_ms;assert(sensors.requestGpsTelemetryTimeSync(60));
gps.applied();gps.setTimeSyncIntervalHours(0);
now_ms+=59999;assert(!sensors.requestGpsTelemetryTimeSync(60));
++now_ms;assert(sensors.requestGpsTelemetryTimeSync(60));
}
'''
class GpsSyncIntervalTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.compiler = shutil.which("g++") or shutil.which("clang++")
if cls.compiler is None:
raise unittest.SkipTest("a host C++17 compiler is required")
def compile_run(self, source):
with tempfile.TemporaryDirectory(prefix="meshcore-gps-sync-") as directory:
path = Path(directory)
for name, content in {
"Arduino.h": ARDUINO, "Mesh.h": MESH, "MicroNMEA.h": NMEA,
"RTClib.h": RTC, "CayenneLPP.h": "#pragma once\nclass CayenneLPP {};\n",
"Wire.h": "#pragma once\nclass TwoWire {};\n",
}.items():
(path / name).write_text(content, encoding="utf-8")
cpp = path / "test.cpp"
cpp.write_text(source, encoding="utf-8")
binary = path / "test"
subprocess.run([
self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-DENV_INCLUDE_GPS=1",
*(["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []),
"-I", str(path), "-I", str(ROOT / "src"), str(cpp), "-o", str(binary),
], check=True)
subprocess.run([str(binary)], check=True)
def test_positive_whole_hours_clamp_without_overflow(self):
self.compile_run(PARSER)
def test_power_saving_interval_reconfiguration_and_rollover(self):
self.compile_run(POWER)
def test_both_providers_use_hours_keep_legacy_and_allow_manual_sync(self):
source = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text()
rak = extract_braced(source, "class RAK12500LocationProvider") + ";"
self.compile_run(PROVIDERS.replace("@RAK@", rak))
def test_position_telemetry_cannot_bypass_selected_time_interval(self):
self.compile_run(TELEMETRY)
if __name__ == "__main__":
unittest.main()
+133
View File
@@ -0,0 +1,133 @@
"""Run canonical GPS sync CLI branches with durable and live state failures."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <helpers/GpsPowerPolicy.h>
struct Prefs { uint16_t gps_sync_interval_hours = 0; };
struct Provider {
uint16_t hours = 0;
uint16_t getTimeSyncIntervalHours() const { return hours; }
};
struct Sensors {
Provider provider;
bool present = true;
unsigned applications = 0;
Provider* getLocationProvider() { return present ? &provider : nullptr; }
void applyGpsTimeSyncInterval(uint16_t hours) { provider.hours = hours; ++applications; }
};
struct CLI {
Prefs prefs, persisted;
Prefs* _prefs = &prefs;
Sensors sensors;
Sensors* _sensors = &sensors;
bool save_ok = true;
unsigned saves = 0;
bool trySavePrefs() { ++saves; if (save_ok) persisted = prefs; return save_ok; }
void set(const char* config, char* reply) { @COMMON_SET@ }
void get(const char* config, char* reply) { @COMMON_GET@ }
};
struct Companion {
Prefs _prefs, persisted;
Sensors sensors;
bool save_ok = true;
unsigned saves = 0;
bool savePrefs() { ++saves; if (save_ok) persisted = _prefs; return save_ok; }
@SAVE_PREFERENCE@
bool command(const char* command, char* reply, size_t reply_size) {
@COMPANION_GET@
const char* gps_sync_value = nullptr;
@COMPANION_PREFIX@
@COMPANION_SET@
return false;
}
};
int main() {
char reply[160];
CLI common; Companion companion;
common.get("gps.sync.interval", reply);
assert(strstr(reply, "default") && !strstr(reply, "0 hours"));
assert(companion.command("get gps.sync.interval", reply, sizeof(reply)));
assert(strstr(reply, "default") && !strstr(reply, "0 hours"));
for (const char* value : {"1", "24", "336", "337", "4294967296", "999999999999999999999999"}) {
char command[96];
uint16_t expected=0; assert(mesh::gps::parseSyncIntervalHours(value,expected));
snprintf(command,sizeof(command),"gps.sync.interval %s",value);
common.set(command,reply);
assert(common.prefs.gps_sync_interval_hours==expected);
assert(common.persisted.gps_sync_interval_hours==expected && common.sensors.provider.hours==expected);
char actual[32];snprintf(actual,sizeof(actual),"%u hours",unsigned(expected));
assert(strstr(reply,actual) && strstr(reply,"saved"));
snprintf(command,sizeof(command),"set gps.sync.interval %s",value);
assert(companion.command(command,reply,sizeof(reply)));
assert(companion._prefs.gps_sync_interval_hours==expected);
assert(companion.persisted.gps_sync_interval_hours==expected && companion.sensors.provider.hours==expected);
assert(strstr(reply,actual) && strstr(reply,"saved"));
}
for(const char* value : {"", "0", "0000", "-1", "1.5", "5h", "1 2", "337junk"}) {
char command[64];
const unsigned common_saves=common.saves, companion_saves=companion.saves;
snprintf(command,sizeof(command),"gps.sync.interval %s",value);
common.set(command,reply);assert(strstr(reply,"Error:"));
snprintf(command,sizeof(command),"set gps.sync.interval %s",value);
assert(companion.command(command,reply,sizeof(reply)) && strstr(reply,"Error:"));
assert(common.saves==common_saves && companion.saves==companion_saves);
}
common.save_ok=companion.save_ok=false;
const unsigned common_applied=common.sensors.applications, companion_applied=companion.sensors.applications;
common.set("gps.sync.interval 7",reply);assert(strstr(reply,"could not be saved"));
assert(companion.command("set gps.sync.interval 7",reply,sizeof(reply)) && strstr(reply,"could not be saved"));
assert(common.prefs.gps_sync_interval_hours==336 && common.persisted.gps_sync_interval_hours==336);
assert(companion._prefs.gps_sync_interval_hours==336 && companion.persisted.gps_sync_interval_hours==336);
assert(common.sensors.provider.hours==336 && companion.sensors.provider.hours==336);
assert(common.sensors.applications==common_applied && companion.sensors.applications==companion_applied);
common.get("gps.sync.interval",reply);assert(!strcmp(reply,"> 336 hours"));
assert(companion.command("get gps.sync.interval",reply,sizeof(reply)) && !strcmp(reply,"> 336 hours"));
common.sensors.present=companion.sensors.present=false;
const unsigned common_saves=common.saves, companion_saves=companion.saves;
common.set("gps.sync.interval 1",reply);assert(strstr(reply,"unavailable"));
assert(companion.command("set gps.sync.interval 1",reply,sizeof(reply)) && strstr(reply,"unavailable"));
common.get("gps.sync.interval",reply);assert(strstr(reply,"unavailable"));
assert(companion.command("get gps.sync.interval",reply,sizeof(reply)) && strstr(reply,"unavailable"));
assert(common.saves==common_saves && companion.saves==companion_saves);
}
'''
class GpsSyncPreferencesTest(unittest.TestCase):
def test_canonical_cli_transactions(self):
common = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
companion = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
header = (ROOT / 'examples/companion_radio/MyMesh.h').read_text()
replacements = {
'@COMMON_SET@': extract_braced(common, 'if (strncmp(config, "gps.sync.interval", 17)'),
'@COMMON_GET@': extract_braced(common, 'if (strcmp(config, "gps.sync.interval") == 0)'),
'@COMPANION_GET@': extract_braced(companion, 'if (strcmp(command, "get gps.sync.interval") == 0)'),
'@COMPANION_PREFIX@': extract_braced(companion, 'if (strncmp(command, "set gps.sync.interval", 21)'),
'@COMPANION_SET@': extract_braced(companion, 'if (gps_sync_value)'),
'@SAVE_PREFERENCE@': extract_braced(header, 'template <typename T> bool savePreference('),
}
code = HARNESS
for token, value in replacements.items():
code = code.replace(token, value)
with tempfile.TemporaryDirectory(prefix='meshcore-gps-sync-prefs-') as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test.exe'
cpp.write_text(code)
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-I' + str(ROOT / 'src'), str(cpp), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
+3 -2
View File
@@ -392,9 +392,10 @@ int main(){
rak_provider = rak[rak.index("class RAK12500LocationProvider"):rak.index("static RAK12500LocationProvider")]
self.assertIn("markTimeSyncApplied();", rak_provider)
self.assertIn("resetTimeSyncRequestState();", rak_provider)
# Preserve deliberate force-sync callers outside telemetry queries.
# Explicit CLI sync remains forced; scheduled power cycles honor the
# configured hourly cadence, with force-sync retained for legacy0.
self.assertIn("void syncTime() override { nmea.clear(); LocationProvider::syncTime(); }", provider)
self.assertIn("_location->syncTime();", extract_braced(rak, "void EnvironmentSensorManager::armGpsPowerSavingCycle()"))
self.assertIn("_location->syncTimeForPowerSavingCycle();", extract_braced(rak, "void EnvironmentSensorManager::armGpsPowerSavingCycle()"))
if __name__ == "__main__":
+10 -3
View File
@@ -35,6 +35,10 @@ static unsigned rx_flushes = 0, tx_clears = 0, log_edges = 0;
// leave an armed packet intact; only a real detach + enumeration retires it.
static bool armed_in[2] = {false, false}, attached = true;
static unsigned detaches = 0, attaches = 0;
struct Registers { unsigned USBPULLUP = 1; } registers;
#define NRF_USBD (&registers)
static void __ISB() {}
static void __DSB() {}
static bool close_during_rx_sample = false;
static uint32_t millis() { return now_ms; }
static bool tud_mounted() { return mounted; }
@@ -52,8 +56,8 @@ static void tud_cdc_n_read_flush(uint8_t) { ++rx_flushes; rx_count = 0; }
static uint32_t tud_cdc_n_write_clear(uint8_t) { ++tx_clears; tx_count = 0; return 0; }
static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed_in[n]; }
struct UsbDevice {
void detach() { ++detaches; attached = false; }
void attach() { ++attaches; attached = true; }
void detach() { ++detaches; attached = false; registers.USBPULLUP = 0; }
void attach() { ++attaches; attached = true; registers.USBPULLUP = 1; }
} TinyUSBDevice;
static uint32_t tud_cdc_n_write_available(uint8_t) { return 64 - tx_count; }
static uint32_t tud_cdc_n_write(uint8_t, const void*, uint32_t size) {
@@ -167,9 +171,12 @@ int main() {
rx_count = 4;
assert(mesh::isUsbCompanionClientConnected());
line(false);
assert(tx_count == 0 && armed_in[0] && !attached && detaches == 1);
assert(tx_count == 0 && armed_in[0] && registers.USBPULLUP == 0);
assert(attached && detaches == 0); // callback cannot enqueue a stack event
assert(!mesh::isUsbCompanionClientConnected());
assert(mesh::writeTinyUsbCdcOnce(nullptr, bytes, 4) == 0);
mesh::serviceNrf52UsbSessionReenumeration();
assert(!attached && detaches == 1); // application loop performs stack detach
// Repeated control callbacks cannot cause a detach/attach storm.
meshTinyUsbCdcLineCodingChanged(0);
line(false);
+113 -4
View File
@@ -130,6 +130,105 @@ int main() {
'''.replace("@BEGIN@", function("void beginUsbLoggingPort()"))
self.run_native(source)
def test_full_owner_event_queue_defers_stack_detach_but_quarantines_packet(self):
start = USB.index("static std::atomic<uint32_t> primary_usb_reset_generation")
gates = USB[start:USB.index("static void endPrimaryUsbHostSession(", start)]
pinned_driver = (ROOT / "test/fixtures/nrf52_usb_power_original.c").read_text()
start = pinned_driver.index("void dcd_disconnect(")
disconnect = pinned_driver[start:pinned_driver.index("\n}\n", start) + 3]
source = r'''
#include <atomic>
#include <cassert>
#include <cstdint>
#define ENABLE_USB_INTERFACE 1
static bool mounted = true, armed[2] = {false, true};
static uint32_t now = 100, fifo[2] = {0, 32};
static bool owner_callback = false;
static constexpr unsigned DCD_EVENT_UNPLUGGED = 1, queue_capacity = 4;
static unsigned events[queue_capacity] = {0, 0, 0, 0};
static unsigned queue_head = 0, queue_tail = 0, queue_count = queue_capacity;
static unsigned event_sends = 0, detach_count = 0, attach_count = 0;
static unsigned owner_resets = 0;
struct Registers { unsigned USBPULLUP = 1; } registers;
#define NRF_USBD (&registers)
static void __ISB() {}
static void __DSB() {}
static uint32_t millis() { return now; }
static bool tud_mounted() { return mounted; }
static uint32_t tud_cdc_n_available(uint8_t) { return 0; }
static void tud_cdc_n_read_flush(uint8_t) {}
static bool tud_cdc_n_write_clear(uint8_t n) { fifo[n] = 0; return true; }
static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed[n]; }
static void owner_dispatch_one() {
assert(!owner_callback && queue_count != 0);
owner_callback = true;
const unsigned event = events[queue_tail];
queue_tail = (queue_tail + 1) % queue_capacity;
--queue_count;
if (event == DCD_EVENT_UNPLUGGED) {
mounted = false;
armed[0] = armed[1] = false;
++owner_resets;
}
owner_callback = false;
}
static void dcd_event_bus_signal(unsigned port, unsigned event, bool isr) {
assert(port == 0 && event == DCD_EVENT_UNPLUGGED && !isr);
// Real osal_queue_send uses an infinite FreeRTOS timeout here. If called
// by its sole consumer while full, it could never return. Fail immediately
// rather than hanging the test on precisely that regression.
assert(!owner_callback);
++event_sends;
if (queue_count == queue_capacity) {
// A blocked application sender yields to the independent USB owner,
// which drains an existing event and releases a slot for UNPLUGGED.
owner_dispatch_one();
}
assert(queue_count < queue_capacity);
events[queue_head] = event;
queue_head = (queue_head + 1) % queue_capacity;
++queue_count;
}
@DISCONNECT@
struct Device {
void detach() { ++detach_count; dcd_disconnect(0); }
void attach() { ++attach_count; registers.USBPULLUP = 1; }
} TinyUSBDevice;
namespace mesh {
@GATES@
}
int main() {
// The owner is servicing a close/SOF callback while IRQ events fill every
// available queue slot. The old data is armed in the controller, not FIFO.
owner_callback = true;
mesh::clearNrf52UsbTxForSession(1);
assert(queue_count == queue_capacity && event_sends == 0);
assert(registers.USBPULLUP == 0 && armed[1] && fifo[1] == 0);
assert(!mesh::canAccessPrimaryUsbSession(nullptr));
mesh::requestNrf52UsbSessionReenumeration(); // repeated callback is harmless
assert(detach_count == 0 && event_sends == 0);
owner_callback = false;
// A busy application loop can service much later. Its first pass only
// issues the stack detach; it must not reuse an expired callback deadline.
now += 400;
mesh::serviceNrf52UsbSessionReenumeration();
assert(detach_count == 1 && event_sends == 1 && attach_count == 0);
mesh::serviceNrf52UsbSessionReenumeration();
assert(detach_count == 1 && attach_count == 0);
now += 20;
mesh::serviceNrf52UsbSessionReenumeration();
assert(attach_count == 0); // owner has not consumed UNPLUGGED yet
while (queue_count != 0) owner_dispatch_one();
assert(owner_resets == 1 && !mounted && !armed[1]);
mesh::serviceNrf52UsbSessionReenumeration();
assert(attach_count == 1 && registers.USBPULLUP == 1);
mesh::serviceNrf52UsbSessionReenumeration();
assert(detach_count == 1 && attach_count == 1); // recovery is one-shot
}
'''
self.run_native(source.replace("@DISCONNECT@", disconnect).replace("@GATES@", gates))
def test_armed_logging_packet_and_racing_primary_packet_force_clean_bus(self):
start = USB.index("static std::atomic<uint32_t> primary_usb_reset_generation")
end_start = USB.index("static void endPrimaryUsbHostSession(", start)
@@ -142,6 +241,10 @@ int main() {
static bool mounted = true, attached = true, armed[2] = {false, true};
static uint32_t fifo[2] = {0, 32}, now = UINT32_MAX - 10;
static unsigned detach_count = 0, attach_count = 0, reset_count = 0;
struct Registers { unsigned USBPULLUP = 1; } registers;
#define NRF_USBD (&registers)
static void __ISB() {}
static void __DSB() {}
static uint32_t millis() { return now; }
static bool tud_mounted() { return mounted; }
static uint32_t tud_cdc_n_available(uint8_t) { return 0; }
@@ -149,8 +252,8 @@ static void tud_cdc_n_read_flush(uint8_t) {}
static bool tud_cdc_n_write_clear(uint8_t n) { fifo[n] = 0; return true; }
static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed[n]; }
struct Device {
void detach() { ++detach_count; attached = false; }
void attach() { ++attach_count; attached = true; }
void detach() { ++detach_count; attached = false; registers.USBPULLUP = 0; }
void attach() { ++attach_count; attached = true; registers.USBPULLUP = 1; }
} TinyUSBDevice;
namespace mesh {
static void clearUsbLoggingClientActivity() {}
@@ -169,9 +272,12 @@ int main() {
// CDC1 old data is in endpoint RAM, not the software FIFO. No 50-SOF
// waiting/clearing policy can retract it without re-enumeration.
mesh::restartDedicatedUsbLoggingHostSession();
assert(fifo[1] == 0 && armed[1] && !attached && detach_count == 1);
assert(fifo[1] == 0 && armed[1] && registers.USBPULLUP == 0);
assert(attached && detach_count == 0); // no owner-task event send
assert(!mesh::dedicated_usb_logging_port_connected && reset_count == 1);
assert(!mesh::canAccessPrimaryUsbSession(nullptr)); // both ports gated
mesh::serviceNrf52UsbSessionReenumeration();
assert(!attached && detach_count == 1);
mesh::restartDedicatedUsbLoggingHostSession();
assert(detach_count == 1); // repeated control requests do not reset timer
now += 19; mounted = false; armed[1] = false;
@@ -188,8 +294,11 @@ int main() {
mesh::primary_usb_terminal_taken_reset_generation =
mesh::primaryUsbSessionGeneration();
mesh::completePrimaryUsbSessionReset(nullptr);
assert(fifo[0] == 0 && armed[0] && !attached && detach_count == 2);
assert(fifo[0] == 0 && armed[0] && registers.USBPULLUP == 0);
assert(attached && detach_count == 1);
assert(!mesh::canAccessPrimaryUsbSession(nullptr));
mesh::serviceNrf52UsbSessionReenumeration();
assert(!attached && detach_count == 2);
}
'''
source = source.replace("@GATES@", gates)
+24 -4
View File
@@ -17,13 +17,15 @@ int main() {
char reply[160] = {};
assert(cli.prefs.trace_when_repeat_off == 0);
cli.prefs.usb_debug_enabled = 1;
cli.prefs.gps_sync_interval_hours = 336;
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(cli._common_save_succeeded);
const auto baseline = cli.fs.files["/com_prefs"];
assert(baseline.size() == 871);
const size_t trace_offset = baseline.size() - 1;
const size_t debug_offset = trace_offset - 1;
assert(baseline.size() == 873);
const size_t trace_offset = 872, debug_offset = 871, gps_offset = 869;
assert(baseline[trace_offset] == 0 && baseline[debug_offset] == 1);
assert(baseline[866] == 48 && baseline[867] == 0 && baseline[868] == 1);
assert(baseline[gps_offset] == 0x50 && baseline[gps_offset + 1] == 0x01);
Capture capture;
assert(writeCommonPrefsImage(capture, &cli.prefs));
assert(capture.bytes == baseline);
@@ -123,15 +125,32 @@ int main() {
}
// Every old or torn image lacks the complete appended preference and must
// reset prior RAM intent to OFF. The old debug byte retains its old offset.
// reset prior RAM intent to OFF. GPS was published before local debug/trace;
// a complete GPS-only image must not reinterpret either GPS byte as intent.
auto image = enabled;
for (size_t size = 0; size < image.size(); ++size) {
CommonCLI reader;
reader.prefs.trace_when_repeat_off = 1;
reader.prefs.usb_debug_enabled = 1;
reader.fs.files["/com_prefs"] = {image.begin(), image.begin() + size};
reader.loadPrefsInt(&reader.fs, "/com_prefs");
assert(reader.prefs.trace_when_repeat_off == 0);
if (size > debug_offset) assert(reader.prefs.usb_debug_enabled == 1);
else assert(reader.prefs.usb_debug_enabled == 0);
if (size >= gps_offset + 2) assert(reader.prefs.gps_sync_interval_hours == 336);
}
// Explicit old GPS-only files with either byte equal to one must not arm
// the local boolean preferences, regardless of their previous RAM values.
for (uint16_t hours : {1, 256, 257, 336}) {
auto legacy = std::vector<uint8_t>(enabled.begin(), enabled.begin() + 871);
legacy[gps_offset] = uint8_t(hours);
legacy[gps_offset + 1] = uint8_t(hours >> 8);
CommonCLI reader;
reader.prefs.usb_debug_enabled = reader.prefs.trace_when_repeat_off = 1;
reader.fs.files["/com_prefs"] = legacy;
reader.loadPrefsInt(&reader.fs, "/com_prefs");
assert(reader.prefs.gps_sync_interval_hours == hours);
assert(reader.prefs.usb_debug_enabled == 0 && reader.prefs.trace_when_repeat_off == 0);
}
for (uint8_t saved : {0, 1, 2, 255}) {
image[trace_offset] = saved;
@@ -141,6 +160,7 @@ int main() {
reader.loadPrefsInt(&reader.fs, "/com_prefs");
assert(reader.prefs.trace_when_repeat_off == (saved == 1 ? 1 : 0));
assert(reader.prefs.usb_debug_enabled == 1);
assert(reader.prefs.gps_sync_interval_hours == 336);
assert(reader.prefs.freq == 910 && reader.prefs.primary_radio_preamble == 48);
}
cli.prefs.trace_when_repeat_off = 1;
+6 -3
View File
@@ -25,10 +25,12 @@ class UsbDiagnosticLoggingTests(unittest.TestCase):
with tempfile.TemporaryDirectory(prefix="meshcore-usb-diagnostics-") as directory:
temporary = Path(directory)
(temporary / "NetworkLoggingMacro.h").write_text(macro, encoding="utf-8")
for debug in (None, 0, 1):
with self.subTest(mqtt_debug=debug):
for debug, touch_debug in ((None, False), (0, False), (1, False), (1, True)):
with self.subTest(mqtt_debug=debug, touch_debug=touch_debug):
binary = temporary / "usb-diagnostics.exe"
defines = [] if debug is None else [f"-DMQTT_DEBUG={debug}"]
if touch_debug:
defines += ["-DDISPLAY_TOUCH_DEBUG=1", "-DPIN_TOUCH_INT=7"]
compiled = subprocess.run(
[compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-DARDUINO=1", "-DETHERNET_ENABLED=1", *defines,
@@ -45,7 +47,8 @@ class UsbDiagnosticLoggingTests(unittest.TestCase):
self.assertIn("PASS: gated USB diagnostics", checked.stdout)
def test_no_raw_serial_diagnostic_writes(self):
for relative in ("src/helpers/nrf52/EthernetCLI.h", "src/helpers/NetworkLink.cpp"):
for relative in ("src/helpers/nrf52/EthernetCLI.h", "src/helpers/NetworkLink.cpp",
"src/helpers/ui/CHSC6XTouch.h", "src/helpers/ethernet/ch390/CH390Config.h"):
with self.subTest(source=relative):
source = (ROOT / relative).read_text(encoding="utf-8")
self.assertIn("UsbLogging.h", source)