Merge USB session safety and preserve GPS preference compatibility
This commit is contained in:
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();
|
||||
+203
-16
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
@@ -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 (®isters)
|
||||
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);
|
||||
|
||||
@@ -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 (®isters)
|
||||
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 (®isters)
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user